From 0ebc85783996eece813c1ef209c2aedfc9c36810 Mon Sep 17 00:00:00 2001 From: ABKQPO <93412322+ABKQPO@users.noreply.github.com> Date: Sat, 16 May 2026 00:23:18 +0800 Subject: [PATCH 1/5] add runtime websocket bridge --- .../com/hfstudio/guidenh/ClientProxy.java | 17 ++ .../guidenh/bridge/GuideNhRuntimeBridge.java | 26 +++ .../bridge/GuideNhRuntimeBridgeServer.java | 130 +++++++++++++++ .../bridge/GuideNhRuntimeBridgeSettings.java | 67 ++++++++ .../bridge/protocol/BridgeEnvelope.java | 52 ++++++ .../guidenh/bridge/protocol/BridgeError.java | 26 +++ .../bridge/protocol/BridgeMessageCodec.java | 42 +++++ .../bridge/protocol/BridgeProtocolLimits.java | 39 +++++ .../protocol/BridgeResponseFactory.java | 37 +++++ .../security/BridgeTokenAuthenticator.java | 35 ++++ .../bridge/semantic/SemanticCapability.java | 15 ++ .../bridge/semantic/SemanticProvider.java | 8 + .../semantic/SemanticProviderRegistry.java | 30 ++++ .../bridge/semantic/SemanticQuery.java | 35 ++++ .../bridge/semantic/SemanticQueryFactory.java | 70 ++++++++ .../bridge/semantic/SemanticQueryResult.java | 35 ++++ .../providers/RuntimeSemanticProviders.java | 32 ++++ .../providers/StaticSemanticProvider.java | 44 +++++ .../transport/RuntimeBridgeConnection.java | 156 ++++++++++++++++++ .../bridge/transport/WebSocketFrame.java | 32 ++++ .../bridge/transport/WebSocketFrameCodec.java | 121 ++++++++++++++ .../bridge/transport/WebSocketHandshake.java | 112 +++++++++++++ .../hfstudio/guidenh/config/ModConfig.java | 42 +++++ 23 files changed, 1203 insertions(+) create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeSettings.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeEnvelope.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeError.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeMessageCodec.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/security/BridgeTokenAuthenticator.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProviderRegistry.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQuery.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryFactory.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryResult.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StaticSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrame.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrameCodec.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketHandshake.java diff --git a/src/main/java/com/hfstudio/guidenh/ClientProxy.java b/src/main/java/com/hfstudio/guidenh/ClientProxy.java index 185cb1fb..87063298 100644 --- a/src/main/java/com/hfstudio/guidenh/ClientProxy.java +++ b/src/main/java/com/hfstudio/guidenh/ClientProxy.java @@ -5,11 +5,14 @@ import net.minecraftforge.client.ClientCommandHandler; import net.minecraftforge.common.MinecraftForge; +import com.hfstudio.guidenh.bridge.GuideNhRuntimeBridge; +import com.hfstudio.guidenh.bridge.GuideNhRuntimeBridgeSettings; import com.hfstudio.guidenh.client.RegionWandRenderer; import com.hfstudio.guidenh.client.command.GuideNhClientBridgeController; import com.hfstudio.guidenh.client.command.GuideNhClientCommand; import com.hfstudio.guidenh.client.hotkey.OpenGuideHotkey; import com.hfstudio.guidenh.client.hotkey.OpenSceneEditorHotkey; +import com.hfstudio.guidenh.config.ModConfig; import com.hfstudio.guidenh.guide.internal.GuideDevWatcherPump; import com.hfstudio.guidenh.guide.internal.GuideDevelopmentResourcePackWatcher; import com.hfstudio.guidenh.guide.internal.GuideME; @@ -40,6 +43,8 @@ public class ClientProxy extends CommonProxy { + private final GuideNhRuntimeBridge runtimeBridge = new GuideNhRuntimeBridge(); + @Override public void preInit(FMLPreInitializationEvent event) { super.preInit(event); @@ -73,6 +78,17 @@ public void init(FMLInitializationEvent event) { MinecraftForge.EVENT_BUS.register(new RegionWandRenderer()); GuideWarmupPump.init(); MinecraftForge.EVENT_BUS.register(this); + runtimeBridge.start( + new GuideNhRuntimeBridgeSettings( + ModConfig.runtimeBridge.enabled, + ModConfig.runtimeBridge.host, + ModConfig.runtimeBridge.port, + ModConfig.runtimeBridge.token, + ModConfig.runtimeBridge.maxMessageBytes, + ModConfig.runtimeBridge.maxPageSize, + ModConfig.runtimeBridge.maxSubscriptions, + ModConfig.runtimeBridge.maxConnections, + ModConfig.runtimeBridge.maxDeltaEntries)); } @Override @@ -90,6 +106,7 @@ public void completeInit(FMLLoadCompleteEvent event) { @SubscribeEvent public void onClientDisconnect(FMLNetworkEvent.ClientDisconnectionFromServerEvent event) { + runtimeBridge.stop(); GuideME.closeSearch(); for (var guide : GuideRegistry.getAll()) { guide.resetWarmup(); diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java new file mode 100644 index 00000000..7ad17046 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java @@ -0,0 +1,26 @@ +package com.hfstudio.guidenh.bridge; + +public class GuideNhRuntimeBridge { + + private GuideNhRuntimeBridgeServer server; + + public void start(GuideNhRuntimeBridgeSettings settings) { + stop(); + if (!settings.canStart()) { + return; + } + server = new GuideNhRuntimeBridgeServer(settings); + server.start(); + } + + public void stop() { + if (server != null) { + server.stop(); + server = null; + } + } + + public boolean isRunning() { + return server != null && server.isRunning(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java new file mode 100644 index 00000000..99ae476d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java @@ -0,0 +1,130 @@ +package com.hfstudio.guidenh.bridge; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.hfstudio.guidenh.GuideNH; +import com.hfstudio.guidenh.bridge.protocol.BridgeMessageCodec; +import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; +import com.hfstudio.guidenh.bridge.security.BridgeTokenAuthenticator; +import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; +import com.hfstudio.guidenh.bridge.semantic.providers.RuntimeSemanticProviders; +import com.hfstudio.guidenh.bridge.transport.RuntimeBridgeConnection; + +public class GuideNhRuntimeBridgeServer { + + private final GuideNhRuntimeBridgeSettings settings; + private final BridgeProtocolLimits limits; + private final BridgeMessageCodec messageCodec; + private final BridgeTokenAuthenticator authenticator; + private final SemanticProviderRegistry registry = new SemanticProviderRegistry(); + private final Set connections = Collections.synchronizedSet(new HashSet<>()); + private final ExecutorService executor = Executors.newCachedThreadPool(new RuntimeBridgeThreadFactory()); + private final AtomicBoolean running = new AtomicBoolean(); + private ServerSocket serverSocket; + + public GuideNhRuntimeBridgeServer(GuideNhRuntimeBridgeSettings settings) { + this.settings = settings; + this.limits = new BridgeProtocolLimits( + settings.getMaxMessageBytes(), + settings.getMaxPageSize(), + settings.getMaxSubscriptions(), + settings.getMaxConnections(), + settings.getMaxDeltaEntries()); + this.messageCodec = new BridgeMessageCodec(limits); + this.authenticator = new BridgeTokenAuthenticator(settings.getToken()); + RuntimeSemanticProviders.registerBaseline(registry); + } + + public void start() { + if (!settings.canStart() || !running.compareAndSet(false, true)) { + return; + } + try { + serverSocket = new ServerSocket(); + serverSocket.bind(new InetSocketAddress(settings.getHost(), settings.getPort())); + executor.execute(this::acceptConnections); + GuideNH.LOG.info("GuideNH runtime bridge started at ws://{}:{}", settings.getHost(), settings.getPort()); + } catch (IOException e) { + running.set(false); + closeServerSocket(); + GuideNH.LOG.warn("Failed to start GuideNH runtime bridge", e); + } + } + + public void stop() { + if (!running.getAndSet(false)) { + return; + } + closeServerSocket(); + List snapshot; + synchronized (connections) { + snapshot = new ArrayList<>(connections); + connections.clear(); + } + for (RuntimeBridgeConnection connection : snapshot) { + connection.close(); + } + executor.shutdownNow(); + } + + public boolean isRunning() { + return running.get(); + } + + private void acceptConnections() { + while (running.get()) { + try { + Socket socket = serverSocket.accept(); + if (connections.size() >= limits.getMaxConnections()) { + socket.close(); + continue; + } + RuntimeBridgeConnection connection = new RuntimeBridgeConnection( + socket, + messageCodec, + authenticator, + registry, + limits, + connections::remove); + connections.add(connection); + executor.execute(connection); + } catch (IOException e) { + if (running.get()) { + GuideNH.LOG.warn("GuideNH runtime bridge accept loop failed", e); + } + } + } + } + + private void closeServerSocket() { + try { + if (serverSocket != null) { + serverSocket.close(); + } + } catch (IOException ignored) {} + } + + public static class RuntimeBridgeThreadFactory implements ThreadFactory { + + private int nextThreadId; + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "GuideNH-RuntimeBridge-" + nextThreadId++); + thread.setDaemon(true); + return thread; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeSettings.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeSettings.java new file mode 100644 index 00000000..34baca28 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeSettings.java @@ -0,0 +1,67 @@ +package com.hfstudio.guidenh.bridge; + +public class GuideNhRuntimeBridgeSettings { + + private final boolean enabled; + private final String host; + private final int port; + private final String token; + private final int maxMessageBytes; + private final int maxPageSize; + private final int maxSubscriptions; + private final int maxConnections; + private final int maxDeltaEntries; + + public GuideNhRuntimeBridgeSettings(boolean enabled, String host, int port, String token, int maxMessageBytes, + int maxPageSize, int maxSubscriptions, int maxConnections, int maxDeltaEntries) { + this.enabled = enabled; + this.host = host == null ? "" : host.trim(); + this.port = port; + this.token = token == null ? "" : token; + this.maxMessageBytes = maxMessageBytes; + this.maxPageSize = maxPageSize; + this.maxSubscriptions = maxSubscriptions; + this.maxConnections = maxConnections; + this.maxDeltaEntries = maxDeltaEntries; + } + + public boolean canStart() { + return enabled && !host.isEmpty() && port > 0 && port <= 65535 && !token.isEmpty(); + } + + public boolean isEnabled() { + return enabled; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public String getToken() { + return token; + } + + public int getMaxMessageBytes() { + return maxMessageBytes; + } + + public int getMaxPageSize() { + return maxPageSize; + } + + public int getMaxSubscriptions() { + return maxSubscriptions; + } + + public int getMaxConnections() { + return maxConnections; + } + + public int getMaxDeltaEntries() { + return maxDeltaEntries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeEnvelope.java b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeEnvelope.java new file mode 100644 index 00000000..4f575a69 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeEnvelope.java @@ -0,0 +1,52 @@ +package com.hfstudio.guidenh.bridge.protocol; + +import com.google.gson.JsonObject; + +public class BridgeEnvelope { + + private String id; + private String type; + private String method; + private int protocol; + private JsonObject payload; + + public String getId() { + return id; + } + + public String getType() { + return type; + } + + public String getMethod() { + return method; + } + + public int getProtocol() { + return protocol; + } + + public JsonObject getPayload() { + return payload; + } + + public static BridgeEnvelope response(String id, String method, JsonObject payload) { + BridgeEnvelope envelope = new BridgeEnvelope(); + envelope.id = id; + envelope.type = "response"; + envelope.method = method; + envelope.protocol = 1; + envelope.payload = payload; + return envelope; + } + + public static BridgeEnvelope error(String id, String method, JsonObject payload) { + BridgeEnvelope envelope = new BridgeEnvelope(); + envelope.id = id; + envelope.type = "error"; + envelope.method = method; + envelope.protocol = 1; + envelope.payload = payload; + return envelope; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeError.java b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeError.java new file mode 100644 index 00000000..18b10ba9 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeError.java @@ -0,0 +1,26 @@ +package com.hfstudio.guidenh.bridge.protocol; + +public class BridgeError { + + private final String code; + private final String message; + private final boolean retryable; + + public BridgeError(String code, String message, boolean retryable) { + this.code = code; + this.message = message; + this.retryable = retryable; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public boolean isRetryable() { + return retryable; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeMessageCodec.java b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeMessageCodec.java new file mode 100644 index 00000000..3cc84116 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeMessageCodec.java @@ -0,0 +1,42 @@ +package com.hfstudio.guidenh.bridge.protocol; + +import java.nio.charset.StandardCharsets; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +public class BridgeMessageCodec { + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping() + .create(); + + private final BridgeProtocolLimits limits; + + public BridgeMessageCodec(BridgeProtocolLimits limits) { + this.limits = limits; + } + + public BridgeEnvelope decode(String message) { + if (message == null) { + throw new IllegalArgumentException("Message must not be null"); + } + if (message.getBytes(StandardCharsets.UTF_8).length > limits.getMaxMessageBytes()) { + throw new IllegalArgumentException("Message exceeds maximum size"); + } + BridgeEnvelope envelope = GSON.fromJson(message, BridgeEnvelope.class); + if (envelope == null || envelope.getMethod() == null + || envelope.getType() == null + || envelope.getProtocol() != 1) { + throw new IllegalArgumentException("Invalid bridge envelope"); + } + return envelope; + } + + public String encode(Object value) { + String json = GSON.toJson(value); + if (json.getBytes(StandardCharsets.UTF_8).length > limits.getMaxMessageBytes()) { + throw new IllegalArgumentException("Encoded message exceeds maximum size"); + } + return json; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java new file mode 100644 index 00000000..80a0d0f3 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.bridge.protocol; + +public class BridgeProtocolLimits { + + private final int maxMessageBytes; + private final int maxPageSize; + private final int maxSubscriptions; + private final int maxConnections; + private final int maxDeltaEntries; + + public BridgeProtocolLimits(int maxMessageBytes, int maxPageSize, int maxSubscriptions, int maxConnections, + int maxDeltaEntries) { + this.maxMessageBytes = maxMessageBytes; + this.maxPageSize = maxPageSize; + this.maxSubscriptions = maxSubscriptions; + this.maxConnections = maxConnections; + this.maxDeltaEntries = maxDeltaEntries; + } + + public int getMaxMessageBytes() { + return maxMessageBytes; + } + + public int getMaxPageSize() { + return maxPageSize; + } + + public int getMaxSubscriptions() { + return maxSubscriptions; + } + + public int getMaxConnections() { + return maxConnections; + } + + public int getMaxDeltaEntries() { + return maxDeltaEntries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java new file mode 100644 index 00000000..930d68f5 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java @@ -0,0 +1,37 @@ +package com.hfstudio.guidenh.bridge.protocol; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +public class BridgeResponseFactory { + + private static final Gson GSON = new Gson(); + + public BridgeEnvelope hello(String id, BridgeProtocolLimits limits) { + JsonObject payload = new JsonObject(); + payload.addProperty("serverName", "GuideNH"); + payload.addProperty("protocol", 1); + payload.add("limits", GSON.toJsonTree(limits)); + return BridgeEnvelope.response(id, "hello", payload); + } + + public BridgeEnvelope semanticResult(String id, String method, Object result) { + return BridgeEnvelope.response( + id, + method, + GSON.toJsonTree(result) + .getAsJsonObject()); + } + + public BridgeEnvelope capabilities(String id, Object capabilities) { + JsonObject payload = new JsonObject(); + payload.add("capabilities", GSON.toJsonTree(capabilities)); + return BridgeEnvelope.response(id, "capabilities", payload); + } + + public BridgeEnvelope error(String id, String method, String code, String message, boolean retryable) { + JsonObject payload = GSON.toJsonTree(new BridgeError(code, message, retryable)) + .getAsJsonObject(); + return BridgeEnvelope.error(id, method, payload); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/security/BridgeTokenAuthenticator.java b/src/main/java/com/hfstudio/guidenh/bridge/security/BridgeTokenAuthenticator.java new file mode 100644 index 00000000..d4947b3f --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/security/BridgeTokenAuthenticator.java @@ -0,0 +1,35 @@ +package com.hfstudio.guidenh.bridge.security; + +public class BridgeTokenAuthenticator { + + private final String token; + + public BridgeTokenAuthenticator(String token) { + this.token = token == null ? "" : token; + } + + public boolean matches(String candidate) { + if (token.isEmpty() || candidate == null) { + return false; + } + return constantTimeEquals(token, candidate); + } + + public String redact(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + return "[redacted]"; + } + + private boolean constantTimeEquals(String left, String right) { + int result = left.length() ^ right.length(); + int max = Math.max(left.length(), right.length()); + for (int index = 0; index < max; index++) { + char leftChar = index < left.length() ? left.charAt(index) : 0; + char rightChar = index < right.length() ? right.charAt(index) : 0; + result |= leftChar ^ rightChar; + } + return result == 0; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java new file mode 100644 index 00000000..a5e4e933 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java @@ -0,0 +1,15 @@ +package com.hfstudio.guidenh.bridge.semantic; + +public class SemanticCapability { + + public static final String ITEMS = "items"; + public static final String ORES = "ores"; + public static final String SOUNDS = "sounds"; + public static final String KEYBINDS = "keybinds"; + public static final String RECIPES = "recipes"; + public static final String QUESTS = "quests"; + public static final String STRUCTURELIB = "structurelib"; + public static final String PAGES = "pages"; + + public SemanticCapability() {} +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProvider.java new file mode 100644 index 00000000..abb5e2b7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProvider.java @@ -0,0 +1,8 @@ +package com.hfstudio.guidenh.bridge.semantic; + +public interface SemanticProvider { + + String getCapability(); + + SemanticQueryResult query(SemanticQuery query); +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProviderRegistry.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProviderRegistry.java new file mode 100644 index 00000000..982cbbf0 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticProviderRegistry.java @@ -0,0 +1,30 @@ +package com.hfstudio.guidenh.bridge.semantic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class SemanticProviderRegistry { + + private final Map providers = new HashMap<>(); + + public void register(SemanticProvider provider) { + providers.put(provider.getCapability(), provider); + } + + public List getCapabilities() { + List capabilities = new ArrayList<>(providers.keySet()); + Collections.sort(capabilities); + return capabilities; + } + + public SemanticQueryResult query(String capability, SemanticQuery query) { + SemanticProvider provider = providers.get(capability); + if (provider == null) { + throw new IllegalArgumentException("Unknown capability"); + } + return provider.query(query); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQuery.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQuery.java new file mode 100644 index 00000000..ba35890f --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQuery.java @@ -0,0 +1,35 @@ +package com.hfstudio.guidenh.bridge.semantic; + +import java.util.Collections; +import java.util.Map; + +public class SemanticQuery { + + private final String cursor; + private final int limit; + private final String prefix; + private final Map filters; + + public SemanticQuery(String cursor, int limit, String prefix, Map filters) { + this.cursor = cursor == null ? "" : cursor; + this.limit = limit; + this.prefix = prefix == null ? "" : prefix; + this.filters = filters == null ? Collections.emptyMap() : filters; + } + + public String getCursor() { + return cursor; + } + + public int getLimit() { + return limit; + } + + public String getPrefix() { + return prefix; + } + + public Map getFilters() { + return filters; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryFactory.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryFactory.java new file mode 100644 index 00000000..99660f10 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryFactory.java @@ -0,0 +1,70 @@ +package com.hfstudio.guidenh.bridge.semantic; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; + +public class SemanticQueryFactory { + + private final BridgeProtocolLimits limits; + + public SemanticQueryFactory(BridgeProtocolLimits limits) { + this.limits = limits; + } + + public String readCapability(JsonObject payload) { + return readString(payload, "capability", ""); + } + + public SemanticQuery fromPayload(JsonObject payload) { + int requestedLimit = readInt(payload, "limit", limits.getMaxPageSize()); + int limit = Math.max(0, Math.min(requestedLimit, limits.getMaxPageSize())); + return new SemanticQuery( + readString(payload, "cursor", ""), + limit, + readString(payload, "prefix", ""), + readFilters(payload)); + } + + private Map readFilters(JsonObject payload) { + if (payload == null || !payload.has("filters") + || !payload.get("filters") + .isJsonObject()) { + return Collections.emptyMap(); + } + + Map filters = new HashMap<>(); + for (Map.Entry entry : payload.getAsJsonObject("filters") + .entrySet()) { + JsonElement value = entry.getValue(); + if (value != null && value.isJsonPrimitive()) { + filters.put(entry.getKey(), value.getAsString()); + } + } + return filters; + } + + private String readString(JsonObject payload, String name, String defaultValue) { + if (payload == null || !payload.has(name)) { + return defaultValue; + } + JsonElement value = payload.get(name); + return value != null && value.isJsonPrimitive() ? value.getAsString() : defaultValue; + } + + private int readInt(JsonObject payload, String name, int defaultValue) { + if (payload == null || !payload.has(name)) { + return defaultValue; + } + JsonElement value = payload.get(name); + try { + return value != null && value.isJsonPrimitive() ? value.getAsInt() : defaultValue; + } catch (NumberFormatException ignored) { + return defaultValue; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryResult.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryResult.java new file mode 100644 index 00000000..36a13105 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticQueryResult.java @@ -0,0 +1,35 @@ +package com.hfstudio.guidenh.bridge.semantic; + +import java.util.List; +import java.util.Map; + +public class SemanticQueryResult { + + private final String capability; + private final int version; + private final List> entries; + private final String nextCursor; + + public SemanticQueryResult(String capability, int version, List> entries, String nextCursor) { + this.capability = capability; + this.version = version; + this.entries = entries; + this.nextCursor = nextCursor; + } + + public String getCapability() { + return capability; + } + + public int getVersion() { + return version; + } + + public List> getEntries() { + return entries; + } + + public String getNextCursor() { + return nextCursor; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java new file mode 100644 index 00000000..fc9b582e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java @@ -0,0 +1,32 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; +import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; + +public class RuntimeSemanticProviders { + + public static void registerBaseline(SemanticProviderRegistry registry) { + registry.register( + new StaticSemanticProvider( + SemanticCapability.ITEMS, + Collections.singletonList(entry("minecraft:stone", "Stone")))); + registry.register(new StaticSemanticProvider(SemanticCapability.PAGES, Collections.emptyList())); + registry.register(new StaticSemanticProvider(SemanticCapability.ORES, Collections.emptyList())); + registry.register(new StaticSemanticProvider(SemanticCapability.SOUNDS, Collections.emptyList())); + registry.register(new StaticSemanticProvider(SemanticCapability.KEYBINDS, Collections.emptyList())); + registry.register(new StaticSemanticProvider(SemanticCapability.RECIPES, Collections.emptyList())); + registry.register(new StaticSemanticProvider(SemanticCapability.QUESTS, Collections.emptyList())); + registry.register(new StaticSemanticProvider(SemanticCapability.STRUCTURELIB, Collections.emptyList())); + } + + private static Map entry(String id, String label) { + Map entry = new HashMap<>(); + entry.put("id", id); + entry.put("label", label); + return entry; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StaticSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StaticSemanticProvider.java new file mode 100644 index 00000000..51aad975 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StaticSemanticProvider.java @@ -0,0 +1,44 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; +import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; +import com.hfstudio.guidenh.bridge.semantic.SemanticQueryResult; + +public class StaticSemanticProvider implements SemanticProvider { + + private final String capability; + private final List> entries; + + public StaticSemanticProvider(String capability, List> entries) { + this.capability = capability; + this.entries = entries; + } + + @Override + public String getCapability() { + return capability; + } + + @Override + public SemanticQueryResult query(SemanticQuery query) { + List> filtered = new ArrayList<>(); + String prefix = query.getPrefix(); + for (Map entry : entries) { + String id = entry.get("id"); + if (prefix.isEmpty() || id != null && id.startsWith(prefix)) { + filtered.add(entry); + } + } + int limit = Math.max(0, query.getLimit()); + int end = limit == 0 ? filtered.size() : Math.min(filtered.size(), limit); + return new SemanticQueryResult( + capability, + 1, + filtered.subList(0, end), + end < filtered.size() ? String.valueOf(end) : null); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java new file mode 100644 index 00000000..905313ed --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java @@ -0,0 +1,156 @@ +package com.hfstudio.guidenh.bridge.transport; + +import java.io.IOException; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import com.hfstudio.guidenh.bridge.protocol.BridgeEnvelope; +import com.hfstudio.guidenh.bridge.protocol.BridgeMessageCodec; +import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; +import com.hfstudio.guidenh.bridge.protocol.BridgeResponseFactory; +import com.hfstudio.guidenh.bridge.security.BridgeTokenAuthenticator; +import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; +import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; +import com.hfstudio.guidenh.bridge.semantic.SemanticQueryFactory; + +public class RuntimeBridgeConnection implements Runnable { + + private static final int SOCKET_TIMEOUT_MILLIS = 30000; + + private final Socket socket; + private final BridgeMessageCodec messageCodec; + private final WebSocketFrameCodec frameCodec; + private final BridgeTokenAuthenticator authenticator; + private final SemanticProviderRegistry registry; + private final BridgeProtocolLimits limits; + private final Consumer closeCallback; + private final AtomicBoolean closed = new AtomicBoolean(); + private final BridgeResponseFactory responseFactory = new BridgeResponseFactory(); + private final SemanticQueryFactory queryFactory; + private boolean authenticated; + + public RuntimeBridgeConnection(Socket socket, BridgeMessageCodec messageCodec, + BridgeTokenAuthenticator authenticator, SemanticProviderRegistry registry, BridgeProtocolLimits limits, + Consumer closeCallback) { + this.socket = socket; + this.messageCodec = messageCodec; + this.frameCodec = new WebSocketFrameCodec(limits.getMaxMessageBytes()); + this.authenticator = authenticator; + this.registry = registry; + this.limits = limits; + this.closeCallback = closeCallback; + this.queryFactory = new SemanticQueryFactory(limits); + } + + @Override + public void run() { + try { + socket.setSoTimeout(SOCKET_TIMEOUT_MILLIS); + if (!new WebSocketHandshake().accept(socket.getInputStream(), socket.getOutputStream())) { + return; + } + readFrames(); + } catch (SocketTimeoutException ignored) { + closeQuietly(); + } catch (IOException ignored) { + closeQuietly(); + } finally { + close(); + } + } + + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + closeQuietly(); + closeCallback.accept(this); + } + + private void readFrames() throws IOException { + while (!closed.get()) { + WebSocketFrame frame = frameCodec.read(socket.getInputStream()); + if (frame.isClose()) { + frameCodec.writeClose(socket.getOutputStream()); + return; + } + if (frame.isPing()) { + frameCodec.writePong(socket.getOutputStream(), frame.getPayload()); + continue; + } + if (frame.isText()) { + handleText(new String(frame.getPayload(), StandardCharsets.UTF_8)); + } + } + } + + private void handleText(String text) throws IOException { + BridgeEnvelope envelope; + try { + envelope = messageCodec.decode(text); + BridgeEnvelope response = dispatch(envelope); + if (response != null) { + frameCodec.writeText(socket.getOutputStream(), messageCodec.encode(response)); + } + } catch (IllegalArgumentException e) { + BridgeEnvelope error = responseFactory.error(null, "unknown", "invalid_request", e.getMessage(), false); + frameCodec.writeText(socket.getOutputStream(), messageCodec.encode(error)); + } + } + + private BridgeEnvelope dispatch(BridgeEnvelope envelope) { + if ("hello".equals(envelope.getMethod())) { + return handleHello(envelope); + } + if (!authenticated) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "unauthorized", "Bridge token is required", false); + } + if ("semantic.query".equals(envelope.getMethod())) { + return handleSemanticQuery(envelope); + } + if ("capabilities".equals(envelope.getMethod())) { + return responseFactory.capabilities(envelope.getId(), registry.getCapabilities()); + } + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "unknown_method", "Unknown bridge method", false); + } + + private BridgeEnvelope handleHello(BridgeEnvelope envelope) { + String token = envelope.getPayload() != null && envelope.getPayload() + .has("token") ? envelope.getPayload() + .get("token") + .getAsString() : ""; + if (!authenticator.matches(token)) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "unauthorized", "Invalid bridge token", false); + } + authenticated = true; + return responseFactory.hello(envelope.getId(), limits); + } + + private BridgeEnvelope handleSemanticQuery(BridgeEnvelope envelope) { + String capability = queryFactory.readCapability(envelope.getPayload()); + if (capability.isEmpty()) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "invalid_capability", "Capability is required", false); + } + try { + SemanticQuery query = queryFactory.fromPayload(envelope.getPayload()); + return responseFactory + .semanticResult(envelope.getId(), envelope.getMethod(), registry.query(capability, query)); + } catch (IllegalArgumentException e) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "invalid_capability", e.getMessage(), false); + } + } + + private void closeQuietly() { + try { + socket.close(); + } catch (IOException ignored) {} + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrame.java b/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrame.java new file mode 100644 index 00000000..00fdb792 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrame.java @@ -0,0 +1,32 @@ +package com.hfstudio.guidenh.bridge.transport; + +public class WebSocketFrame { + + private final int opcode; + private final byte[] payload; + + public WebSocketFrame(int opcode, byte[] payload) { + this.opcode = opcode; + this.payload = payload == null ? new byte[0] : payload; + } + + public int getOpcode() { + return opcode; + } + + public byte[] getPayload() { + return payload; + } + + public boolean isText() { + return opcode == 1; + } + + public boolean isClose() { + return opcode == 8; + } + + public boolean isPing() { + return opcode == 9; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrameCodec.java b/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrameCodec.java new file mode 100644 index 00000000..ace0ff8a --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketFrameCodec.java @@ -0,0 +1,121 @@ +package com.hfstudio.guidenh.bridge.transport; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +public class WebSocketFrameCodec { + + private static final int FIN = 0x80; + private static final int MASK = 0x80; + private static final int SHORT_LENGTH = 126; + private static final int LONG_LENGTH = 127; + private static final int MAX_CONTROL_PAYLOAD_BYTES = 125; + + private final int maxPayloadBytes; + + public WebSocketFrameCodec(int maxPayloadBytes) { + this.maxPayloadBytes = maxPayloadBytes; + } + + public WebSocketFrame read(InputStream input) throws IOException { + int first = input.read(); + if (first < 0) { + throw new EOFException("WebSocket connection closed"); + } + int second = readByte(input); + int opcode = first & 0x0F; + boolean masked = (second & MASK) != 0; + long payloadLength = second & 0x7F; + if (payloadLength == SHORT_LENGTH) { + payloadLength = readUnsignedShort(input); + } else if (payloadLength == LONG_LENGTH) { + payloadLength = readLong(input); + } + if (payloadLength > maxPayloadBytes) { + throw new IOException("WebSocket frame exceeds maximum size"); + } + + byte[] mask = masked ? readBytes(input, 4) : new byte[0]; + byte[] payload = readBytes(input, (int) payloadLength); + if (masked) { + for (int index = 0; index < payload.length; index++) { + payload[index] = (byte) (payload[index] ^ mask[index % 4]); + } + } + return new WebSocketFrame(opcode, payload); + } + + public void writeText(OutputStream output, String message) throws IOException { + write(output, 1, message.getBytes(StandardCharsets.UTF_8)); + } + + public void writePong(OutputStream output, byte[] payload) throws IOException { + byte[] safePayload = payload.length <= MAX_CONTROL_PAYLOAD_BYTES ? payload : new byte[0]; + write(output, 10, safePayload); + } + + public void writeClose(OutputStream output) throws IOException { + write(output, 8, new byte[0]); + } + + private void write(OutputStream output, int opcode, byte[] payload) throws IOException { + if (payload.length > maxPayloadBytes) { + throw new IOException("WebSocket response exceeds maximum size"); + } + output.write(FIN | opcode); + if (payload.length < SHORT_LENGTH) { + output.write(payload.length); + } else if (payload.length <= 65535) { + output.write(SHORT_LENGTH); + output.write( + ByteBuffer.allocate(2) + .putShort((short) payload.length) + .array()); + } else { + output.write(LONG_LENGTH); + output.write( + ByteBuffer.allocate(8) + .putLong(payload.length) + .array()); + } + output.write(payload); + output.flush(); + } + + private int readByte(InputStream input) throws IOException { + int value = input.read(); + if (value < 0) { + throw new EOFException("WebSocket connection closed"); + } + return value; + } + + private int readUnsignedShort(InputStream input) throws IOException { + return readByte(input) << 8 | readByte(input); + } + + private long readLong(InputStream input) throws IOException { + long result = 0L; + for (int index = 0; index < 8; index++) { + result = result << 8 | readByte(input); + } + return result; + } + + private byte[] readBytes(InputStream input, int length) throws IOException { + byte[] bytes = new byte[length]; + int offset = 0; + while (offset < length) { + int read = input.read(bytes, offset, length - offset); + if (read < 0) { + throw new EOFException("WebSocket connection closed"); + } + offset += read; + } + return bytes; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketHandshake.java b/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketHandshake.java new file mode 100644 index 00000000..a3458298 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/WebSocketHandshake.java @@ -0,0 +1,112 @@ +package com.hfstudio.guidenh.bridge.transport; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +public class WebSocketHandshake { + + private static final String MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + public boolean accept(InputStream input, OutputStream output) throws IOException { + String request = readHttpHeader(input); + String[] lines = request.split("\r\n"); + String requestLine = lines.length > 0 ? lines[0] : ""; + if (requestLine == null || !requestLine.startsWith("GET ")) { + writeHttpError(output, 400); + return false; + } + + Map headers = readHeaders(lines); + String key = headers.get("sec-websocket-key"); + if (key == null || key.isEmpty() || !isUpgrade(headers)) { + writeHttpError(output, 400); + return false; + } + + String response = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + + acceptKey(key) + + "\r\n" + + "\r\n"; + output.write(response.getBytes(StandardCharsets.US_ASCII)); + output.flush(); + return true; + } + + private String readHttpHeader(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int previousThird = -1; + int previousSecond = -1; + int previous = -1; + int current; + while ((current = input.read()) >= 0) { + output.write(current); + if (previousThird == '\r' && previousSecond == '\n' && previous == '\r' && current == '\n') { + break; + } + if (output.size() > 8192) { + throw new IOException("WebSocket handshake exceeds maximum size"); + } + previousThird = previousSecond; + previousSecond = previous; + previous = current; + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + + private Map readHeaders(String[] lines) { + Map headers = new LinkedHashMap<>(); + for (int index = 1; index < lines.length; index++) { + String line = lines[index]; + if (line.isEmpty()) { + break; + } + int separator = line.indexOf(':'); + if (separator > 0) { + headers.put( + line.substring(0, separator) + .trim() + .toLowerCase(Locale.ROOT), + line.substring(separator + 1) + .trim()); + } + } + return headers; + } + + private boolean isUpgrade(Map headers) { + String upgrade = headers.get("upgrade"); + String connection = headers.get("connection"); + return upgrade != null && "websocket".equalsIgnoreCase(upgrade) + && connection != null + && connection.toLowerCase(Locale.ROOT) + .contains("upgrade"); + } + + private String acceptKey(String key) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + byte[] hash = digest.digest((key + MAGIC).getBytes(StandardCharsets.US_ASCII)); + return Base64.getEncoder() + .encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-1 digest is unavailable", e); + } + } + + private void writeHttpError(OutputStream output, int status) throws IOException { + String response = "HTTP/1.1 " + status + " Bad Request\r\nConnection: close\r\n\r\n"; + output.write(response.getBytes(StandardCharsets.US_ASCII)); + output.flush(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/config/ModConfig.java b/src/main/java/com/hfstudio/guidenh/config/ModConfig.java index 11a90d2d..60646b19 100644 --- a/src/main/java/com/hfstudio/guidenh/config/ModConfig.java +++ b/src/main/java/com/hfstudio/guidenh/config/ModConfig.java @@ -22,6 +22,7 @@ public static void registerConfig() throws ConfigException { public static final Debug debug = new Debug(); public static final Ui ui = new Ui(); + public static final RuntimeBridge runtimeBridge = new RuntimeBridge(); @Comment("Debug section") public static class Debug { @@ -181,6 +182,47 @@ public static class Ui { public int sceneWheelInteractionDelayMillis = 750; } + @Comment("Runtime bridge section. The bridge is disabled by default and requires a client restart.") + public static class RuntimeBridge { + + @Comment("Whether the GuideNH runtime bridge WebSocket server is enabled. Default: false.") + @DefaultBoolean(false) + @RequiresMcRestart + public boolean enabled = false; + + @Comment("Runtime bridge host. No default is provided; set this explicitly when enabling the bridge.") + @RequiresMcRestart + public String host = ""; + + @Comment("Runtime bridge port. No default is provided; set this explicitly when enabling the bridge.") + @RequiresMcRestart + public int port = 0; + + @Comment("Runtime bridge authentication token. Empty values prevent the bridge from starting.") + @RequiresMcRestart + public String token = ""; + + @Comment("Maximum accepted WebSocket message size in bytes.") + @RequiresMcRestart + public int maxMessageBytes = 262144; + + @Comment("Maximum semantic query page size.") + @RequiresMcRestart + public int maxPageSize = 200; + + @Comment("Maximum subscriptions per connection.") + @RequiresMcRestart + public int maxSubscriptions = 16; + + @Comment("Maximum concurrent runtime bridge connections.") + @RequiresMcRestart + public int maxConnections = 2; + + @Comment("Maximum semantic delta entries sent in one message.") + @RequiresMcRestart + public int maxDeltaEntries = 200; + } + public static void save() { try { ConfigurationManager.save(ModConfig.class); From 294a3b661b3737d538b6c75ab473c5d989eb54bf Mon Sep 17 00:00:00 2001 From: ABKQPO <93412322+ABKQPO@users.noreply.github.com> Date: Sat, 16 May 2026 21:39:26 +0800 Subject: [PATCH 2/5] add log --- .../com/hfstudio/guidenh/ClientProxy.java | 8 + .../guidenh/bridge/GuideNhRuntimeBridge.java | 20 + .../bridge/GuideNhRuntimeBridgeServer.java | 29 +- .../bridge/semantic/SemanticCapability.java | 3 + .../AbstractCollectionSemanticProvider.java | 151 +++++ .../providers/AliasSemanticProvider.java | 27 + .../providers/RuntimeSemanticProviders.java | 625 +++++++++++++++++- .../transport/RuntimeBridgeConnection.java | 23 +- 8 files changed, 870 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AbstractCollectionSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AliasSemanticProvider.java diff --git a/src/main/java/com/hfstudio/guidenh/ClientProxy.java b/src/main/java/com/hfstudio/guidenh/ClientProxy.java index 87063298..aa7d153a 100644 --- a/src/main/java/com/hfstudio/guidenh/ClientProxy.java +++ b/src/main/java/com/hfstudio/guidenh/ClientProxy.java @@ -78,6 +78,13 @@ public void init(FMLInitializationEvent event) { MinecraftForge.EVENT_BUS.register(new RegionWandRenderer()); GuideWarmupPump.init(); MinecraftForge.EVENT_BUS.register(this); + GuideNH.LOG.info( + "GuideNH runtime bridge configuration loaded. enabled={}, hostConfigured={}, port={}, tokenConfigured={}", + ModConfig.runtimeBridge.enabled, + ModConfig.runtimeBridge.host != null && !ModConfig.runtimeBridge.host.trim() + .isEmpty(), + ModConfig.runtimeBridge.port, + ModConfig.runtimeBridge.token != null && !ModConfig.runtimeBridge.token.isEmpty()); runtimeBridge.start( new GuideNhRuntimeBridgeSettings( ModConfig.runtimeBridge.enabled, @@ -106,6 +113,7 @@ public void completeInit(FMLLoadCompleteEvent event) { @SubscribeEvent public void onClientDisconnect(FMLNetworkEvent.ClientDisconnectionFromServerEvent event) { + GuideNH.LOG.info("Minecraft client disconnected. Stopping GuideNH runtime bridge session state"); runtimeBridge.stop(); GuideME.closeSearch(); for (var guide : GuideRegistry.getAll()) { diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java index 7ad17046..274e5b8b 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java @@ -1,5 +1,7 @@ package com.hfstudio.guidenh.bridge; +import com.hfstudio.guidenh.GuideNH; + public class GuideNhRuntimeBridge { private GuideNhRuntimeBridgeServer server; @@ -7,14 +9,32 @@ public class GuideNhRuntimeBridge { public void start(GuideNhRuntimeBridgeSettings settings) { stop(); if (!settings.canStart()) { + GuideNH.LOG.info( + "GuideNH runtime bridge start skipped. enabled={}, hostConfigured={}, portConfigured={}, tokenConfigured={}", + settings.isEnabled(), + !settings.getHost() + .isEmpty(), + settings.getPort() > 0, + !settings.getToken() + .isEmpty()); return; } + GuideNH.LOG.info( + "Starting GuideNH runtime bridge. host={}, port={}, maxConnections={}, maxMessageBytes={}, maxPageSize={}, maxSubscriptions={}, maxDeltaEntries={}", + settings.getHost(), + settings.getPort(), + settings.getMaxConnections(), + settings.getMaxMessageBytes(), + settings.getMaxPageSize(), + settings.getMaxSubscriptions(), + settings.getMaxDeltaEntries()); server = new GuideNhRuntimeBridgeServer(settings); server.start(); } public void stop() { if (server != null) { + GuideNH.LOG.info("Stopping GuideNH runtime bridge"); server.stop(); server = null; } diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java index 99ae476d..5feded17 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java @@ -52,6 +52,7 @@ public void start() { return; } try { + GuideNH.LOG.info("Binding GuideNH runtime bridge server to {}:{}", settings.getHost(), settings.getPort()); serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(settings.getHost(), settings.getPort())); executor.execute(this::acceptConnections); @@ -67,6 +68,7 @@ public void stop() { if (!running.getAndSet(false)) { return; } + GuideNH.LOG.info("GuideNH runtime bridge server stopping"); closeServerSocket(); List snapshot; synchronized (connections) { @@ -87,7 +89,13 @@ private void acceptConnections() { while (running.get()) { try { Socket socket = serverSocket.accept(); + String remoteAddress = describeRemote(socket); + GuideNH.LOG.info("GuideNH runtime bridge accepted socket from {}", remoteAddress); if (connections.size() >= limits.getMaxConnections()) { + GuideNH.LOG.warn( + "GuideNH runtime bridge rejected socket from {} because maxConnections={} has been reached", + remoteAddress, + limits.getMaxConnections()); socket.close(); continue; } @@ -97,8 +105,12 @@ private void acceptConnections() { authenticator, registry, limits, - connections::remove); + this::handleClosedConnection); connections.add(connection); + GuideNH.LOG.info( + "GuideNH runtime bridge starting session for {}. activeConnections={}", + remoteAddress, + connections.size()); executor.execute(connection); } catch (IOException e) { if (running.get()) { @@ -116,6 +128,21 @@ private void closeServerSocket() { } catch (IOException ignored) {} } + private String describeRemote(Socket socket) { + if (socket == null || socket.getRemoteSocketAddress() == null) { + return "unknown"; + } + return String.valueOf(socket.getRemoteSocketAddress()); + } + + private void handleClosedConnection(RuntimeBridgeConnection connection) { + connections.remove(connection); + GuideNH.LOG.info( + "GuideNH runtime bridge session closed for {}. activeConnections={}", + connection.getRemoteAddress(), + connections.size()); + } + public static class RuntimeBridgeThreadFactory implements ThreadFactory { private int nextThreadId; diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java index a5e4e933..85671e73 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java @@ -4,6 +4,9 @@ public class SemanticCapability { public static final String ITEMS = "items"; public static final String ORES = "ores"; + public static final String CATEGORIES = "categories"; + public static final String MODS = "mods"; + public static final String COMMANDS = "commands"; public static final String SOUNDS = "sounds"; public static final String KEYBINDS = "keybinds"; public static final String RECIPES = "recipes"; diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AbstractCollectionSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AbstractCollectionSemanticProvider.java new file mode 100644 index 00000000..a2fea344 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AbstractCollectionSemanticProvider.java @@ -0,0 +1,151 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; +import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; +import com.hfstudio.guidenh.bridge.semantic.SemanticQueryResult; + +public abstract class AbstractCollectionSemanticProvider implements SemanticProvider { + + private final String capability; + + public AbstractCollectionSemanticProvider(String capability) { + this.capability = capability; + } + + @Override + public String getCapability() { + return capability; + } + + @Override + public SemanticQueryResult query(SemanticQuery query) { + List> entries = normalizeEntries(loadEntries()); + List> filteredEntries = filterEntries(entries, query.getPrefix()); + int cursor = parseCursor(query.getCursor(), filteredEntries.size()); + int limit = query.getLimit() > 0 ? query.getLimit() : filteredEntries.size(); + int end = Math.min(filteredEntries.size(), cursor + limit); + String nextCursor = end < filteredEntries.size() ? Integer.toString(end) : null; + return new SemanticQueryResult( + capability, + computeVersion(entries), + new ArrayList<>(filteredEntries.subList(cursor, end)), + nextCursor); + } + + protected abstract List> loadEntries(); + + protected Map createEntry(String id, String label) { + return createEntry(id, label, null); + } + + protected Map createEntry(String id, String label, String detail) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + if (label != null && !label.isEmpty()) { + entry.put("label", label); + } + if (detail != null && !detail.isEmpty()) { + entry.put("detail", detail); + } + return entry; + } + + protected List> normalizeEntries(List> entries) { + Map> deduplicated = new LinkedHashMap<>(); + for (Map entry : entries) { + if (entry == null) { + continue; + } + String id = trimToNull(entry.get("id")); + if (id == null) { + continue; + } + + Map normalized = new LinkedHashMap<>(); + normalized.put("id", id); + + String label = trimToNull(entry.get("label")); + if (label != null) { + normalized.put("label", label); + } + + String detail = trimToNull(entry.get("detail")); + if (detail != null) { + normalized.put("detail", detail); + } + + deduplicated.putIfAbsent(id.toLowerCase(Locale.ROOT), normalized); + } + + List> normalizedEntries = new ArrayList<>(deduplicated.values()); + normalizedEntries.sort(Comparator.comparing(entry -> entry.get("id"), String.CASE_INSENSITIVE_ORDER)); + return normalizedEntries; + } + + protected List> filterEntries(List> entries, String prefix) { + String normalizedPrefix = prefix == null ? "" + : prefix.trim() + .toLowerCase(Locale.ROOT); + if (normalizedPrefix.isEmpty()) { + return entries; + } + + List> filteredEntries = new ArrayList<>(); + for (Map entry : entries) { + if (matchesPrefix(entry, normalizedPrefix)) { + filteredEntries.add(entry); + } + } + return filteredEntries; + } + + protected boolean matchesPrefix(Map entry, String normalizedPrefix) { + return startsWithIgnoreCase(entry.get("id"), normalizedPrefix) + || startsWithIgnoreCase(entry.get("label"), normalizedPrefix) + || startsWithIgnoreCase(entry.get("detail"), normalizedPrefix); + } + + protected int computeVersion(List> entries) { + int hash = entries.hashCode(); + if (hash == Integer.MIN_VALUE) { + return Integer.MAX_VALUE; + } + return Math.abs(hash) + 1; + } + + protected int parseCursor(String cursor, int size) { + if (cursor == null || cursor.isEmpty()) { + return 0; + } + try { + return Math.max(0, Math.min(Integer.parseInt(cursor), size)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + protected String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private boolean startsWithIgnoreCase(String value, String normalizedPrefix) { + return value != null && value.toLowerCase(Locale.ROOT) + .startsWith(normalizedPrefix); + } + + protected List> emptyEntries() { + return Collections.emptyList(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AliasSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AliasSemanticProvider.java new file mode 100644 index 00000000..a01befe1 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/AliasSemanticProvider.java @@ -0,0 +1,27 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; +import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; +import com.hfstudio.guidenh.bridge.semantic.SemanticQueryResult; + +public class AliasSemanticProvider implements SemanticProvider { + + private final String capability; + private final SemanticProvider delegate; + + public AliasSemanticProvider(String capability, SemanticProvider delegate) { + this.capability = capability; + this.delegate = delegate; + } + + @Override + public String getCapability() { + return capability; + } + + @Override + public SemanticQueryResult query(SemanticQuery query) { + SemanticQueryResult result = delegate.query(query); + return new SemanticQueryResult(capability, result.getVersion(), result.getEntries(), result.getNextCursor()); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java index fc9b582e..a9aba6e4 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java @@ -1,32 +1,629 @@ package com.hfstudio.guidenh.bridge.semantic.providers; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import net.minecraft.block.Block; +import net.minecraft.client.Minecraft; +import net.minecraft.client.audio.SoundHandler; +import net.minecraft.client.resources.I18n; +import net.minecraft.client.settings.KeyBinding; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.StatCollector; +import net.minecraftforge.oredict.OreDictionary; + +import org.jetbrains.annotations.Nullable; + +import com.gtnewhorizon.gtnhlib.util.data.ItemId; import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; +import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; +import com.hfstudio.guidenh.client.command.GuideNhClientCommand; +import com.hfstudio.guidenh.guide.compiler.FrontmatterNavigation; +import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; +import com.hfstudio.guidenh.guide.compiler.tags.KeyBindTagCompiler; +import com.hfstudio.guidenh.guide.indices.ItemIndex; +import com.hfstudio.guidenh.guide.internal.GuideCommand; +import com.hfstudio.guidenh.guide.internal.GuideRegistry; +import com.hfstudio.guidenh.guide.internal.MutableGuide; +import com.hfstudio.structurelibexport.StructureExportCommand; + +import cpw.mods.fml.common.Loader; +import cpw.mods.fml.common.ModContainer; public class RuntimeSemanticProviders { public static void registerBaseline(SemanticProviderRegistry registry) { - registry.register( - new StaticSemanticProvider( - SemanticCapability.ITEMS, - Collections.singletonList(entry("minecraft:stone", "Stone")))); - registry.register(new StaticSemanticProvider(SemanticCapability.PAGES, Collections.emptyList())); - registry.register(new StaticSemanticProvider(SemanticCapability.ORES, Collections.emptyList())); - registry.register(new StaticSemanticProvider(SemanticCapability.SOUNDS, Collections.emptyList())); - registry.register(new StaticSemanticProvider(SemanticCapability.KEYBINDS, Collections.emptyList())); - registry.register(new StaticSemanticProvider(SemanticCapability.RECIPES, Collections.emptyList())); - registry.register(new StaticSemanticProvider(SemanticCapability.QUESTS, Collections.emptyList())); + SemanticProvider itemsProvider = createItemsProvider(); + registry.register(itemsProvider); + registry.register(new AliasSemanticProvider(SemanticCapability.RECIPES, itemsProvider)); + registry.register(createPagesProvider()); + registry.register(createOresProvider()); + registry.register(createCategoriesProvider()); + registry.register(createModsProvider()); + registry.register(createCommandsProvider()); + registry.register(createSoundsProvider()); + registry.register(createKeybindsProvider()); + registry.register(createQuestsProvider()); registry.register(new StaticSemanticProvider(SemanticCapability.STRUCTURELIB, Collections.emptyList())); } - private static Map entry(String id, String label) { - Map entry = new HashMap<>(); + private static SemanticProvider createItemsProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.ITEMS) { + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + addItemEntries(entries); + addBlockOnlyEntries(entries); + return entries; + } + }; + } + + private static SemanticProvider createPagesProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.PAGES) { + + @Override + protected List> loadEntries() { + Map> entriesById = new LinkedHashMap<>(); + for (ParsedGuidePage page : getAllParsedPages()) { + String pagePath = page.getId() + .getResourcePath(); + String title = resolvePageTitle(page); + String detail = resolvePageDetail(page); + entriesById.putIfAbsent(pagePath, createEntry(pagePath, title, detail)); + } + return new ArrayList<>(entriesById.values()); + } + }; + } + + private static SemanticProvider createOresProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.ORES) { + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + String[] oreNames = OreDictionary.getOreNames(); + for (String oreName : oreNames) { + if (oreName == null || oreName.trim() + .isEmpty()) { + continue; + } + int stackCount = OreDictionary.getOres(oreName) + .size(); + entries.add(createEntry(oreName, "Ore Dictionary", "Variants: " + stackCount)); + } + return entries; + } + }; + } + + private static SemanticProvider createCategoriesProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.CATEGORIES) { + + @Override + protected List> loadEntries() { + Map counts = new LinkedHashMap<>(); + Map firstPageByCategory = new LinkedHashMap<>(); + for (ParsedGuidePage page : getAllParsedPages()) { + for (String category : readStringList(page, "categories")) { + counts.put(category, counts.getOrDefault(category, Integer.valueOf(0)) + 1); + firstPageByCategory.putIfAbsent( + category, + page.getId() + .getResourcePath()); + } + } + + List> entries = new ArrayList<>(); + for (Map.Entry entry : counts.entrySet()) { + String category = entry.getKey(); + Integer count = entry.getValue(); + String detail = firstPageByCategory.get(category); + entries.add(createEntry(category, "Referenced by " + count + " page(s)", detail)); + } + return entries; + } + }; + } + + private static SemanticProvider createModsProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.MODS) { + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + Map indexedMods = Loader.instance() + .getIndexedModList(); + for (Map.Entry entry : indexedMods.entrySet()) { + String modId = entry.getKey(); + ModContainer mod = entry.getValue(); + if (modId == null || modId.trim() + .isEmpty() || mod == null) { + continue; + } + String version = trimToNull(mod.getVersion()); + String detail = version != null ? version : modId; + entries.add(createEntry(modId, trimToNull(mod.getName()), detail)); + } + return entries; + } + }; + } + + private static SemanticProvider createCommandsProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.COMMANDS) { + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + addGuideCommandEntries(entries); + addGuideNhClientCommandEntries(entries); + addStructureExportCommandEntries(entries); + return entries; + } + }; + } + + private static SemanticProvider createSoundsProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.SOUNDS) { + + @Override + protected List> loadEntries() { + SoundHandler soundHandler = resolveSoundHandler(); + if (soundHandler == null) { + return emptyEntries(); + } + + Set soundIds = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + collectSoundIds(soundHandler, soundIds); + + List> entries = new ArrayList<>(); + for (String soundId : soundIds) { + entries.add(createEntry(soundId, "Registered sound", soundId)); + } + return entries; + } + }; + } + + private static SemanticProvider createKeybindsProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.KEYBINDS) { + + @Override + protected List> loadEntries() { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.gameSettings == null || minecraft.gameSettings.keyBindings == null) { + return emptyEntries(); + } + + List> entries = new ArrayList<>(); + for (KeyBinding keyBinding : minecraft.gameSettings.keyBindings) { + if (keyBinding == null) { + continue; + } + String id = trimToNull(keyBinding.getKeyDescription()); + if (id == null) { + continue; + } + + String actionName = localizeKey(id); + String bindingName = trimToNull(KeyBindTagCompiler.describeMapping(keyBinding)); + String categoryName = localizeKey(keyBinding.getKeyCategory()); + String label = actionName; + if (bindingName != null) { + label = actionName + " - " + bindingName; + } + entries.add(createEntry(id, label, categoryName)); + } + return entries; + } + }; + } + + private static SemanticProvider createQuestsProvider() { + return new AbstractCollectionSemanticProvider(SemanticCapability.QUESTS) { + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + for (ParsedGuidePage page : getAllParsedPages()) { + String pageTitle = resolvePageTitle(page); + String pagePath = page.getId() + .getResourcePath(); + for (String questId : readStringList(page, "quest_ids")) { + if (!isUuidLike(questId)) { + continue; + } + entries.add(createEntry(questId, pageTitle, pagePath)); + } + } + return entries; + } + }; + } + + private static void addItemEntries(List> entries) { + for (Object rawItem : Item.itemRegistry) { + if (!(rawItem instanceof Item item)) { + continue; + } + String baseId = resolveRegistryName(Item.itemRegistry.getNameForObject(item)); + if (baseId == null) { + continue; + } + + List variants = collectItemVariants(item); + if (variants.isEmpty()) { + variants.add(new ItemStack(item, 1, 0)); + } + + for (ItemStack stack : variants) { + if (stack == null || stack.getItem() == null) { + continue; + } + String insertId = buildInsertId(baseId, stack.getItemDamage()); + String detail = buildDisplayId(baseId, stack.getItemDamage()); + String label = resolveItemLabel(stack, baseId); + entries.add(entry(insertId, label, detail)); + } + } + } + + private static void addBlockOnlyEntries(List> entries) { + for (Object rawBlock : Block.blockRegistry) { + if (!(rawBlock instanceof Block block) || Item.getItemFromBlock(block) != null) { + continue; + } + String baseId = resolveRegistryName(Block.blockRegistry.getNameForObject(block)); + if (baseId == null) { + continue; + } + String label = resolveBlockLabel(block, baseId); + entries.add(entry(baseId, label, baseId + ":0")); + } + } + + private static void addGuideCommandEntries(List> entries) { + String root = "/" + new GuideCommand().getCommandName(); + entries.add(entry(root, "Open guide command", "Guide command root")); + entries.add(entry(root + " list", "List guides", "Lists registered guides")); + entries.add(entry(root + " open", "Open a guide", "Open a guide by id")); + entries.add(entry(root + " reload", "Reload guides", "Reload guide resources")); + entries.add(entry(root + " search", "Search guides", "Search guide content")); + addGuideOpenEntries(entries, root + " open", "Open guide"); + } + + private static void addGuideNhClientCommandEntries(List> entries) { + String root = "/" + new GuideNhClientCommand().getCommandName(); + entries.add(entry(root, "Open client guide command", "GuideNH client command root")); + for (String subCommand : GuideNhClientCommand.ROOT_SUB_COMMANDS) { + String command = root + " " + subCommand; + entries.add(entry(command, formatCommandLabel(subCommand), "GuideNH client command")); + } + addGuideOpenEntries(entries, root + " open", "Open guide"); + addGuideOpenEntries(entries, root + " export", "Export guide"); + addCommandOptionEntries( + entries, + root + " exportsite", + GuideNhClientCommand.EXPORT_SITE_FLAGS, + "Export site option"); + addCommandOptionEntries( + entries, + root + " exportstructure", + GuideNhClientCommand.EXPORT_STRUCTURE_FLAGS, + "Export structure option"); + } + + private static void addStructureExportCommandEntries(List> entries) { + String root = "/" + new StructureExportCommand().getCommandName(); + entries.add(entry(root, "Export structure", "Structure export command root")); + for (String subCommand : StructureExportCommand.SUBCOMMANDS) { + String command = root + " " + subCommand; + entries.add(entry(command, formatCommandLabel(subCommand), "Structure export subcommand")); + } + addCommandOptionEntries( + entries, + root + " " + StructureExportCommand.SUBCOMMAND_STRUCTURE_LIB, + StructureExportCommand.STRUCTURE_LIB_OPTIONS, + "StructureLib export option"); + addCommandOptionEntries( + entries, + root + " " + StructureExportCommand.SUBCOMMAND_GAME_SCENE, + StructureExportCommand.GAME_SCENE_OPTIONS, + "Game scene export option"); + } + + private static String buildInsertId(String baseId, int meta) { + return meta > 0 ? baseId + ":" + meta : baseId; + } + + private static String buildDisplayId(String baseId, int meta) { + return baseId + ":" + Math.max(meta, 0); + } + + private static List collectItemVariants(Item item) { + List variants = new ArrayList<>(); + try { + item.getSubItems(item, CreativeTabs.tabAllSearch, variants); + } catch (Throwable ignored) {} + + if (variants.isEmpty()) { + return variants; + } + + Map uniqueVariants = new LinkedHashMap<>(); + for (ItemStack variant : variants) { + if (variant == null || variant.getItem() == null) { + continue; + } + String key = ItemIndex.formatKey(ItemId.createNoCopy(variant.getItem(), variant.getItemDamage(), null)); + uniqueVariants.putIfAbsent(key, variant); + } + return new ArrayList<>(uniqueVariants.values()); + } + + private static @Nullable String resolveRegistryName(Object registryName) { + if (registryName == null) { + return null; + } + String value = registryName.toString(); + return value.isEmpty() ? null : value; + } + + private static String resolveItemLabel(ItemStack stack, String fallback) { + try { + String displayName = stack.getDisplayName(); + if (displayName != null && !displayName.trim() + .isEmpty()) { + return displayName; + } + } catch (Throwable ignored) {} + return fallback; + } + + private static String resolveBlockLabel(Block block, String fallback) { + try { + String localizedName = block.getLocalizedName(); + if (localizedName != null && !localizedName.trim() + .isEmpty()) { + return localizedName; + } + } catch (Throwable ignored) {} + return fallback; + } + + private static List getAllParsedPages() { + List pages = new ArrayList<>(); + for (MutableGuide guide : GuideRegistry.getAll()) { + try { + pages.addAll(guide.getPages()); + } catch (IllegalStateException ignored) {} + } + return pages; + } + + private static void addGuideOpenEntries(List> entries, String prefix, String labelPrefix) { + for (MutableGuide guide : GuideRegistry.getAll()) { + ResourceLocation guideId = guide.getId(); + if (guideId == null) { + continue; + } + String id = guideId.toString(); + entries.add(entry(prefix + " " + id, labelPrefix + ": " + id, "Guide id")); + } + } + + private static void addCommandOptionEntries(List> entries, String prefix, String[] options, + String detail) { + for (String option : options) { + if (option == null || option.trim() + .isEmpty()) { + continue; + } + entries.add(entry(prefix + " " + option, formatCommandLabel(option), detail)); + } + } + + private static List readStringList(ParsedGuidePage page, String key) { + Object value = page.getFrontmatter() + .additionalProperties() + .get(key); + if (!(value instanceof Listvalues)) { + return Collections.emptyList(); + } + + List strings = new ArrayList<>(); + for (Object rawValue : values) { + if (rawValue instanceof String stringValue) { + String trimmed = stringValue.trim(); + if (!trimmed.isEmpty()) { + strings.add(trimmed); + } + } + } + return strings; + } + + private static String resolvePageTitle(ParsedGuidePage page) { + FrontmatterNavigation navigation = page.getFrontmatter() + .navigationEntry(); + if (navigation != null && navigation.title() != null + && !navigation.title() + .trim() + .isEmpty()) { + return navigation.title(); + } + return page.getId() + .getResourcePath(); + } + + private static String resolvePageDetail(ParsedGuidePage page) { + return page.getLanguage() + " - " + page.getSourcePack(); + } + + private static @Nullable SoundHandler resolveSoundHandler() { + Minecraft minecraft = Minecraft.getMinecraft(); + return minecraft != null ? minecraft.getSoundHandler() : null; + } + + private static void collectSoundIds(SoundHandler soundHandler, Set soundIds) { + try { + for (Field field : getAllFields(soundHandler.getClass())) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + field.setAccessible(true); + Object value = field.get(soundHandler); + collectSoundIdsFromValue(value, soundIds, 2); + } + } catch (IllegalAccessException ignored) {} + } + + private static void collectSoundIdsFromValue(@Nullable Object value, Set soundIds, int depth) { + if (value == null || depth < 0) { + return; + } + + if (value instanceof ResourceLocation resourceLocation) { + soundIds.add(resourceLocation.toString()); + return; + } + + if (value instanceof Mapmap) { + for (Map.Entry entry : map.entrySet()) { + collectSoundIdsFromValue(entry.getKey(), soundIds, depth - 1); + collectSoundIdsFromValue(entry.getValue(), soundIds, depth - 1); + } + return; + } + + if (value instanceof Iterableiterable) { + for (Object element : iterable) { + collectSoundIdsFromValue(element, soundIds, depth - 1); + } + return; + } + + Class type = value.getClass(); + if (type.isArray()) { + int length = Array.getLength(value); + for (int index = 0; index < length; index++) { + collectSoundIdsFromValue(Array.get(value, index), soundIds, depth - 1); + } + return; + } + + String typeName = type.getName() + .toLowerCase(Locale.ROOT); + if (!typeName.contains("sound")) { + return; + } + + for (Field field : getAllFields(type)) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + try { + field.setAccessible(true); + collectSoundIdsFromValue(field.get(value), soundIds, depth - 1); + } catch (IllegalAccessException ignored) {} + } + } + + private static List getAllFields(Class type) { + List fields = new ArrayList<>(); + for (Class current = type; current != null; current = current.getSuperclass()) { + Collections.addAll(fields, current.getDeclaredFields()); + } + return fields; + } + + private static String localizeKey(@Nullable String translationKey) { + if (translationKey == null || translationKey.trim() + .isEmpty()) { + return ""; + } + + try { + String localized = StatCollector.translateToLocal(translationKey); + if (localized != null && !localized.equals(translationKey)) { + return localized; + } + } catch (Throwable ignored) {} + + try { + String localized = I18n.format(translationKey); + if (localized != null && !localized.equals(translationKey)) { + return localized; + } + } catch (Throwable ignored) {} + + return translationKey; + } + + private static boolean isUuidLike(String value) { + try { + UUID.fromString(value); + return true; + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private static String formatCommandLabel(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + if (value.startsWith("--")) { + return value; + } + + StringBuilder builder = new StringBuilder(); + char previous = 0; + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (index > 0 && Character.isUpperCase(current) && Character.isLowerCase(previous)) { + builder.append(' '); + } else if (current == '-' || current == '_') { + builder.append(' '); + previous = current; + continue; + } + builder.append(current); + previous = current; + } + if (builder.length() == 0) { + return value; + } + builder.setCharAt(0, Character.toUpperCase(builder.charAt(0))); + return builder.toString(); + } + + private static Map entry(String id, String label, String detail) { + Map entry = new LinkedHashMap<>(); entry.put("id", id); - entry.put("label", label); + if (label != null && !label.isEmpty()) { + entry.put("label", label); + } + if (detail != null && !detail.isEmpty()) { + entry.put("detail", detail); + } return entry; } } diff --git a/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java index 905313ed..414b94b1 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java @@ -7,6 +7,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import com.hfstudio.guidenh.GuideNH; import com.hfstudio.guidenh.bridge.protocol.BridgeEnvelope; import com.hfstudio.guidenh.bridge.protocol.BridgeMessageCodec; import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; @@ -49,13 +50,19 @@ public RuntimeBridgeConnection(Socket socket, BridgeMessageCodec messageCodec, public void run() { try { socket.setSoTimeout(SOCKET_TIMEOUT_MILLIS); + GuideNH.LOG.info("GuideNH runtime bridge waiting for WebSocket handshake from {}", describeRemote()); if (!new WebSocketHandshake().accept(socket.getInputStream(), socket.getOutputStream())) { + GuideNH.LOG + .warn("GuideNH runtime bridge rejected invalid WebSocket handshake from {}", describeRemote()); return; } + GuideNH.LOG.info("GuideNH runtime bridge WebSocket handshake completed for {}", describeRemote()); readFrames(); } catch (SocketTimeoutException ignored) { + GuideNH.LOG.warn("GuideNH runtime bridge connection timed out for {}", describeRemote()); closeQuietly(); - } catch (IOException ignored) { + } catch (IOException e) { + GuideNH.LOG.warn("GuideNH runtime bridge connection I/O failed for {}", describeRemote(), e); closeQuietly(); } finally { close(); @@ -66,10 +73,15 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } + GuideNH.LOG.info("GuideNH runtime bridge closing connection for {}", describeRemote()); closeQuietly(); closeCallback.accept(this); } + public String getRemoteAddress() { + return describeRemote(); + } + private void readFrames() throws IOException { while (!closed.get()) { WebSocketFrame frame = frameCodec.read(socket.getInputStream()); @@ -125,10 +137,12 @@ private BridgeEnvelope handleHello(BridgeEnvelope envelope) { .get("token") .getAsString() : ""; if (!authenticator.matches(token)) { + GuideNH.LOG.warn("GuideNH runtime bridge authentication failed for {}", describeRemote()); return responseFactory .error(envelope.getId(), envelope.getMethod(), "unauthorized", "Invalid bridge token", false); } authenticated = true; + GuideNH.LOG.info("GuideNH runtime bridge authenticated {}", describeRemote()); return responseFactory.hello(envelope.getId(), limits); } @@ -153,4 +167,11 @@ private void closeQuietly() { socket.close(); } catch (IOException ignored) {} } + + private String describeRemote() { + if (socket.getRemoteSocketAddress() == null) { + return "unknown"; + } + return String.valueOf(socket.getRemoteSocketAddress()); + } } From bf7e0ffa16af7eaa48bc7c686c00d578953952eb Mon Sep 17 00:00:00 2001 From: ABKQPO <93412322+ABKQPO@users.noreply.github.com> Date: Sun, 17 May 2026 02:37:17 +0800 Subject: [PATCH 3/5] Update RuntimeSemanticProviders.java --- .../providers/RuntimeSemanticProviders.java | 359 +++++++++++++++++- 1 file changed, 358 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java index a9aba6e4..b25f00d7 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java @@ -21,6 +21,7 @@ import net.minecraft.creativetab.CreativeTabs; import net.minecraft.item.Item; import net.minecraft.item.ItemStack; +import net.minecraft.tileentity.TileEntity; import net.minecraft.util.ResourceLocation; import net.minecraft.util.StatCollector; import net.minecraftforge.oredict.OreDictionary; @@ -28,9 +29,13 @@ import org.jetbrains.annotations.Nullable; import com.gtnewhorizon.gtnhlib.util.data.ItemId; +import com.gtnewhorizon.structurelib.alignment.IAlignment; +import com.gtnewhorizon.structurelib.alignment.enumerable.ExtendedFacing; import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; +import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; +import com.hfstudio.guidenh.bridge.semantic.SemanticQueryResult; import com.hfstudio.guidenh.client.command.GuideNhClientCommand; import com.hfstudio.guidenh.guide.compiler.FrontmatterNavigation; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; @@ -39,7 +44,11 @@ import com.hfstudio.guidenh.guide.internal.GuideCommand; import com.hfstudio.guidenh.guide.internal.GuideRegistry; import com.hfstudio.guidenh.guide.internal.MutableGuide; +import com.hfstudio.guidenh.integration.structurelib.StructureLibImportRequest; +import com.hfstudio.guidenh.integration.structurelib.StructureLibRuntimeFacade; import com.hfstudio.structurelibexport.StructureExportCommand; +import com.hfstudio.structurelibexport.StructureLibControllerDiscovery; +import com.hfstudio.structurelibexport.StructureLibControllerSpec; import cpw.mods.fml.common.Loader; import cpw.mods.fml.common.ModContainer; @@ -58,7 +67,7 @@ public static void registerBaseline(SemanticProviderRegistry registry) { registry.register(createSoundsProvider()); registry.register(createKeybindsProvider()); registry.register(createQuestsProvider()); - registry.register(new StaticSemanticProvider(SemanticCapability.STRUCTURELIB, Collections.emptyList())); + registry.register(createStructureLibProvider()); } private static SemanticProvider createItemsProvider() { @@ -258,6 +267,354 @@ protected List> loadEntries() { }; } + private static SemanticProvider createStructureLibProvider() { + return new SemanticProvider() { + + @Override + public String getCapability() { + return SemanticCapability.STRUCTURELIB; + } + + @Override + public SemanticQueryResult query(SemanticQuery query) { + List> entries = loadStructureLibEntries(query); + List> filteredEntries = filterStructureLibEntries(entries, query.getPrefix()); + int cursor = parseStructureLibCursor(query.getCursor(), filteredEntries.size()); + int limit = query.getLimit() > 0 ? query.getLimit() : filteredEntries.size(); + int end = Math.min(filteredEntries.size(), cursor + limit); + String nextCursor = end < filteredEntries.size() ? Integer.toString(end) : null; + return new SemanticQueryResult( + SemanticCapability.STRUCTURELIB, + computeStructureLibVersion(entries), + new ArrayList<>(filteredEntries.subList(cursor, end)), + nextCursor); + } + }; + } + + private static List> loadStructureLibEntries(SemanticQuery query) { + Map filters = query.getFilters(); + String attribute = normalizeStructureLibValue(filters.get("attribute")); + if ("channel".equals(attribute)) { + return loadStructureLibChannelEntries(filters); + } + if (isStructureLibOrientationAttribute(attribute)) { + return loadStructureLibOrientationEntries(filters, attribute); + } + return loadStructureLibControllerEntries(); + } + + private static List> loadStructureLibControllerEntries() { + List> entries = new ArrayList<>(); + for (StructureLibControllerSpec controller : new StructureLibControllerDiscovery().discoverAllControllers()) { + String id = normalizeStructureLibValue(controller.getControllerArgument()); + if (id == null) { + continue; + } + + String label = normalizeStructureLibValue(controller.getDisplayName()); + String detail = controller.getBlockId() + ":" + controller.getMeta(); + entries.add(createStructureLibEntry(id, label, detail)); + } + return normalizeStructureLibEntries(entries); + } + + private static List> loadStructureLibChannelEntries(Map filters) { + String controller = normalizeStructureLibValue(filters.get("controller")); + if (controller == null) { + return Collections.emptyList(); + } + try { + StructureLibImportRequest request = new StructureLibImportRequest(controller, null, null, null, null, null); + StructureLibRuntimeFacade.ResolvedController resolvedController = StructureLibRuntimeFacade + .resolveController(request); + StructureLibRuntimeFacade.ControlAnalysis analysis = StructureLibRuntimeFacade + .analyzeControls(request, resolvedController); + int maxTier = analysis.getMaxTotalTier(); + if (maxTier <= 0) { + return Collections.emptyList(); + } + List> entries = new ArrayList<>(); + String detail = describeStructureLibTierRange(controller, analysis); + for (int value = 1; value <= maxTier; value++) { + entries.add(createStructureLibEntry(Integer.toString(value), "StructureLib preview tier", detail)); + } + return normalizeStructureLibEntries(entries); + } catch (IllegalArgumentException ignored) { + return Collections.emptyList(); + } catch (Throwable ignored) { + return Collections.emptyList(); + } + } + + private static List> loadStructureLibOrientationEntries(Map filters, + String attribute) { + String controller = normalizeStructureLibValue(filters.get("controller")); + if (controller == null || attribute == null) { + return Collections.emptyList(); + } + try { + StructureLibControllerSpec controllerSpec = StructureLibControllerSpec.parse(controller); + List allowedFacings = findStructureLibAllowedFacings(controllerSpec); + if (allowedFacings.isEmpty()) { + return Collections.emptyList(); + } + List> entries = switch (attribute) { + case "facing" -> createStructureLibFacingEntries(controllerSpec, allowedFacings, filters); + case "rotation" -> createStructureLibRotationEntries(controllerSpec, allowedFacings, filters); + case "flip" -> createStructureLibFlipEntries(controllerSpec, allowedFacings, filters); + default -> Collections.emptyList(); + }; + return normalizeStructureLibEntries(entries); + } catch (IllegalArgumentException ignored) { + return Collections.emptyList(); + } catch (Throwable ignored) { + return Collections.emptyList(); + } + } + + private static List> createStructureLibFacingEntries(StructureLibControllerSpec controller, + List allowedFacings, Map filters) { + List> entries = new ArrayList<>(); + for (ExtendedFacing facing : allowedFacings) { + if (matchesStructureLibOrientationFilters(facing, filters, "facing")) { + String value = facing.getDirection() + .name() + .toLowerCase(Locale.ROOT); + entries.add( + createStructureLibEntry(value, "StructureLib facing", describeStructureLibOrientation(controller))); + } + } + return entries; + } + + private static List> createStructureLibRotationEntries(StructureLibControllerSpec controller, + List allowedFacings, Map filters) { + List> entries = new ArrayList<>(); + for (ExtendedFacing facing : allowedFacings) { + if (matchesStructureLibOrientationFilters(facing, filters, "rotation")) { + String value = facing.getRotation() + .getName(); + entries.add( + createStructureLibEntry( + value, + "StructureLib rotation", + describeStructureLibOrientation(controller))); + } + } + return entries; + } + + private static List> createStructureLibFlipEntries(StructureLibControllerSpec controller, + List allowedFacings, Map filters) { + List> entries = new ArrayList<>(); + for (ExtendedFacing facing : allowedFacings) { + if (matchesStructureLibOrientationFilters(facing, filters, "flip")) { + String value = facing.getFlip() + .getName(); + entries.add( + createStructureLibEntry(value, "StructureLib flip", describeStructureLibOrientation(controller))); + } + } + return entries; + } + + private static List findStructureLibAllowedFacings(StructureLibControllerSpec controller) { + List allowedFacings = new ArrayList<>(); + StructureLibRuntimeFacade.BuildContext context = new StructureLibRuntimeFacade.BuildContext(); + try { + StructureLibRuntimeFacade.ResolvedController resolvedController = new StructureLibRuntimeFacade.ResolvedController( + controller.getBlockId(), + controller.getBlock(), + controller.getMeta()); + TileEntity tile = StructureLibRuntimeFacade + .placeControllerDirectly(context.getLevel(), context.getWorld(), resolvedController, new ArrayList<>()); + if (tile == null) { + return Collections.emptyList(); + } + IAlignment alignment = StructureLibRuntimeFacade.resolveAlignment(tile); + if (alignment == null) { + return Collections.emptyList(); + } + for (ExtendedFacing facing : ExtendedFacing.VALUES) { + if (alignment.getAlignmentLimits() != null ? alignment.getAlignmentLimits() + .isNewExtendedFacingValid(facing) : alignment.checkedSetExtendedFacing(facing)) { + allowedFacings.add(facing); + } + } + return allowedFacings; + } catch (Throwable ignored) { + return Collections.emptyList(); + } finally { + context.clear(); + } + } + + private static boolean matchesStructureLibOrientationFilters(ExtendedFacing facing, Map filters, + String targetAttribute) { + return matchesStructureLibOrientationFilterValue( + facing.getDirection() + .name() + .toLowerCase(Locale.ROOT), + normalizeStructureLibValue(filters.get("facing")), + targetAttribute, + "facing") + && matchesStructureLibOrientationFilterValue( + facing.getRotation() + .getName(), + normalizeStructureLibValue(filters.get("rotation")), + targetAttribute, + "rotation") + && matchesStructureLibOrientationFilterValue( + facing.getFlip() + .getName(), + normalizeStructureLibValue(filters.get("flip")), + targetAttribute, + "flip"); + } + + private static boolean matchesStructureLibOrientationFilterValue(String actualValue, + @Nullable String requestedValue, String targetAttribute, String attributeName) { + if (targetAttribute.equals(attributeName) || requestedValue == null) { + return true; + } + return actualValue.equalsIgnoreCase(requestedValue); + } + + private static String describeStructureLibOrientation(StructureLibControllerSpec controller) { + return "Allowed orientation for " + controller.getControllerArgument(); + } + + private static boolean isStructureLibOrientationAttribute(@Nullable String attribute) { + return "facing".equals(attribute) || "rotation".equals(attribute) || "flip".equals(attribute); + } + + private static String describeStructureLibTierRange(String controller, + StructureLibRuntimeFacade.ControlAnalysis analysis) { + StringBuilder detail = new StringBuilder(); + detail.append("Preview tier for ") + .append(controller) + .append(" (max ") + .append(analysis.getMaxTotalTier()) + .append(')'); + if (analysis.getChannelMaxTierMap() + .isEmpty()) { + return detail.toString(); + } + + detail.append(" | Channel caps: "); + boolean first = true; + for (Map.Entry entry : analysis.getChannelMaxTierMap() + .entrySet()) { + String channelId = normalizeStructureLibValue(entry.getKey()); + Integer maxValue = entry.getValue(); + if (channelId == null || maxValue == null || maxValue <= 0) { + continue; + } + if (!first) { + detail.append(", "); + } + detail.append(channelId) + .append('=') + .append(maxValue); + first = false; + } + return detail.toString(); + } + + private static List> normalizeStructureLibEntries(List> entries) { + Map> deduplicated = new LinkedHashMap<>(); + for (Map entry : entries) { + if (entry == null) { + continue; + } + String id = normalizeStructureLibValue(entry.get("id")); + if (id == null) { + continue; + } + Map normalized = new LinkedHashMap<>(); + normalized.put("id", id); + String label = normalizeStructureLibValue(entry.get("label")); + if (label != null) { + normalized.put("label", label); + } + String detail = normalizeStructureLibValue(entry.get("detail")); + if (detail != null) { + normalized.put("detail", detail); + } + deduplicated.putIfAbsent(id.toLowerCase(Locale.ROOT), normalized); + } + List> normalizedEntries = new ArrayList<>(deduplicated.values()); + normalizedEntries.sort( + (left, right) -> left.get("id") + .compareToIgnoreCase(right.get("id"))); + return normalizedEntries; + } + + private static List> filterStructureLibEntries(List> entries, + String prefix) { + String normalizedPrefix = prefix == null ? "" + : prefix.trim() + .toLowerCase(Locale.ROOT); + if (normalizedPrefix.isEmpty()) { + return entries; + } + List> filteredEntries = new ArrayList<>(); + for (Map entry : entries) { + if (startsWithIgnoreCase(entry.get("id"), normalizedPrefix) + || startsWithIgnoreCase(entry.get("label"), normalizedPrefix) + || startsWithIgnoreCase(entry.get("detail"), normalizedPrefix)) { + filteredEntries.add(entry); + } + } + return filteredEntries; + } + + private static int parseStructureLibCursor(String cursor, int size) { + if (cursor == null || cursor.isEmpty()) { + return 0; + } + try { + return Math.max(0, Math.min(Integer.parseInt(cursor), size)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private static int computeStructureLibVersion(List> entries) { + int hash = entries.hashCode(); + if (hash == Integer.MIN_VALUE) { + return Integer.MAX_VALUE; + } + return Math.abs(hash) + 1; + } + + private static boolean startsWithIgnoreCase(@Nullable String value, String prefix) { + return value != null && value.toLowerCase(Locale.ROOT) + .startsWith(prefix); + } + + private static @Nullable String normalizeStructureLibValue(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Map createStructureLibEntry(String id, @Nullable String label, + @Nullable String detail) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + if (label != null) { + entry.put("label", label); + } + if (detail != null) { + entry.put("detail", detail); + } + return entry; + } + private static void addItemEntries(List> entries) { for (Object rawItem : Item.itemRegistry) { if (!(rawItem instanceof Item item)) { From 9c336a7689fec4bcf6085ac078c6bb467f696f0a Mon Sep 17 00:00:00 2001 From: ABKQPO <93412322+ABKQPO@users.noreply.github.com> Date: Wed, 27 May 2026 19:03:44 +0800 Subject: [PATCH 4/5] add more info --- .../bridge/GuideNhRuntimeBridgeServer.java | 10 + .../bridge/preview/ItemPreviewCache.java | 29 + .../bridge/preview/ItemPreviewCacheKey.java | 80 ++ .../bridge/preview/ItemPreviewPayload.java | 52 + .../preview/ItemPreviewSearchService.java | 281 +++++ .../bridge/preview/ItemPreviewService.java | 288 +++++ .../bridge/preview/PreviewQueryFactory.java | 43 + .../bridge/preview/PreviewRequestSupport.java | 65 ++ .../bridge/preview/PreviewResolveQuery.java | 50 + .../bridge/preview/PreviewResolveResult.java | 87 ++ .../bridge/preview/PreviewSearchEntry.java | 38 + .../bridge/preview/PreviewSearchQuery.java | 43 + .../bridge/preview/PreviewSearchResult.java | 70 ++ .../bridge/preview/RuntimePreviewFacade.java | 21 + .../bridge/protocol/BridgeProtocolLimits.java | 24 + .../protocol/BridgeResponseFactory.java | 37 + .../bridge/semantic/SemanticCapability.java | 1 + .../providers/CategorySemanticProvider.java | 21 + .../providers/CommandSemanticProvider.java | 24 + .../providers/EntitySemanticProvider.java | 21 + .../providers/ItemSemanticProvider.java | 22 + .../providers/KeybindSemanticProvider.java | 21 + .../providers/ModSemanticProvider.java | 21 + .../providers/OreSemanticProvider.java | 21 + .../providers/PageSemanticProvider.java | 21 + .../providers/QuestSemanticProvider.java | 21 + .../providers/RuntimeSemanticProviders.java | 990 +----------------- .../providers/RuntimeSemanticSupport.java | 791 ++++++++++++++ .../providers/SoundSemanticProvider.java | 21 + .../StructureLibSemanticProvider.java | 377 +++++++ .../transport/RuntimeBridgeConnection.java | 63 +- tools/runtime-bridge/README.txt | 14 + tools/runtime-bridge/query-runtime-bridge.mjs | 216 ++++ .../runtime-bridge-config.sample.json | 12 + .../runtime-bridge/verify-runtime-bridge.ps1 | 192 ++++ 35 files changed, 3111 insertions(+), 977 deletions(-) create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCache.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCacheKey.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewPayload.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewQueryFactory.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewRequestSupport.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveQuery.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveResult.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchEntry.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchQuery.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchResult.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/preview/RuntimePreviewFacade.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CategorySemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CommandSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/EntitySemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ItemSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/KeybindSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ModSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/OreSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/PageSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/QuestSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticSupport.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/SoundSemanticProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StructureLibSemanticProvider.java create mode 100644 tools/runtime-bridge/README.txt create mode 100644 tools/runtime-bridge/query-runtime-bridge.mjs create mode 100644 tools/runtime-bridge/runtime-bridge-config.sample.json create mode 100644 tools/runtime-bridge/verify-runtime-bridge.ps1 diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java index 5feded17..6f04379d 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java @@ -15,6 +15,10 @@ import java.util.concurrent.atomic.AtomicBoolean; import com.hfstudio.guidenh.GuideNH; +import com.hfstudio.guidenh.bridge.preview.ItemPreviewCache; +import com.hfstudio.guidenh.bridge.preview.ItemPreviewSearchService; +import com.hfstudio.guidenh.bridge.preview.ItemPreviewService; +import com.hfstudio.guidenh.bridge.preview.RuntimePreviewFacade; import com.hfstudio.guidenh.bridge.protocol.BridgeMessageCodec; import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; import com.hfstudio.guidenh.bridge.security.BridgeTokenAuthenticator; @@ -29,6 +33,7 @@ public class GuideNhRuntimeBridgeServer { private final BridgeMessageCodec messageCodec; private final BridgeTokenAuthenticator authenticator; private final SemanticProviderRegistry registry = new SemanticProviderRegistry(); + private final RuntimePreviewFacade previewFacade; private final Set connections = Collections.synchronizedSet(new HashSet<>()); private final ExecutorService executor = Executors.newCachedThreadPool(new RuntimeBridgeThreadFactory()); private final AtomicBoolean running = new AtomicBoolean(); @@ -45,6 +50,10 @@ public GuideNhRuntimeBridgeServer(GuideNhRuntimeBridgeSettings settings) { this.messageCodec = new BridgeMessageCodec(limits); this.authenticator = new BridgeTokenAuthenticator(settings.getToken()); RuntimeSemanticProviders.registerBaseline(registry); + ItemPreviewCache previewCache = new ItemPreviewCache(256); + ItemPreviewSearchService previewSearchService = new ItemPreviewSearchService(); + ItemPreviewService previewService = new ItemPreviewService(previewCache, limits); + this.previewFacade = new RuntimePreviewFacade(previewSearchService, previewService); } public void start() { @@ -104,6 +113,7 @@ private void acceptConnections() { messageCodec, authenticator, registry, + previewFacade, limits, this::handleClosedConnection); connections.add(connection); diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCache.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCache.java new file mode 100644 index 00000000..4c0f7939 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCache.java @@ -0,0 +1,29 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class ItemPreviewCache { + + private final int maxEntries; + private final Map cache; + + public ItemPreviewCache(int maxEntries) { + this.maxEntries = Math.max(1, maxEntries); + this.cache = new LinkedHashMap(16, 0.75f, true) { + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > ItemPreviewCache.this.maxEntries; + } + }; + } + + public synchronized ItemPreviewPayload get(ItemPreviewCacheKey key) { + return cache.get(key); + } + + public synchronized void put(ItemPreviewCacheKey key, ItemPreviewPayload value) { + cache.put(key, value); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCacheKey.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCacheKey.java new file mode 100644 index 00000000..d812f1c0 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewCacheKey.java @@ -0,0 +1,80 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.Objects; + +public class ItemPreviewCacheKey { + + private final String capability; + private final String id; + private final int meta; + private final int count; + private final String nbt; + private final String renderVariant; + + public ItemPreviewCacheKey(String capability, String id, int meta, int count, String nbt, String renderVariant) { + this.capability = capability == null ? "" : capability; + this.id = id == null ? "" : id; + this.meta = meta; + this.count = count; + this.nbt = nbt == null ? "" : nbt; + this.renderVariant = renderVariant == null ? "default" : renderVariant; + } + + public String getCapability() { + return capability; + } + + public String getId() { + return id; + } + + public int getMeta() { + return meta; + } + + public int getCount() { + return count; + } + + public String getNbt() { + return nbt; + } + + public String getRenderVariant() { + return renderVariant; + } + + public String toPreviewKey() { + return capability + "|" + + id + + "|" + + meta + + "|" + + count + + "|" + + renderVariant + + "|" + + Integer.toHexString(nbt.hashCode()); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ItemPreviewCacheKey)) { + return false; + } + ItemPreviewCacheKey that = (ItemPreviewCacheKey) other; + return meta == that.meta && count == that.count + && Objects.equals(capability, that.capability) + && Objects.equals(id, that.id) + && Objects.equals(nbt, that.nbt) + && Objects.equals(renderVariant, that.renderVariant); + } + + @Override + public int hashCode() { + return Objects.hash(capability, id, meta, count, nbt, renderVariant); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewPayload.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewPayload.java new file mode 100644 index 00000000..c6d96594 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewPayload.java @@ -0,0 +1,52 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ItemPreviewPayload { + + private final String previewKey; + private final String id; + private final String displayName; + private final String detail; + private final int meta; + private final int count; + private final String nbt; + private final List tooltipLines; + private final String iconPngBase64; + private final int pixelWidth; + private final int pixelHeight; + + public ItemPreviewPayload(String previewKey, String id, String displayName, String detail, int meta, int count, + String nbt, List tooltipLines, String iconPngBase64, int pixelWidth, int pixelHeight) { + this.previewKey = previewKey == null ? "" : previewKey; + this.id = id == null ? "" : id; + this.displayName = displayName; + this.detail = detail; + this.meta = meta; + this.count = count; + this.nbt = nbt == null ? "" : nbt; + this.tooltipLines = tooltipLines == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(tooltipLines)); + this.iconPngBase64 = iconPngBase64 == null ? "" : iconPngBase64; + this.pixelWidth = pixelWidth; + this.pixelHeight = pixelHeight; + } + + public PreviewResolveResult toResult(String capability) { + return new PreviewResolveResult( + capability, + previewKey, + id, + displayName, + detail, + Integer.valueOf(meta), + Integer.valueOf(count), + nbt, + tooltipLines, + iconPngBase64, + pixelWidth, + pixelHeight); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java new file mode 100644 index 00000000..ef527504 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java @@ -0,0 +1,281 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.providers.RuntimeSemanticSupport; + +public class ItemPreviewSearchService { + + public PreviewSearchResult search(PreviewSearchQuery query) { + if (!"items".equals(query.getCapability())) { + throw new IllegalArgumentException("Unknown preview capability"); + } + + List> semanticEntries = new ArrayList<>(); + RuntimeSemanticSupport.addItemEntries(semanticEntries); + RuntimeSemanticSupport.addBlockOnlyEntries(semanticEntries); + + String normalizedPrefix = normalize(query.getPrefix()); + List rankedEntries = new ArrayList<>(); + for (Map semanticEntry : semanticEntries) { + String id = trimToNull(semanticEntry.get("id")); + if (id == null) { + continue; + } + String label = trimToNull(semanticEntry.get("label")); + String detail = trimToNull(semanticEntry.get("detail")); + int score = scoreEntry(id, label, detail, normalizedPrefix); + if (score == Integer.MAX_VALUE) { + continue; + } + rankedEntries.add( + new RankedPreviewSearchEntry( + score, + new PreviewSearchEntry(id, label, detail, buildPreviewKey(id), describeMatchKind(score)))); + } + + rankedEntries.sort( + Comparator.comparingInt(RankedPreviewSearchEntry::getScore) + .thenComparing( + entry -> entry.getEntry() + .getId(), + String.CASE_INSENSITIVE_ORDER) + .thenComparing( + entry -> entry.getEntry() + .getLabel(), + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))); + + List entries = new ArrayList<>(rankedEntries.size()); + for (RankedPreviewSearchEntry rankedEntry : rankedEntries) { + entries.add(rankedEntry.getEntry()); + } + return PreviewSearchResult.page(query.getCapability(), entries, query.getCursor(), query.getLimit()); + } + + private int scoreEntry(String id, String label, String detail, String prefix) { + if (prefix.isEmpty()) { + return 0; + } + + String normalizedId = normalize(id); + String normalizedLabel = normalize(label); + String normalizedDetail = normalize(detail); + String namespace = normalizedId.contains(":") ? normalizedId.substring(0, normalizedId.indexOf(':')) + : normalizedId; + String path = normalizedId.contains(":") ? normalizedId.substring(normalizedId.indexOf(':') + 1) : normalizedId; + String compactId = compact(normalizedId); + String compactLabel = compact(normalizedLabel); + String compactDetail = compact(normalizedDetail); + String compactPath = compact(path); + String compactPrefix = compact(prefix); + String tokenInitials = createTokenInitials(path); + String labelInitials = createTokenInitials(normalizedLabel); + boolean shortPrefix = isShortPrefix(prefix); + + if (normalizedLabel.equals(prefix)) { + return 0; + } + if (normalizedId.equals(prefix)) { + return 1; + } + if (namespace.startsWith(prefix) && shortPrefix) { + return 2; + } + if (normalizedLabel.startsWith(prefix)) { + return 3; + } + if (matchesTokenPrefix(normalizedLabel, prefix)) { + return 4; + } + if (normalizedId.startsWith(prefix)) { + return 5; + } + if (path.startsWith(prefix)) { + return 6; + } + if (matchesTokenPrefix(normalizedId, prefix) || matchesTokenPrefix(path, prefix)) { + return 7; + } + if (normalizedDetail.startsWith(prefix)) { + return 8; + } + if (!compactPrefix.isEmpty() && compactPrefix.length() >= 2 && labelInitials.startsWith(compactPrefix)) { + return 9; + } + if (!compactPrefix.isEmpty() && compactPrefix.length() >= 2 && tokenInitials.startsWith(compactPrefix)) { + return 10; + } + if (!compactPrefix.isEmpty() && compactId.startsWith(compactPrefix)) { + return 11; + } + if (!compactPrefix.isEmpty() && compactLabel.startsWith(compactPrefix)) { + return 12; + } + if (!compactPrefix.isEmpty() && compactPath.startsWith(compactPrefix)) { + return 13; + } + if (!compactPrefix.isEmpty() && compactDetail.startsWith(compactPrefix)) { + return 14; + } + if (normalizedId.contains(prefix)) { + return 15; + } + if (normalizedLabel.contains(prefix)) { + return 16; + } + if (normalizedDetail.contains(prefix)) { + return 17; + } + return Integer.MAX_VALUE; + } + + private boolean isShortPrefix(String prefix) { + return prefix.indexOf(':') < 0 && prefix.length() > 0 && prefix.length() <= 4; + } + + private String describeMatchKind(int score) { + switch (score) { + case 0: + return "label-exact"; + case 1: + return "id-exact"; + case 2: + return "namespace-prefix"; + case 3: + return "label-prefix"; + case 4: + return "label-token"; + case 5: + return "id-prefix"; + case 6: + return "path-prefix"; + case 7: + return "path-token"; + case 8: + return "detail-prefix"; + case 9: + return "label-acronym"; + case 10: + return "path-acronym"; + case 11: + return "id-compact"; + case 12: + return "label-compact"; + case 13: + return "path-compact"; + case 14: + return "detail-compact"; + case 15: + return "id-contains"; + case 16: + return "label-contains"; + case 17: + return "detail-contains"; + default: + return "runtime"; + } + } + + private String buildPreviewKey(String id) { + return new ItemPreviewCacheKey("items", id, 0, 1, "", "default").toPreviewKey(); + } + + private String normalize(String value) { + return value == null ? "" + : value.trim() + .toLowerCase(Locale.ROOT); + } + + private String compact(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(value.length()); + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if ((current >= 'a' && current <= 'z') || (current >= '0' && current <= '9')) { + builder.append(current); + } + } + return builder.toString(); + } + + private boolean matchesTokenPrefix(String value, String prefix) { + if (value == null || value.isEmpty() || prefix.isEmpty()) { + return false; + } + int length = value.length(); + int tokenStart = -1; + for (int index = 0; index <= length; index++) { + char current = index < length ? value.charAt(index) : 0; + boolean tokenCharacter = index < length + && ((current >= 'a' && current <= 'z') || (current >= '0' && current <= '9')); + if (tokenCharacter && tokenStart < 0) { + tokenStart = index; + continue; + } + if (tokenCharacter) { + continue; + } + if (tokenStart >= 0 && value.regionMatches(tokenStart, prefix, 0, prefix.length())) { + return true; + } + tokenStart = -1; + } + return false; + } + + private String createTokenInitials(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(value.length()); + int length = value.length(); + int tokenStart = -1; + for (int index = 0; index <= length; index++) { + char current = index < length ? value.charAt(index) : 0; + boolean tokenCharacter = index < length + && ((current >= 'a' && current <= 'z') || (current >= '0' && current <= '9')); + if (tokenCharacter && tokenStart < 0) { + tokenStart = index; + builder.append(current); + continue; + } + if (!tokenCharacter) { + tokenStart = -1; + } + } + return builder.toString(); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + public static class RankedPreviewSearchEntry { + + private final int score; + private final PreviewSearchEntry entry; + + public RankedPreviewSearchEntry(int score, PreviewSearchEntry entry) { + this.score = score; + this.entry = entry; + } + + public int getScore() { + return score; + } + + public PreviewSearchEntry getEntry() { + return entry; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java new file mode 100644 index 00000000..55ff843e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java @@ -0,0 +1,288 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import javax.imageio.ImageIO; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.RenderHelper; +import net.minecraft.client.renderer.entity.RenderItem; +import net.minecraft.client.shader.Framebuffer; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumChatFormatting; + +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; + +import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; +import com.hfstudio.guidenh.guide.compiler.IdUtils; +import com.hfstudio.guidenh.guide.document.interaction.ItemTooltip; +import com.hfstudio.guidenh.guide.internal.tooltip.GuideItemTooltipLines; +import com.hfstudio.guidenh.guide.scene.ponder.PonderNbtPath; +import com.hfstudio.guidenh.guide.siteexport.site.GuideSiteItemSupport; + +public class ItemPreviewService { + + private static final int DEFAULT_ICON_SIZE = 64; + private static final int LARGE_ICON_SIZE = 128; + private static final int RENDER_TIMEOUT_SECONDS = 5; + + private final ItemPreviewCache cache; + private final BridgeProtocolLimits limits; + + public ItemPreviewService(ItemPreviewCache cache, BridgeProtocolLimits limits) { + this.cache = cache; + this.limits = limits; + } + + public PreviewResolveResult resolve(PreviewResolveQuery query) { + if (!"items".equals(query.getCapability())) { + throw new IllegalArgumentException("Unknown preview capability"); + } + + ItemStack stack = resolveItemStack(query); + if (stack == null || stack.getItem() == null) { + throw new IllegalArgumentException("Unknown item id"); + } + + ItemPreviewCacheKey cacheKey = new ItemPreviewCacheKey( + query.getCapability(), + query.getId(), + stack.getItemDamage(), + Math.max(1, query.getCount()), + normalizedNbtText(stack.stackTagCompound), + query.getRenderVariant()); + ItemPreviewPayload cached = cache.get(cacheKey); + if (cached != null) { + return cached.toResult(query.getCapability()); + } + + ItemPreviewPayload payload = renderOnClientThread(cacheKey, stack.copy()); + cache.put(cacheKey, payload); + return payload.toResult(query.getCapability()); + } + + private ItemStack resolveItemStack(PreviewResolveQuery query) { + ItemStack stack = IdUtils.resolveItemStack(query.getId(), "minecraft"); + if (stack == null || stack.getItem() == null) { + return null; + } + stack.stackSize = Math.max(1, query.getCount()); + String nbt = query.getNbt(); + if (!nbt.isEmpty()) { + try { + NBTTagCompound parsedNbt = PonderNbtPath.parseCompound(nbt); + stack.stackTagCompound = parsedNbt; + } catch (Exception e) { + throw new IllegalArgumentException("Invalid preview NBT", e); + } + } + return stack; + } + + private ItemPreviewPayload renderOnClientThread(ItemPreviewCacheKey cacheKey, ItemStack stack) { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null) { + throw new IllegalStateException("Minecraft client is not ready for preview rendering."); + } + if (minecraft.func_152345_ab()) { + return createPayload(cacheKey, stack, minecraft); + } + + CompletableFuture future = new CompletableFuture<>(); + minecraft.func_152344_a(() -> { + try { + future.complete(createPayload(cacheKey, stack, Minecraft.getMinecraft())); + } catch (Throwable error) { + future.completeExceptionally(error); + } + }); + + try { + return future.get(RENDER_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Exception error) { + throw new IllegalStateException("Timed out waiting for preview rendering.", error); + } + } + + private ItemPreviewPayload createPayload(ItemPreviewCacheKey cacheKey, ItemStack stack, Minecraft minecraft) { + int iconSize = resolveIconSize(cacheKey.getRenderVariant()); + byte[] iconBytes = renderPng(stack, iconSize, minecraft); + String iconPngBase64 = Base64.getEncoder() + .encodeToString(iconBytes); + List tooltipLines = sanitizeTooltipLines( + GuideItemTooltipLines.build(new ItemTooltip(stack), minecraft)); + String displayName = GuideSiteItemSupport.displayName(stack); + String resolvedId = GuideSiteItemSupport.itemId(stack); + String detail = buildDetail(resolvedId, stack.getItemDamage()); + return new ItemPreviewPayload( + cacheKey.toPreviewKey(), + cacheKey.getId(), + displayName, + detail, + stack.getItemDamage(), + stack.stackSize, + normalizedNbtText(stack.stackTagCompound), + tooltipLines, + iconPngBase64, + iconSize, + iconSize); + } + + private int resolveIconSize(String renderVariant) { + int requestedSize = "picker".equalsIgnoreCase(renderVariant) ? LARGE_ICON_SIZE : DEFAULT_ICON_SIZE; + int maxEdge = (int) Math.sqrt(Math.max(16, limits.getMaxPreviewIconPixels())); + return Math.max(16, Math.min(requestedSize, maxEdge)); + } + + private byte[] renderPng(ItemStack stack, int iconSize, Minecraft minecraft) { + if (minecraft.gameSettings == null || minecraft.fontRenderer == null) { + throw new IllegalStateException("Minecraft client is not ready for preview rendering."); + } + + Framebuffer framebuffer = new Framebuffer(iconSize, iconSize, true); + framebuffer.setFramebufferColor(0f, 0f, 0f, 0f); + + int previousDisplayWidth = minecraft.displayWidth; + int previousDisplayHeight = minecraft.displayHeight; + int previousGuiScale = minecraft.gameSettings.guiScale; + + boolean projectionPushed = false; + boolean modelViewPushed = false; + + try { + minecraft.displayWidth = iconSize; + minecraft.displayHeight = iconSize; + minecraft.gameSettings.guiScale = 1; + + framebuffer.bindFramebuffer(true); + GL11.glViewport(0, 0, iconSize, iconSize); + GL11.glClearColor(0f, 0f, 0f, 0f); + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPushMatrix(); + projectionPushed = true; + GL11.glLoadIdentity(); + GL11.glOrtho(0.0D, iconSize, iconSize, 0.0D, 1000.0D, 3000.0D); + + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPushMatrix(); + modelViewPushed = true; + GL11.glLoadIdentity(); + GL11.glTranslatef(0.0F, 0.0F, -2000.0F); + + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glEnable(GL11.GL_ALPHA_TEST); + GL11.glAlphaFunc(GL11.GL_GREATER, 0.1f); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glColor4f(1f, 1f, 1f, 1f); + + float scale = (iconSize - 2f) / 16f; + float origin = (iconSize - 16f * scale) / 2f; + GL11.glPushMatrix(); + try { + GL11.glTranslatef(origin, origin, 0f); + GL11.glScalef(scale, scale, 1f); + + RenderHelper.enableGUIStandardItemLighting(); + GL11.glEnable(GL11.GL_NORMALIZE); + GL11.glEnable(GL11.GL_DEPTH_TEST); + + RenderItem itemRenderer = RenderItem.getInstance(); + itemRenderer.zLevel = 100f; + itemRenderer + .renderItemAndEffectIntoGUI(minecraft.fontRenderer, minecraft.getTextureManager(), stack, 0, 0); + itemRenderer + .renderItemOverlayIntoGUI(minecraft.fontRenderer, minecraft.getTextureManager(), stack, 0, 0); + itemRenderer.zLevel = 0f; + } finally { + GL11.glPopMatrix(); + RenderHelper.disableStandardItemLighting(); + } + + BufferedImage image = readPixels(iconSize); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(image, "png", output); + return output.toByteArray(); + } catch (Exception error) { + throw new IllegalStateException("Failed to render item preview icon.", error); + } finally { + if (modelViewPushed) { + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPopMatrix(); + } + if (projectionPushed) { + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPopMatrix(); + GL11.glMatrixMode(GL11.GL_MODELVIEW); + } + + framebuffer.unbindFramebuffer(); + framebuffer.deleteFramebuffer(); + minecraft.displayWidth = previousDisplayWidth; + minecraft.displayHeight = previousDisplayHeight; + minecraft.gameSettings.guiScale = previousGuiScale; + GL11.glViewport(0, 0, previousDisplayWidth, previousDisplayHeight); + + GL11.glDisable(GL11.GL_LIGHTING); + GL11.glDisable(GL11.GL_DEPTH_TEST); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glColor4f(1f, 1f, 1f, 1f); + } + } + + private BufferedImage readPixels(int iconSize) { + java.nio.ByteBuffer buffer = BufferUtils.createByteBuffer(iconSize * iconSize * 4); + GL11.glReadPixels(0, 0, iconSize, iconSize, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buffer); + + BufferedImage image = new BufferedImage(iconSize, iconSize, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < iconSize; y++) { + int flippedY = iconSize - 1 - y; + for (int x = 0; x < iconSize; x++) { + int index = (x + y * iconSize) * 4; + int red = buffer.get(index) & 0xFF; + int green = buffer.get(index + 1) & 0xFF; + int blue = buffer.get(index + 2) & 0xFF; + int alpha = buffer.get(index + 3) & 0xFF; + image.setRGB(x, flippedY, (alpha << 24) | (red << 16) | (green << 8) | blue); + } + } + return image; + } + + private List sanitizeTooltipLines(List tooltipLines) { + List sanitizedLines = new ArrayList<>(); + int maxLines = limits.getMaxPreviewTooltipLines(); + for (String tooltipLine : tooltipLines) { + if (sanitizedLines.size() >= maxLines) { + break; + } + sanitizedLines.add(tooltipLine == null ? "" : tooltipLine); + } + if (sanitizedLines.isEmpty()) { + sanitizedLines.add(EnumChatFormatting.WHITE + "Unknown item"); + } + return sanitizedLines; + } + + private String buildDetail(String resolvedId, int meta) { + if (resolvedId == null || resolvedId.isEmpty()) { + return ""; + } + return resolvedId + ":" + Math.max(meta, 0); + } + + private String normalizedNbtText(NBTTagCompound tagCompound) { + return tagCompound == null ? "" : tagCompound.toString(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewQueryFactory.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewQueryFactory.java new file mode 100644 index 00000000..c310ec1e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewQueryFactory.java @@ -0,0 +1,43 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.Map; + +import com.google.gson.JsonObject; +import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; + +public class PreviewQueryFactory { + + private final BridgeProtocolLimits limits; + + public PreviewQueryFactory(BridgeProtocolLimits limits) { + this.limits = limits; + } + + public String readCapability(JsonObject payload) { + return PreviewRequestSupport.readOptionalString(payload, "capability", ""); + } + + public PreviewSearchQuery createSearchQuery(JsonObject payload) { + String capability = PreviewRequestSupport.requireString(payload, "capability"); + String cursor = PreviewRequestSupport.readOptionalString(payload, "cursor", ""); + int limit = PreviewRequestSupport.readBoundedInt( + payload, + "limit", + limits.getMaxPreviewSearchPageSize(), + 1, + limits.getMaxPreviewSearchPageSize()); + String prefix = PreviewRequestSupport.readOptionalString(payload, "prefix", ""); + Map filters = PreviewRequestSupport.readStringMap(payload, "filters"); + return new PreviewSearchQuery(capability, cursor, limit, prefix, filters); + } + + public PreviewResolveQuery createResolveQuery(JsonObject payload) { + String capability = PreviewRequestSupport.requireString(payload, "capability"); + String id = PreviewRequestSupport.requireString(payload, "id"); + int count = PreviewRequestSupport.readBoundedInt(payload, "count", 1, 1, 999999); + String nbt = PreviewRequestSupport.readOptionalString(payload, "nbt", ""); + String renderVariant = PreviewRequestSupport.readOptionalString(payload, "renderVariant", "default"); + Map filters = PreviewRequestSupport.readStringMap(payload, "filters"); + return new PreviewResolveQuery(capability, id, count, nbt, renderVariant, filters); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewRequestSupport.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewRequestSupport.java new file mode 100644 index 00000000..5371b7d5 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewRequestSupport.java @@ -0,0 +1,65 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +public class PreviewRequestSupport { + + protected PreviewRequestSupport() {} + + public static String requireString(JsonObject payload, String name) { + String value = readOptionalString(payload, name, ""); + if (value.isEmpty()) { + throw new IllegalArgumentException(name + " is required"); + } + return value; + } + + public static String readOptionalString(JsonObject payload, String name, String defaultValue) { + if (payload == null || !payload.has(name)) { + return defaultValue; + } + JsonElement value = payload.get(name); + if (value == null || !value.isJsonPrimitive()) { + return defaultValue; + } + String text = value.getAsString(); + return text == null ? defaultValue : text; + } + + public static int readBoundedInt(JsonObject payload, String name, int defaultValue, int minValue, int maxValue) { + int value = defaultValue; + if (payload != null && payload.has(name)) { + JsonElement rawValue = payload.get(name); + if (rawValue != null && rawValue.isJsonPrimitive()) { + try { + value = rawValue.getAsInt(); + } catch (NumberFormatException ignored) { + value = defaultValue; + } + } + } + return Math.max(minValue, Math.min(value, maxValue)); + } + + public static Map readStringMap(JsonObject payload, String name) { + if (payload == null || !payload.has(name) + || !payload.get(name) + .isJsonObject()) { + return Collections.emptyMap(); + } + Map values = new LinkedHashMap<>(); + JsonObject object = payload.getAsJsonObject(name); + for (Map.Entry entry : object.entrySet()) { + JsonElement value = entry.getValue(); + if (value != null && value.isJsonPrimitive()) { + values.put(entry.getKey(), value.getAsString()); + } + } + return values; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveQuery.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveQuery.java new file mode 100644 index 00000000..12ea1f8d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveQuery.java @@ -0,0 +1,50 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class PreviewResolveQuery { + + private final String capability; + private final String id; + private final int count; + private final String nbt; + private final String renderVariant; + private final Map filters; + + public PreviewResolveQuery(String capability, String id, int count, String nbt, String renderVariant, + Map filters) { + this.capability = capability == null ? "" : capability; + this.id = id == null ? "" : id; + this.count = count; + this.nbt = nbt == null ? "" : nbt; + this.renderVariant = renderVariant == null ? "default" : renderVariant; + this.filters = filters == null ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(filters)); + } + + public String getCapability() { + return capability; + } + + public String getId() { + return id; + } + + public int getCount() { + return count; + } + + public String getNbt() { + return nbt; + } + + public String getRenderVariant() { + return renderVariant; + } + + public Map getFilters() { + return filters; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveResult.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveResult.java new file mode 100644 index 00000000..37bbc5c3 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewResolveResult.java @@ -0,0 +1,87 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class PreviewResolveResult { + + private final String capability; + private final String previewKey; + private final String id; + private final String displayName; + private final String detail; + private final Integer meta; + private final Integer count; + private final String nbt; + private final List tooltipLines; + private final String iconPngBase64; + private final int pixelWidth; + private final int pixelHeight; + + public PreviewResolveResult(String capability, String previewKey, String id, String displayName, String detail, + Integer meta, Integer count, String nbt, List tooltipLines, String iconPngBase64, int pixelWidth, + int pixelHeight) { + this.capability = capability == null ? "" : capability; + this.previewKey = previewKey == null ? "" : previewKey; + this.id = id == null ? "" : id; + this.displayName = displayName; + this.detail = detail; + this.meta = meta; + this.count = count; + this.nbt = nbt; + this.tooltipLines = tooltipLines == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(tooltipLines)); + this.iconPngBase64 = iconPngBase64 == null ? "" : iconPngBase64; + this.pixelWidth = pixelWidth; + this.pixelHeight = pixelHeight; + } + + public String getCapability() { + return capability; + } + + public String getPreviewKey() { + return previewKey; + } + + public String getId() { + return id; + } + + public String getDisplayName() { + return displayName; + } + + public String getDetail() { + return detail; + } + + public Integer getMeta() { + return meta; + } + + public Integer getCount() { + return count; + } + + public String getNbt() { + return nbt; + } + + public List getTooltipLines() { + return tooltipLines; + } + + public String getIconPngBase64() { + return iconPngBase64; + } + + public int getPixelWidth() { + return pixelWidth; + } + + public int getPixelHeight() { + return pixelHeight; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchEntry.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchEntry.java new file mode 100644 index 00000000..be13ed3b --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchEntry.java @@ -0,0 +1,38 @@ +package com.hfstudio.guidenh.bridge.preview; + +public class PreviewSearchEntry { + + private final String id; + private final String label; + private final String detail; + private final String previewKey; + private final String matchKind; + + public PreviewSearchEntry(String id, String label, String detail, String previewKey, String matchKind) { + this.id = id; + this.label = label; + this.detail = detail; + this.previewKey = previewKey; + this.matchKind = matchKind; + } + + public String getId() { + return id; + } + + public String getLabel() { + return label; + } + + public String getDetail() { + return detail; + } + + public String getPreviewKey() { + return previewKey; + } + + public String getMatchKind() { + return matchKind; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchQuery.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchQuery.java new file mode 100644 index 00000000..ae2935ae --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchQuery.java @@ -0,0 +1,43 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class PreviewSearchQuery { + + private final String capability; + private final String cursor; + private final int limit; + private final String prefix; + private final Map filters; + + public PreviewSearchQuery(String capability, String cursor, int limit, String prefix, Map filters) { + this.capability = capability == null ? "" : capability; + this.cursor = cursor == null ? "" : cursor; + this.limit = limit; + this.prefix = prefix == null ? "" : prefix; + this.filters = filters == null ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(filters)); + } + + public String getCapability() { + return capability; + } + + public String getCursor() { + return cursor; + } + + public int getLimit() { + return limit; + } + + public String getPrefix() { + return prefix; + } + + public Map getFilters() { + return filters; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchResult.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchResult.java new file mode 100644 index 00000000..6e47b4d7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/PreviewSearchResult.java @@ -0,0 +1,70 @@ +package com.hfstudio.guidenh.bridge.preview; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class PreviewSearchResult { + + private final String capability; + private final int version; + private final List entries; + private final String nextCursor; + + public PreviewSearchResult(String capability, int version, List entries, String nextCursor) { + this.capability = capability == null ? "" : capability; + this.version = version; + this.entries = entries == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(entries)); + this.nextCursor = nextCursor; + } + + public static PreviewSearchResult page(String capability, List entries, String cursor, + int limit) { + List safeEntries = entries == null ? Collections.emptyList() : entries; + int start = parseCursor(cursor, safeEntries.size()); + int safeLimit = limit > 0 ? limit : safeEntries.size(); + int end = Math.min(safeEntries.size(), start + safeLimit); + String nextCursor = end < safeEntries.size() ? Integer.toString(end) : null; + return new PreviewSearchResult( + capability, + computeVersion(safeEntries), + new ArrayList<>(safeEntries.subList(start, end)), + nextCursor); + } + + public String getCapability() { + return capability; + } + + public int getVersion() { + return version; + } + + public List getEntries() { + return entries; + } + + public String getNextCursor() { + return nextCursor; + } + + private static int parseCursor(String cursor, int size) { + if (cursor == null || cursor.isEmpty()) { + return 0; + } + try { + return Math.max(0, Math.min(Integer.parseInt(cursor), size)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private static int computeVersion(List entries) { + int hash = entries.hashCode(); + if (hash == Integer.MIN_VALUE) { + return Integer.MAX_VALUE; + } + return Math.abs(hash) + 1; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/RuntimePreviewFacade.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/RuntimePreviewFacade.java new file mode 100644 index 00000000..5bdbe9b2 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/RuntimePreviewFacade.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.preview; + +public class RuntimePreviewFacade { + + private final ItemPreviewSearchService itemPreviewSearchService; + private final ItemPreviewService itemPreviewService; + + public RuntimePreviewFacade(ItemPreviewSearchService itemPreviewSearchService, + ItemPreviewService itemPreviewService) { + this.itemPreviewSearchService = itemPreviewSearchService; + this.itemPreviewService = itemPreviewService; + } + + public PreviewSearchResult search(PreviewSearchQuery query) { + return itemPreviewSearchService.search(query); + } + + public PreviewResolveResult resolve(PreviewResolveQuery query) { + return itemPreviewService.resolve(query); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java index 80a0d0f3..e4d5b774 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java @@ -7,6 +7,10 @@ public class BridgeProtocolLimits { private final int maxSubscriptions; private final int maxConnections; private final int maxDeltaEntries; + private final int maxPreviewSearchPageSize; + private final int maxPreviewResolveBytes; + private final int maxPreviewIconPixels; + private final int maxPreviewTooltipLines; public BridgeProtocolLimits(int maxMessageBytes, int maxPageSize, int maxSubscriptions, int maxConnections, int maxDeltaEntries) { @@ -15,6 +19,10 @@ public BridgeProtocolLimits(int maxMessageBytes, int maxPageSize, int maxSubscri this.maxSubscriptions = maxSubscriptions; this.maxConnections = maxConnections; this.maxDeltaEntries = maxDeltaEntries; + this.maxPreviewSearchPageSize = Math.max(1, Math.min(maxPageSize, 80)); + this.maxPreviewResolveBytes = Math.max(32768, Math.min(maxMessageBytes - 4096, 131072)); + this.maxPreviewIconPixels = 128 * 128; + this.maxPreviewTooltipLines = 24; } public int getMaxMessageBytes() { @@ -36,4 +44,20 @@ public int getMaxConnections() { public int getMaxDeltaEntries() { return maxDeltaEntries; } + + public int getMaxPreviewSearchPageSize() { + return maxPreviewSearchPageSize; + } + + public int getMaxPreviewResolveBytes() { + return maxPreviewResolveBytes; + } + + public int getMaxPreviewIconPixels() { + return maxPreviewIconPixels; + } + + public int getMaxPreviewTooltipLines() { + return maxPreviewTooltipLines; + } } diff --git a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java index 930d68f5..71e1f135 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java @@ -1,7 +1,11 @@ package com.hfstudio.guidenh.bridge.protocol; +import java.nio.charset.StandardCharsets; + import com.google.gson.Gson; import com.google.gson.JsonObject; +import com.hfstudio.guidenh.bridge.preview.PreviewResolveResult; +import com.hfstudio.guidenh.bridge.preview.PreviewSearchResult; public class BridgeResponseFactory { @@ -23,15 +27,48 @@ public BridgeEnvelope semanticResult(String id, String method, Object result) { .getAsJsonObject()); } + public BridgeEnvelope previewSearch(String id, PreviewSearchResult result) { + return BridgeEnvelope.response( + id, + "preview.search", + GSON.toJsonTree(result) + .getAsJsonObject()); + } + + public BridgeEnvelope previewResolve(String id, PreviewResolveResult result) { + return BridgeEnvelope.response( + id, + "preview.resolve", + GSON.toJsonTree(result) + .getAsJsonObject()); + } + public BridgeEnvelope capabilities(String id, Object capabilities) { JsonObject payload = new JsonObject(); payload.add("capabilities", GSON.toJsonTree(capabilities)); return BridgeEnvelope.response(id, "capabilities", payload); } + public BridgeEnvelope documentValidate(String id, String method) { + JsonObject payload = new JsonObject(); + payload.addProperty("accepted", true); + return BridgeEnvelope.response(id, method, payload); + } + public BridgeEnvelope error(String id, String method, String code, String message, boolean retryable) { JsonObject payload = GSON.toJsonTree(new BridgeError(code, message, retryable)) .getAsJsonObject(); return BridgeEnvelope.error(id, method, payload); } + + public void validatePreviewResultSize(PreviewResolveResult result, BridgeProtocolLimits limits) { + JsonObject payload = GSON.toJsonTree(result) + .getAsJsonObject(); + int payloadBytes = payload.toString() + .getBytes(StandardCharsets.UTF_8).length; + if (payloadBytes > limits.getMaxPreviewResolveBytes()) { + throw new IllegalArgumentException( + "Preview payload exceeds maximum size: " + payloadBytes + " > " + limits.getMaxPreviewResolveBytes()); + } + } } diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java index 85671e73..f5b6ae2b 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java @@ -13,6 +13,7 @@ public class SemanticCapability { public static final String QUESTS = "quests"; public static final String STRUCTURELIB = "structurelib"; public static final String PAGES = "pages"; + public static final String ENTITIES = "entities"; public SemanticCapability() {} } diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CategorySemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CategorySemanticProvider.java new file mode 100644 index 00000000..5de8a173 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CategorySemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class CategorySemanticProvider extends AbstractCollectionSemanticProvider { + + public CategorySemanticProvider() { + super(SemanticCapability.CATEGORIES); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addCategoryEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CommandSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CommandSemanticProvider.java new file mode 100644 index 00000000..00caf24c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/CommandSemanticProvider.java @@ -0,0 +1,24 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class CommandSemanticProvider extends AbstractCollectionSemanticProvider { + + public CommandSemanticProvider() { + super(SemanticCapability.COMMANDS); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addGlobalCommandEntries(entries); + RuntimeSemanticSupport.addGuideCommandEntries(entries); + RuntimeSemanticSupport.addGuideNhClientCommandEntries(entries); + RuntimeSemanticSupport.addStructureExportCommandEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/EntitySemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/EntitySemanticProvider.java new file mode 100644 index 00000000..1f46f57f --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/EntitySemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class EntitySemanticProvider extends AbstractCollectionSemanticProvider { + + public EntitySemanticProvider() { + super(SemanticCapability.ENTITIES); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addEntityEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ItemSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ItemSemanticProvider.java new file mode 100644 index 00000000..6b2fbe42 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ItemSemanticProvider.java @@ -0,0 +1,22 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class ItemSemanticProvider extends AbstractCollectionSemanticProvider { + + public ItemSemanticProvider() { + super(SemanticCapability.ITEMS); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addItemEntries(entries); + RuntimeSemanticSupport.addBlockOnlyEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/KeybindSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/KeybindSemanticProvider.java new file mode 100644 index 00000000..b733dd9f --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/KeybindSemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class KeybindSemanticProvider extends AbstractCollectionSemanticProvider { + + public KeybindSemanticProvider() { + super(SemanticCapability.KEYBINDS); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addKeybindEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ModSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ModSemanticProvider.java new file mode 100644 index 00000000..f8b7a83d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/ModSemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class ModSemanticProvider extends AbstractCollectionSemanticProvider { + + public ModSemanticProvider() { + super(SemanticCapability.MODS); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addModEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/OreSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/OreSemanticProvider.java new file mode 100644 index 00000000..fe025132 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/OreSemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class OreSemanticProvider extends AbstractCollectionSemanticProvider { + + public OreSemanticProvider() { + super(SemanticCapability.ORES); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addOreEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/PageSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/PageSemanticProvider.java new file mode 100644 index 00000000..8f844955 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/PageSemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class PageSemanticProvider extends AbstractCollectionSemanticProvider { + + public PageSemanticProvider() { + super(SemanticCapability.PAGES); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addPageEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/QuestSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/QuestSemanticProvider.java new file mode 100644 index 00000000..e5073d74 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/QuestSemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class QuestSemanticProvider extends AbstractCollectionSemanticProvider { + + public QuestSemanticProvider() { + super(SemanticCapability.QUESTS); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addQuestEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java index b25f00d7..b583accd 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java @@ -1,986 +1,26 @@ package com.hfstudio.guidenh.bridge.semantic.providers; -import java.lang.reflect.Array; -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.UUID; - -import net.minecraft.block.Block; -import net.minecraft.client.Minecraft; -import net.minecraft.client.audio.SoundHandler; -import net.minecraft.client.resources.I18n; -import net.minecraft.client.settings.KeyBinding; -import net.minecraft.creativetab.CreativeTabs; -import net.minecraft.item.Item; -import net.minecraft.item.ItemStack; -import net.minecraft.tileentity.TileEntity; -import net.minecraft.util.ResourceLocation; -import net.minecraft.util.StatCollector; -import net.minecraftforge.oredict.OreDictionary; - -import org.jetbrains.annotations.Nullable; - -import com.gtnewhorizon.gtnhlib.util.data.ItemId; -import com.gtnewhorizon.structurelib.alignment.IAlignment; -import com.gtnewhorizon.structurelib.alignment.enumerable.ExtendedFacing; import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; -import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; -import com.hfstudio.guidenh.bridge.semantic.SemanticQueryResult; -import com.hfstudio.guidenh.client.command.GuideNhClientCommand; -import com.hfstudio.guidenh.guide.compiler.FrontmatterNavigation; -import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; -import com.hfstudio.guidenh.guide.compiler.tags.KeyBindTagCompiler; -import com.hfstudio.guidenh.guide.indices.ItemIndex; -import com.hfstudio.guidenh.guide.internal.GuideCommand; -import com.hfstudio.guidenh.guide.internal.GuideRegistry; -import com.hfstudio.guidenh.guide.internal.MutableGuide; -import com.hfstudio.guidenh.integration.structurelib.StructureLibImportRequest; -import com.hfstudio.guidenh.integration.structurelib.StructureLibRuntimeFacade; -import com.hfstudio.structurelibexport.StructureExportCommand; -import com.hfstudio.structurelibexport.StructureLibControllerDiscovery; -import com.hfstudio.structurelibexport.StructureLibControllerSpec; - -import cpw.mods.fml.common.Loader; -import cpw.mods.fml.common.ModContainer; public class RuntimeSemanticProviders { - public static void registerBaseline(SemanticProviderRegistry registry) { - SemanticProvider itemsProvider = createItemsProvider(); - registry.register(itemsProvider); - registry.register(new AliasSemanticProvider(SemanticCapability.RECIPES, itemsProvider)); - registry.register(createPagesProvider()); - registry.register(createOresProvider()); - registry.register(createCategoriesProvider()); - registry.register(createModsProvider()); - registry.register(createCommandsProvider()); - registry.register(createSoundsProvider()); - registry.register(createKeybindsProvider()); - registry.register(createQuestsProvider()); - registry.register(createStructureLibProvider()); - } - - private static SemanticProvider createItemsProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.ITEMS) { - - @Override - protected List> loadEntries() { - List> entries = new ArrayList<>(); - addItemEntries(entries); - addBlockOnlyEntries(entries); - return entries; - } - }; - } - - private static SemanticProvider createPagesProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.PAGES) { - - @Override - protected List> loadEntries() { - Map> entriesById = new LinkedHashMap<>(); - for (ParsedGuidePage page : getAllParsedPages()) { - String pagePath = page.getId() - .getResourcePath(); - String title = resolvePageTitle(page); - String detail = resolvePageDetail(page); - entriesById.putIfAbsent(pagePath, createEntry(pagePath, title, detail)); - } - return new ArrayList<>(entriesById.values()); - } - }; - } - - private static SemanticProvider createOresProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.ORES) { - - @Override - protected List> loadEntries() { - List> entries = new ArrayList<>(); - String[] oreNames = OreDictionary.getOreNames(); - for (String oreName : oreNames) { - if (oreName == null || oreName.trim() - .isEmpty()) { - continue; - } - int stackCount = OreDictionary.getOres(oreName) - .size(); - entries.add(createEntry(oreName, "Ore Dictionary", "Variants: " + stackCount)); - } - return entries; - } - }; - } - - private static SemanticProvider createCategoriesProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.CATEGORIES) { - - @Override - protected List> loadEntries() { - Map counts = new LinkedHashMap<>(); - Map firstPageByCategory = new LinkedHashMap<>(); - for (ParsedGuidePage page : getAllParsedPages()) { - for (String category : readStringList(page, "categories")) { - counts.put(category, counts.getOrDefault(category, Integer.valueOf(0)) + 1); - firstPageByCategory.putIfAbsent( - category, - page.getId() - .getResourcePath()); - } - } - - List> entries = new ArrayList<>(); - for (Map.Entry entry : counts.entrySet()) { - String category = entry.getKey(); - Integer count = entry.getValue(); - String detail = firstPageByCategory.get(category); - entries.add(createEntry(category, "Referenced by " + count + " page(s)", detail)); - } - return entries; - } - }; - } - - private static SemanticProvider createModsProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.MODS) { - - @Override - protected List> loadEntries() { - List> entries = new ArrayList<>(); - Map indexedMods = Loader.instance() - .getIndexedModList(); - for (Map.Entry entry : indexedMods.entrySet()) { - String modId = entry.getKey(); - ModContainer mod = entry.getValue(); - if (modId == null || modId.trim() - .isEmpty() || mod == null) { - continue; - } - String version = trimToNull(mod.getVersion()); - String detail = version != null ? version : modId; - entries.add(createEntry(modId, trimToNull(mod.getName()), detail)); - } - return entries; - } - }; - } - - private static SemanticProvider createCommandsProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.COMMANDS) { - - @Override - protected List> loadEntries() { - List> entries = new ArrayList<>(); - addGuideCommandEntries(entries); - addGuideNhClientCommandEntries(entries); - addStructureExportCommandEntries(entries); - return entries; - } - }; - } - - private static SemanticProvider createSoundsProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.SOUNDS) { - - @Override - protected List> loadEntries() { - SoundHandler soundHandler = resolveSoundHandler(); - if (soundHandler == null) { - return emptyEntries(); - } - - Set soundIds = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); - collectSoundIds(soundHandler, soundIds); - - List> entries = new ArrayList<>(); - for (String soundId : soundIds) { - entries.add(createEntry(soundId, "Registered sound", soundId)); - } - return entries; - } - }; - } - - private static SemanticProvider createKeybindsProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.KEYBINDS) { - - @Override - protected List> loadEntries() { - Minecraft minecraft = Minecraft.getMinecraft(); - if (minecraft == null || minecraft.gameSettings == null || minecraft.gameSettings.keyBindings == null) { - return emptyEntries(); - } - - List> entries = new ArrayList<>(); - for (KeyBinding keyBinding : minecraft.gameSettings.keyBindings) { - if (keyBinding == null) { - continue; - } - String id = trimToNull(keyBinding.getKeyDescription()); - if (id == null) { - continue; - } - - String actionName = localizeKey(id); - String bindingName = trimToNull(KeyBindTagCompiler.describeMapping(keyBinding)); - String categoryName = localizeKey(keyBinding.getKeyCategory()); - String label = actionName; - if (bindingName != null) { - label = actionName + " - " + bindingName; - } - entries.add(createEntry(id, label, categoryName)); - } - return entries; - } - }; - } - - private static SemanticProvider createQuestsProvider() { - return new AbstractCollectionSemanticProvider(SemanticCapability.QUESTS) { - - @Override - protected List> loadEntries() { - List> entries = new ArrayList<>(); - for (ParsedGuidePage page : getAllParsedPages()) { - String pageTitle = resolvePageTitle(page); - String pagePath = page.getId() - .getResourcePath(); - for (String questId : readStringList(page, "quest_ids")) { - if (!isUuidLike(questId)) { - continue; - } - entries.add(createEntry(questId, pageTitle, pagePath)); - } - } - return entries; - } - }; - } - - private static SemanticProvider createStructureLibProvider() { - return new SemanticProvider() { - - @Override - public String getCapability() { - return SemanticCapability.STRUCTURELIB; - } - - @Override - public SemanticQueryResult query(SemanticQuery query) { - List> entries = loadStructureLibEntries(query); - List> filteredEntries = filterStructureLibEntries(entries, query.getPrefix()); - int cursor = parseStructureLibCursor(query.getCursor(), filteredEntries.size()); - int limit = query.getLimit() > 0 ? query.getLimit() : filteredEntries.size(); - int end = Math.min(filteredEntries.size(), cursor + limit); - String nextCursor = end < filteredEntries.size() ? Integer.toString(end) : null; - return new SemanticQueryResult( - SemanticCapability.STRUCTURELIB, - computeStructureLibVersion(entries), - new ArrayList<>(filteredEntries.subList(cursor, end)), - nextCursor); - } - }; - } - - private static List> loadStructureLibEntries(SemanticQuery query) { - Map filters = query.getFilters(); - String attribute = normalizeStructureLibValue(filters.get("attribute")); - if ("channel".equals(attribute)) { - return loadStructureLibChannelEntries(filters); - } - if (isStructureLibOrientationAttribute(attribute)) { - return loadStructureLibOrientationEntries(filters, attribute); - } - return loadStructureLibControllerEntries(); - } - - private static List> loadStructureLibControllerEntries() { - List> entries = new ArrayList<>(); - for (StructureLibControllerSpec controller : new StructureLibControllerDiscovery().discoverAllControllers()) { - String id = normalizeStructureLibValue(controller.getControllerArgument()); - if (id == null) { - continue; - } - - String label = normalizeStructureLibValue(controller.getDisplayName()); - String detail = controller.getBlockId() + ":" + controller.getMeta(); - entries.add(createStructureLibEntry(id, label, detail)); - } - return normalizeStructureLibEntries(entries); - } - - private static List> loadStructureLibChannelEntries(Map filters) { - String controller = normalizeStructureLibValue(filters.get("controller")); - if (controller == null) { - return Collections.emptyList(); - } - try { - StructureLibImportRequest request = new StructureLibImportRequest(controller, null, null, null, null, null); - StructureLibRuntimeFacade.ResolvedController resolvedController = StructureLibRuntimeFacade - .resolveController(request); - StructureLibRuntimeFacade.ControlAnalysis analysis = StructureLibRuntimeFacade - .analyzeControls(request, resolvedController); - int maxTier = analysis.getMaxTotalTier(); - if (maxTier <= 0) { - return Collections.emptyList(); - } - List> entries = new ArrayList<>(); - String detail = describeStructureLibTierRange(controller, analysis); - for (int value = 1; value <= maxTier; value++) { - entries.add(createStructureLibEntry(Integer.toString(value), "StructureLib preview tier", detail)); - } - return normalizeStructureLibEntries(entries); - } catch (IllegalArgumentException ignored) { - return Collections.emptyList(); - } catch (Throwable ignored) { - return Collections.emptyList(); - } - } - - private static List> loadStructureLibOrientationEntries(Map filters, - String attribute) { - String controller = normalizeStructureLibValue(filters.get("controller")); - if (controller == null || attribute == null) { - return Collections.emptyList(); - } - try { - StructureLibControllerSpec controllerSpec = StructureLibControllerSpec.parse(controller); - List allowedFacings = findStructureLibAllowedFacings(controllerSpec); - if (allowedFacings.isEmpty()) { - return Collections.emptyList(); - } - List> entries = switch (attribute) { - case "facing" -> createStructureLibFacingEntries(controllerSpec, allowedFacings, filters); - case "rotation" -> createStructureLibRotationEntries(controllerSpec, allowedFacings, filters); - case "flip" -> createStructureLibFlipEntries(controllerSpec, allowedFacings, filters); - default -> Collections.emptyList(); - }; - return normalizeStructureLibEntries(entries); - } catch (IllegalArgumentException ignored) { - return Collections.emptyList(); - } catch (Throwable ignored) { - return Collections.emptyList(); - } - } - - private static List> createStructureLibFacingEntries(StructureLibControllerSpec controller, - List allowedFacings, Map filters) { - List> entries = new ArrayList<>(); - for (ExtendedFacing facing : allowedFacings) { - if (matchesStructureLibOrientationFilters(facing, filters, "facing")) { - String value = facing.getDirection() - .name() - .toLowerCase(Locale.ROOT); - entries.add( - createStructureLibEntry(value, "StructureLib facing", describeStructureLibOrientation(controller))); - } - } - return entries; - } - - private static List> createStructureLibRotationEntries(StructureLibControllerSpec controller, - List allowedFacings, Map filters) { - List> entries = new ArrayList<>(); - for (ExtendedFacing facing : allowedFacings) { - if (matchesStructureLibOrientationFilters(facing, filters, "rotation")) { - String value = facing.getRotation() - .getName(); - entries.add( - createStructureLibEntry( - value, - "StructureLib rotation", - describeStructureLibOrientation(controller))); - } - } - return entries; - } - - private static List> createStructureLibFlipEntries(StructureLibControllerSpec controller, - List allowedFacings, Map filters) { - List> entries = new ArrayList<>(); - for (ExtendedFacing facing : allowedFacings) { - if (matchesStructureLibOrientationFilters(facing, filters, "flip")) { - String value = facing.getFlip() - .getName(); - entries.add( - createStructureLibEntry(value, "StructureLib flip", describeStructureLibOrientation(controller))); - } - } - return entries; - } - - private static List findStructureLibAllowedFacings(StructureLibControllerSpec controller) { - List allowedFacings = new ArrayList<>(); - StructureLibRuntimeFacade.BuildContext context = new StructureLibRuntimeFacade.BuildContext(); - try { - StructureLibRuntimeFacade.ResolvedController resolvedController = new StructureLibRuntimeFacade.ResolvedController( - controller.getBlockId(), - controller.getBlock(), - controller.getMeta()); - TileEntity tile = StructureLibRuntimeFacade - .placeControllerDirectly(context.getLevel(), context.getWorld(), resolvedController, new ArrayList<>()); - if (tile == null) { - return Collections.emptyList(); - } - IAlignment alignment = StructureLibRuntimeFacade.resolveAlignment(tile); - if (alignment == null) { - return Collections.emptyList(); - } - for (ExtendedFacing facing : ExtendedFacing.VALUES) { - if (alignment.getAlignmentLimits() != null ? alignment.getAlignmentLimits() - .isNewExtendedFacingValid(facing) : alignment.checkedSetExtendedFacing(facing)) { - allowedFacings.add(facing); - } - } - return allowedFacings; - } catch (Throwable ignored) { - return Collections.emptyList(); - } finally { - context.clear(); - } - } - - private static boolean matchesStructureLibOrientationFilters(ExtendedFacing facing, Map filters, - String targetAttribute) { - return matchesStructureLibOrientationFilterValue( - facing.getDirection() - .name() - .toLowerCase(Locale.ROOT), - normalizeStructureLibValue(filters.get("facing")), - targetAttribute, - "facing") - && matchesStructureLibOrientationFilterValue( - facing.getRotation() - .getName(), - normalizeStructureLibValue(filters.get("rotation")), - targetAttribute, - "rotation") - && matchesStructureLibOrientationFilterValue( - facing.getFlip() - .getName(), - normalizeStructureLibValue(filters.get("flip")), - targetAttribute, - "flip"); - } - - private static boolean matchesStructureLibOrientationFilterValue(String actualValue, - @Nullable String requestedValue, String targetAttribute, String attributeName) { - if (targetAttribute.equals(attributeName) || requestedValue == null) { - return true; - } - return actualValue.equalsIgnoreCase(requestedValue); - } - - private static String describeStructureLibOrientation(StructureLibControllerSpec controller) { - return "Allowed orientation for " + controller.getControllerArgument(); - } - - private static boolean isStructureLibOrientationAttribute(@Nullable String attribute) { - return "facing".equals(attribute) || "rotation".equals(attribute) || "flip".equals(attribute); - } - - private static String describeStructureLibTierRange(String controller, - StructureLibRuntimeFacade.ControlAnalysis analysis) { - StringBuilder detail = new StringBuilder(); - detail.append("Preview tier for ") - .append(controller) - .append(" (max ") - .append(analysis.getMaxTotalTier()) - .append(')'); - if (analysis.getChannelMaxTierMap() - .isEmpty()) { - return detail.toString(); - } - - detail.append(" | Channel caps: "); - boolean first = true; - for (Map.Entry entry : analysis.getChannelMaxTierMap() - .entrySet()) { - String channelId = normalizeStructureLibValue(entry.getKey()); - Integer maxValue = entry.getValue(); - if (channelId == null || maxValue == null || maxValue <= 0) { - continue; - } - if (!first) { - detail.append(", "); - } - detail.append(channelId) - .append('=') - .append(maxValue); - first = false; - } - return detail.toString(); - } - - private static List> normalizeStructureLibEntries(List> entries) { - Map> deduplicated = new LinkedHashMap<>(); - for (Map entry : entries) { - if (entry == null) { - continue; - } - String id = normalizeStructureLibValue(entry.get("id")); - if (id == null) { - continue; - } - Map normalized = new LinkedHashMap<>(); - normalized.put("id", id); - String label = normalizeStructureLibValue(entry.get("label")); - if (label != null) { - normalized.put("label", label); - } - String detail = normalizeStructureLibValue(entry.get("detail")); - if (detail != null) { - normalized.put("detail", detail); - } - deduplicated.putIfAbsent(id.toLowerCase(Locale.ROOT), normalized); - } - List> normalizedEntries = new ArrayList<>(deduplicated.values()); - normalizedEntries.sort( - (left, right) -> left.get("id") - .compareToIgnoreCase(right.get("id"))); - return normalizedEntries; - } - - private static List> filterStructureLibEntries(List> entries, - String prefix) { - String normalizedPrefix = prefix == null ? "" - : prefix.trim() - .toLowerCase(Locale.ROOT); - if (normalizedPrefix.isEmpty()) { - return entries; - } - List> filteredEntries = new ArrayList<>(); - for (Map entry : entries) { - if (startsWithIgnoreCase(entry.get("id"), normalizedPrefix) - || startsWithIgnoreCase(entry.get("label"), normalizedPrefix) - || startsWithIgnoreCase(entry.get("detail"), normalizedPrefix)) { - filteredEntries.add(entry); - } - } - return filteredEntries; - } - - private static int parseStructureLibCursor(String cursor, int size) { - if (cursor == null || cursor.isEmpty()) { - return 0; - } - try { - return Math.max(0, Math.min(Integer.parseInt(cursor), size)); - } catch (NumberFormatException ignored) { - return 0; - } - } - - private static int computeStructureLibVersion(List> entries) { - int hash = entries.hashCode(); - if (hash == Integer.MIN_VALUE) { - return Integer.MAX_VALUE; - } - return Math.abs(hash) + 1; - } - - private static boolean startsWithIgnoreCase(@Nullable String value, String prefix) { - return value != null && value.toLowerCase(Locale.ROOT) - .startsWith(prefix); - } - - private static @Nullable String normalizeStructureLibValue(@Nullable String value) { - if (value == null) { - return null; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - private static Map createStructureLibEntry(String id, @Nullable String label, - @Nullable String detail) { - Map entry = new LinkedHashMap<>(); - entry.put("id", id); - if (label != null) { - entry.put("label", label); - } - if (detail != null) { - entry.put("detail", detail); - } - return entry; - } - - private static void addItemEntries(List> entries) { - for (Object rawItem : Item.itemRegistry) { - if (!(rawItem instanceof Item item)) { - continue; - } - String baseId = resolveRegistryName(Item.itemRegistry.getNameForObject(item)); - if (baseId == null) { - continue; - } - - List variants = collectItemVariants(item); - if (variants.isEmpty()) { - variants.add(new ItemStack(item, 1, 0)); - } - - for (ItemStack stack : variants) { - if (stack == null || stack.getItem() == null) { - continue; - } - String insertId = buildInsertId(baseId, stack.getItemDamage()); - String detail = buildDisplayId(baseId, stack.getItemDamage()); - String label = resolveItemLabel(stack, baseId); - entries.add(entry(insertId, label, detail)); - } - } - } - - private static void addBlockOnlyEntries(List> entries) { - for (Object rawBlock : Block.blockRegistry) { - if (!(rawBlock instanceof Block block) || Item.getItemFromBlock(block) != null) { - continue; - } - String baseId = resolveRegistryName(Block.blockRegistry.getNameForObject(block)); - if (baseId == null) { - continue; - } - String label = resolveBlockLabel(block, baseId); - entries.add(entry(baseId, label, baseId + ":0")); - } - } - - private static void addGuideCommandEntries(List> entries) { - String root = "/" + new GuideCommand().getCommandName(); - entries.add(entry(root, "Open guide command", "Guide command root")); - entries.add(entry(root + " list", "List guides", "Lists registered guides")); - entries.add(entry(root + " open", "Open a guide", "Open a guide by id")); - entries.add(entry(root + " reload", "Reload guides", "Reload guide resources")); - entries.add(entry(root + " search", "Search guides", "Search guide content")); - addGuideOpenEntries(entries, root + " open", "Open guide"); - } - - private static void addGuideNhClientCommandEntries(List> entries) { - String root = "/" + new GuideNhClientCommand().getCommandName(); - entries.add(entry(root, "Open client guide command", "GuideNH client command root")); - for (String subCommand : GuideNhClientCommand.ROOT_SUB_COMMANDS) { - String command = root + " " + subCommand; - entries.add(entry(command, formatCommandLabel(subCommand), "GuideNH client command")); - } - addGuideOpenEntries(entries, root + " open", "Open guide"); - addGuideOpenEntries(entries, root + " export", "Export guide"); - addCommandOptionEntries( - entries, - root + " exportsite", - GuideNhClientCommand.EXPORT_SITE_FLAGS, - "Export site option"); - addCommandOptionEntries( - entries, - root + " exportstructure", - GuideNhClientCommand.EXPORT_STRUCTURE_FLAGS, - "Export structure option"); - } - - private static void addStructureExportCommandEntries(List> entries) { - String root = "/" + new StructureExportCommand().getCommandName(); - entries.add(entry(root, "Export structure", "Structure export command root")); - for (String subCommand : StructureExportCommand.SUBCOMMANDS) { - String command = root + " " + subCommand; - entries.add(entry(command, formatCommandLabel(subCommand), "Structure export subcommand")); - } - addCommandOptionEntries( - entries, - root + " " + StructureExportCommand.SUBCOMMAND_STRUCTURE_LIB, - StructureExportCommand.STRUCTURE_LIB_OPTIONS, - "StructureLib export option"); - addCommandOptionEntries( - entries, - root + " " + StructureExportCommand.SUBCOMMAND_GAME_SCENE, - StructureExportCommand.GAME_SCENE_OPTIONS, - "Game scene export option"); - } - - private static String buildInsertId(String baseId, int meta) { - return meta > 0 ? baseId + ":" + meta : baseId; - } - - private static String buildDisplayId(String baseId, int meta) { - return baseId + ":" + Math.max(meta, 0); - } - - private static List collectItemVariants(Item item) { - List variants = new ArrayList<>(); - try { - item.getSubItems(item, CreativeTabs.tabAllSearch, variants); - } catch (Throwable ignored) {} - - if (variants.isEmpty()) { - return variants; - } - - Map uniqueVariants = new LinkedHashMap<>(); - for (ItemStack variant : variants) { - if (variant == null || variant.getItem() == null) { - continue; - } - String key = ItemIndex.formatKey(ItemId.createNoCopy(variant.getItem(), variant.getItemDamage(), null)); - uniqueVariants.putIfAbsent(key, variant); - } - return new ArrayList<>(uniqueVariants.values()); - } - - private static @Nullable String resolveRegistryName(Object registryName) { - if (registryName == null) { - return null; - } - String value = registryName.toString(); - return value.isEmpty() ? null : value; - } - - private static String resolveItemLabel(ItemStack stack, String fallback) { - try { - String displayName = stack.getDisplayName(); - if (displayName != null && !displayName.trim() - .isEmpty()) { - return displayName; - } - } catch (Throwable ignored) {} - return fallback; - } - - private static String resolveBlockLabel(Block block, String fallback) { - try { - String localizedName = block.getLocalizedName(); - if (localizedName != null && !localizedName.trim() - .isEmpty()) { - return localizedName; - } - } catch (Throwable ignored) {} - return fallback; - } - - private static List getAllParsedPages() { - List pages = new ArrayList<>(); - for (MutableGuide guide : GuideRegistry.getAll()) { - try { - pages.addAll(guide.getPages()); - } catch (IllegalStateException ignored) {} - } - return pages; - } - - private static void addGuideOpenEntries(List> entries, String prefix, String labelPrefix) { - for (MutableGuide guide : GuideRegistry.getAll()) { - ResourceLocation guideId = guide.getId(); - if (guideId == null) { - continue; - } - String id = guideId.toString(); - entries.add(entry(prefix + " " + id, labelPrefix + ": " + id, "Guide id")); - } - } - - private static void addCommandOptionEntries(List> entries, String prefix, String[] options, - String detail) { - for (String option : options) { - if (option == null || option.trim() - .isEmpty()) { - continue; - } - entries.add(entry(prefix + " " + option, formatCommandLabel(option), detail)); - } - } - - private static List readStringList(ParsedGuidePage page, String key) { - Object value = page.getFrontmatter() - .additionalProperties() - .get(key); - if (!(value instanceof Listvalues)) { - return Collections.emptyList(); - } - - List strings = new ArrayList<>(); - for (Object rawValue : values) { - if (rawValue instanceof String stringValue) { - String trimmed = stringValue.trim(); - if (!trimmed.isEmpty()) { - strings.add(trimmed); - } - } - } - return strings; - } - - private static String resolvePageTitle(ParsedGuidePage page) { - FrontmatterNavigation navigation = page.getFrontmatter() - .navigationEntry(); - if (navigation != null && navigation.title() != null - && !navigation.title() - .trim() - .isEmpty()) { - return navigation.title(); - } - return page.getId() - .getResourcePath(); - } - - private static String resolvePageDetail(ParsedGuidePage page) { - return page.getLanguage() + " - " + page.getSourcePack(); - } - - private static @Nullable SoundHandler resolveSoundHandler() { - Minecraft minecraft = Minecraft.getMinecraft(); - return minecraft != null ? minecraft.getSoundHandler() : null; - } - - private static void collectSoundIds(SoundHandler soundHandler, Set soundIds) { - try { - for (Field field : getAllFields(soundHandler.getClass())) { - if (Modifier.isStatic(field.getModifiers())) { - continue; - } - field.setAccessible(true); - Object value = field.get(soundHandler); - collectSoundIdsFromValue(value, soundIds, 2); - } - } catch (IllegalAccessException ignored) {} - } - - private static void collectSoundIdsFromValue(@Nullable Object value, Set soundIds, int depth) { - if (value == null || depth < 0) { - return; - } - - if (value instanceof ResourceLocation resourceLocation) { - soundIds.add(resourceLocation.toString()); - return; - } - - if (value instanceof Mapmap) { - for (Map.Entry entry : map.entrySet()) { - collectSoundIdsFromValue(entry.getKey(), soundIds, depth - 1); - collectSoundIdsFromValue(entry.getValue(), soundIds, depth - 1); - } - return; - } + private RuntimeSemanticProviders() {} - if (value instanceof Iterableiterable) { - for (Object element : iterable) { - collectSoundIdsFromValue(element, soundIds, depth - 1); - } - return; - } - - Class type = value.getClass(); - if (type.isArray()) { - int length = Array.getLength(value); - for (int index = 0; index < length; index++) { - collectSoundIdsFromValue(Array.get(value, index), soundIds, depth - 1); - } - return; - } - - String typeName = type.getName() - .toLowerCase(Locale.ROOT); - if (!typeName.contains("sound")) { - return; - } - - for (Field field : getAllFields(type)) { - if (Modifier.isStatic(field.getModifiers())) { - continue; - } - try { - field.setAccessible(true); - collectSoundIdsFromValue(field.get(value), soundIds, depth - 1); - } catch (IllegalAccessException ignored) {} - } - } - - private static List getAllFields(Class type) { - List fields = new ArrayList<>(); - for (Class current = type; current != null; current = current.getSuperclass()) { - Collections.addAll(fields, current.getDeclaredFields()); - } - return fields; - } - - private static String localizeKey(@Nullable String translationKey) { - if (translationKey == null || translationKey.trim() - .isEmpty()) { - return ""; - } - - try { - String localized = StatCollector.translateToLocal(translationKey); - if (localized != null && !localized.equals(translationKey)) { - return localized; - } - } catch (Throwable ignored) {} - - try { - String localized = I18n.format(translationKey); - if (localized != null && !localized.equals(translationKey)) { - return localized; - } - } catch (Throwable ignored) {} - - return translationKey; - } - - private static boolean isUuidLike(String value) { - try { - UUID.fromString(value); - return true; - } catch (IllegalArgumentException ignored) { - return false; - } - } - - private static String formatCommandLabel(String value) { - if (value == null || value.isEmpty()) { - return ""; - } - if (value.startsWith("--")) { - return value; - } - - StringBuilder builder = new StringBuilder(); - char previous = 0; - for (int index = 0; index < value.length(); index++) { - char current = value.charAt(index); - if (index > 0 && Character.isUpperCase(current) && Character.isLowerCase(previous)) { - builder.append(' '); - } else if (current == '-' || current == '_') { - builder.append(' '); - previous = current; - continue; - } - builder.append(current); - previous = current; - } - if (builder.length() == 0) { - return value; - } - builder.setCharAt(0, Character.toUpperCase(builder.charAt(0))); - return builder.toString(); - } - - private static Map entry(String id, String label, String detail) { - Map entry = new LinkedHashMap<>(); - entry.put("id", id); - if (label != null && !label.isEmpty()) { - entry.put("label", label); - } - if (detail != null && !detail.isEmpty()) { - entry.put("detail", detail); - } - return entry; + public static void registerBaseline(SemanticProviderRegistry registry) { + SemanticProvider itemProvider = new ItemSemanticProvider(); + registry.register(itemProvider); + registry.register(new AliasSemanticProvider(SemanticCapability.RECIPES, itemProvider)); + registry.register(new PageSemanticProvider()); + registry.register(new OreSemanticProvider()); + registry.register(new CategorySemanticProvider()); + registry.register(new ModSemanticProvider()); + registry.register(new CommandSemanticProvider()); + registry.register(new SoundSemanticProvider()); + registry.register(new KeybindSemanticProvider()); + registry.register(new QuestSemanticProvider()); + registry.register(new StructureLibSemanticProvider()); + registry.register(new EntitySemanticProvider()); } } diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticSupport.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticSupport.java new file mode 100644 index 00000000..40b03980 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticSupport.java @@ -0,0 +1,791 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; + +import net.minecraft.block.Block; +import net.minecraft.client.Minecraft; +import net.minecraft.client.audio.SoundHandler; +import net.minecraft.client.resources.I18n; +import net.minecraft.client.settings.KeyBinding; +import net.minecraft.command.CommandClearInventory; +import net.minecraft.command.CommandDebug; +import net.minecraft.command.CommandDefaultGameMode; +import net.minecraft.command.CommandDifficulty; +import net.minecraft.command.CommandEffect; +import net.minecraft.command.CommandEnchant; +import net.minecraft.command.CommandGameMode; +import net.minecraft.command.CommandGameRule; +import net.minecraft.command.CommandGive; +import net.minecraft.command.CommandHandler; +import net.minecraft.command.CommandHelp; +import net.minecraft.command.CommandKill; +import net.minecraft.command.CommandPlaySound; +import net.minecraft.command.CommandServerKick; +import net.minecraft.command.CommandSetPlayerTimeout; +import net.minecraft.command.CommandSetSpawnpoint; +import net.minecraft.command.CommandShowSeed; +import net.minecraft.command.CommandSpreadPlayers; +import net.minecraft.command.CommandTime; +import net.minecraft.command.CommandToggleDownfall; +import net.minecraft.command.CommandWeather; +import net.minecraft.command.CommandXP; +import net.minecraft.command.ICommand; +import net.minecraft.command.ICommandManager; +import net.minecraft.command.ICommandSender; +import net.minecraft.command.server.CommandAchievement; +import net.minecraft.command.server.CommandBanIp; +import net.minecraft.command.server.CommandBanPlayer; +import net.minecraft.command.server.CommandBroadcast; +import net.minecraft.command.server.CommandDeOp; +import net.minecraft.command.server.CommandEmote; +import net.minecraft.command.server.CommandListBans; +import net.minecraft.command.server.CommandListPlayers; +import net.minecraft.command.server.CommandMessage; +import net.minecraft.command.server.CommandMessageRaw; +import net.minecraft.command.server.CommandNetstat; +import net.minecraft.command.server.CommandOp; +import net.minecraft.command.server.CommandPardonIp; +import net.minecraft.command.server.CommandPardonPlayer; +import net.minecraft.command.server.CommandPublishLocalServer; +import net.minecraft.command.server.CommandSaveAll; +import net.minecraft.command.server.CommandSaveOff; +import net.minecraft.command.server.CommandSaveOn; +import net.minecraft.command.server.CommandScoreboard; +import net.minecraft.command.server.CommandSetBlock; +import net.minecraft.command.server.CommandSetDefaultSpawnpoint; +import net.minecraft.command.server.CommandStop; +import net.minecraft.command.server.CommandSummon; +import net.minecraft.command.server.CommandTeleport; +import net.minecraft.command.server.CommandTestFor; +import net.minecraft.command.server.CommandTestForBlock; +import net.minecraft.command.server.CommandWhitelist; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.StatCollector; +import net.minecraftforge.client.ClientCommandHandler; +import net.minecraftforge.oredict.OreDictionary; + +import org.jetbrains.annotations.Nullable; + +import com.gtnewhorizon.gtnhlib.util.data.ItemId; +import com.hfstudio.guidenh.client.command.GuideNhClientCommand; +import com.hfstudio.guidenh.guide.compiler.FrontmatterNavigation; +import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; +import com.hfstudio.guidenh.guide.compiler.tags.KeyBindTagCompiler; +import com.hfstudio.guidenh.guide.indices.ItemIndex; +import com.hfstudio.guidenh.guide.internal.GuideCommand; +import com.hfstudio.guidenh.guide.internal.GuideRegistry; +import com.hfstudio.guidenh.guide.internal.MutableGuide; +import com.hfstudio.guidenh.guide.scene.element.GuidebookSceneEntityLoader; +import com.hfstudio.structurelibexport.StructureExportCommand; + +import cpw.mods.fml.common.Loader; +import cpw.mods.fml.common.ModContainer; + +public class RuntimeSemanticSupport { + + private RuntimeSemanticSupport() {} + + public static void addItemEntries(List> entries) { + for (Object rawItem : Item.itemRegistry) { + if (!(rawItem instanceof Item item)) { + continue; + } + String baseId = resolveRegistryName(Item.itemRegistry.getNameForObject(item)); + if (baseId == null) { + continue; + } + + List variants = collectItemVariants(item); + if (variants.isEmpty()) { + variants.add(new ItemStack(item, 1, 0)); + } + + for (ItemStack stack : variants) { + if (stack == null || stack.getItem() == null) { + continue; + } + String insertId = buildInsertId(baseId, stack.getItemDamage()); + String detail = buildDisplayId(baseId, stack.getItemDamage()); + String label = resolveItemLabel(stack, baseId); + entries.add(createEntry(insertId, label, detail)); + } + } + } + + public static void addBlockOnlyEntries(List> entries) { + for (Object rawBlock : Block.blockRegistry) { + if (!(rawBlock instanceof Block block) || Item.getItemFromBlock(block) != null) { + continue; + } + String baseId = resolveRegistryName(Block.blockRegistry.getNameForObject(block)); + if (baseId == null) { + continue; + } + String label = resolveBlockLabel(block, baseId); + entries.add(createEntry(baseId, label, baseId + ":0")); + } + } + + public static void addOreEntries(List> entries) { + String[] oreNames = OreDictionary.getOreNames(); + for (String oreName : oreNames) { + if (oreName == null || oreName.trim() + .isEmpty()) { + continue; + } + int stackCount = OreDictionary.getOres(oreName) + .size(); + entries.add(createEntry(oreName, "Ore Dictionary", "Variants: " + stackCount)); + } + } + + public static void addCategoryEntries(List> entries) { + Map counts = new LinkedHashMap<>(); + Map firstPageByCategory = new LinkedHashMap<>(); + for (ParsedGuidePage page : getAllParsedPages()) { + for (String category : readStringList(page, "categories")) { + counts.put(category, counts.getOrDefault(category, Integer.valueOf(0)) + 1); + firstPageByCategory.putIfAbsent( + category, + page.getId() + .getResourcePath()); + } + } + + for (Map.Entry entry : counts.entrySet()) { + String category = entry.getKey(); + Integer count = entry.getValue(); + String detail = firstPageByCategory.get(category); + entries.add(createEntry(category, "Referenced by " + count + " page(s)", detail)); + } + } + + public static void addModEntries(List> entries) { + Map indexedMods = Loader.instance() + .getIndexedModList(); + for (Map.Entry entry : indexedMods.entrySet()) { + String modId = entry.getKey(); + ModContainer mod = entry.getValue(); + if (modId == null || modId.trim() + .isEmpty() || mod == null) { + continue; + } + String version = trimToNull(mod.getVersion()); + String detail = version != null ? version : modId; + entries.add(createEntry(modId, trimToNull(mod.getName()), detail)); + } + } + + public static void addGuideCommandEntries(List> entries) { + String root = "/" + new GuideCommand().getCommandName(); + entries.add(createEntry(root, "Open guide command", "Guide command root")); + entries.add(createEntry(root + " list", "List guides", "Lists registered guides")); + entries.add(createEntry(root + " open", "Open a guide", "Open a guide by id")); + entries.add(createEntry(root + " reload", "Reload guides", "Reload guide resources")); + entries.add(createEntry(root + " search", "Search guides", "Search guide content")); + addGuideOpenEntries(entries, root + " open", "Open guide"); + } + + public static void addGuideNhClientCommandEntries(List> entries) { + String root = "/" + new GuideNhClientCommand().getCommandName(); + entries.add(createEntry(root, "Open client guide command", "GuideNH client command root")); + for (String subCommand : GuideNhClientCommand.ROOT_SUB_COMMANDS) { + String command = root + " " + subCommand; + entries.add(createEntry(command, formatCommandLabel(subCommand), "GuideNH client command")); + } + addGuideOpenEntries(entries, root + " open", "Open guide"); + addGuideOpenEntries(entries, root + " export", "Export guide"); + addCommandOptionEntries( + entries, + root + " exportsite", + GuideNhClientCommand.EXPORT_SITE_FLAGS, + "Export site option"); + addCommandOptionEntries( + entries, + root + " exportstructure", + GuideNhClientCommand.EXPORT_STRUCTURE_FLAGS, + "Export structure option"); + } + + public static void addStructureExportCommandEntries(List> entries) { + String root = "/" + new StructureExportCommand().getCommandName(); + entries.add(createEntry(root, "Export structure", "Structure export command root")); + for (String subCommand : StructureExportCommand.SUBCOMMANDS) { + String command = root + " " + subCommand; + entries.add(createEntry(command, formatCommandLabel(subCommand), "Structure export subcommand")); + } + addCommandOptionEntries( + entries, + root + " " + StructureExportCommand.SUBCOMMAND_STRUCTURE_LIB, + StructureExportCommand.STRUCTURE_LIB_OPTIONS, + "StructureLib export option"); + addCommandOptionEntries( + entries, + root + " " + StructureExportCommand.SUBCOMMAND_GAME_SCENE, + StructureExportCommand.GAME_SCENE_OPTIONS, + "Game scene export option"); + } + + public static void addGlobalCommandEntries(List> entries) { + addCommandHandlerEntries(entries, ClientCommandHandler.instance, "Client command"); + MinecraftServer minecraftServer = MinecraftServer.getServer(); + if (minecraftServer == null) { + addFallbackServerCommandEntries(entries); + return; + } + ICommandManager commandManager = minecraftServer.getCommandManager(); + if (!(commandManager instanceof CommandHandler commandHandler)) { + addFallbackServerCommandEntries(entries); + return; + } + addCommandHandlerEntries(entries, commandHandler, "Server command"); + } + + public static void addSoundEntries(List> entries) { + SoundHandler soundHandler = resolveSoundHandler(); + if (soundHandler == null) { + return; + } + + Set soundIds = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + collectSoundIds(soundHandler, soundIds); + for (String soundId : soundIds) { + entries.add(createEntry(soundId, "Registered sound", soundId)); + } + } + + public static void addKeybindEntries(List> entries) { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.gameSettings == null || minecraft.gameSettings.keyBindings == null) { + return; + } + + for (KeyBinding keyBinding : minecraft.gameSettings.keyBindings) { + if (keyBinding == null) { + continue; + } + String id = trimToNull(keyBinding.getKeyDescription()); + if (id == null) { + continue; + } + + String actionName = localizeKey(id); + String bindingName = trimToNull(KeyBindTagCompiler.describeMapping(keyBinding)); + String categoryName = localizeKey(keyBinding.getKeyCategory()); + String label = actionName; + if (bindingName != null) { + label = actionName + " - " + bindingName; + } + entries.add(createEntry(id, label, categoryName)); + } + } + + public static void addQuestEntries(List> entries) { + for (ParsedGuidePage page : getAllParsedPages()) { + String pageTitle = resolvePageTitle(page); + String pagePath = page.getId() + .getResourcePath(); + for (String questId : readStringList(page, "quest_ids")) { + if (!isUuidLike(questId)) { + continue; + } + entries.add(createEntry(questId, pageTitle, pagePath)); + } + } + } + + public static void addPageEntries(List> entries) { + Map> entriesById = new LinkedHashMap<>(); + for (ParsedGuidePage page : getAllParsedPages()) { + String pagePath = page.getId() + .getResourcePath(); + String title = resolvePageTitle(page); + String detail = resolvePageDetail(page); + entriesById.putIfAbsent(pagePath, createEntry(pagePath, title, detail)); + } + entries.addAll(entriesById.values()); + } + + public static void addEntityEntries(List> entries) { + LinkedHashSet entityIds = new LinkedHashSet<>(); + for (Object rawId : net.minecraft.entity.EntityList.stringToClassMapping.keySet()) { + if (rawId instanceof String entityId) { + GuidebookSceneEntityLoader.addCandidateForms(entityIds, entityId); + } + } + for (String previewPlayerId : GuidebookSceneEntityLoader.PREVIEW_PLAYER_IDS) { + GuidebookSceneEntityLoader.addCandidateForms(entityIds, previewPlayerId); + } + + for (String entityId : entityIds) { + String simpleToken = GuidebookSceneEntityLoader.extractSimpleEntityToken(entityId); + String label = simpleToken == null ? entityId : formatEntityLabel(simpleToken); + entries.add(createEntry(entityId, label, entityId)); + } + } + + public static List getAllParsedPages() { + List pages = new ArrayList<>(); + for (MutableGuide guide : GuideRegistry.getAll()) { + try { + pages.addAll(guide.getPages()); + } catch (IllegalStateException ignored) {} + } + return pages; + } + + public static List readStringList(ParsedGuidePage page, String key) { + Object value = page.getFrontmatter() + .additionalProperties() + .get(key); + if (!(value instanceof Listvalues)) { + return Collections.emptyList(); + } + + List strings = new ArrayList<>(); + for (Object rawValue : values) { + if (rawValue instanceof String stringValue) { + String trimmed = stringValue.trim(); + if (!trimmed.isEmpty()) { + strings.add(trimmed); + } + } + } + return strings; + } + + public static String resolvePageTitle(ParsedGuidePage page) { + FrontmatterNavigation navigation = page.getFrontmatter() + .navigationEntry(); + if (navigation != null && navigation.title() != null + && !navigation.title() + .trim() + .isEmpty()) { + return navigation.title(); + } + return page.getId() + .getResourcePath(); + } + + public static String resolvePageDetail(ParsedGuidePage page) { + return page.getLanguage() + " - " + page.getSourcePack(); + } + + public static Map createEntry(String id, @Nullable String label, @Nullable String detail) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + if (label != null && !label.isEmpty()) { + entry.put("label", label); + } + if (detail != null && !detail.isEmpty()) { + entry.put("detail", detail); + } + return entry; + } + + public static @Nullable String trimToNull(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static void addGuideOpenEntries(List> entries, String prefix, String labelPrefix) { + for (MutableGuide guide : GuideRegistry.getAll()) { + ResourceLocation guideId = guide.getId(); + if (guideId == null) { + continue; + } + String id = guideId.toString(); + entries.add(createEntry(prefix + " " + id, labelPrefix + ": " + id, "Guide id")); + } + } + + private static void addCommandOptionEntries(List> entries, String prefix, String[] options, + String detail) { + for (String option : options) { + if (option == null || option.trim() + .isEmpty()) { + continue; + } + entries.add(createEntry(prefix + " " + option, formatCommandLabel(option), detail)); + } + } + + private static String buildInsertId(String baseId, int meta) { + return meta > 0 ? baseId + ":" + meta : baseId; + } + + private static String buildDisplayId(String baseId, int meta) { + return baseId + ":" + Math.max(meta, 0); + } + + private static List collectItemVariants(Item item) { + List variants = new ArrayList<>(); + try { + item.getSubItems(item, CreativeTabs.tabAllSearch, variants); + } catch (Throwable ignored) {} + + if (variants.isEmpty()) { + return variants; + } + + Map uniqueVariants = new LinkedHashMap<>(); + for (ItemStack variant : variants) { + if (variant == null || variant.getItem() == null) { + continue; + } + String key = ItemIndex.formatKey(ItemId.createNoCopy(variant.getItem(), variant.getItemDamage(), null)); + uniqueVariants.putIfAbsent(key, variant); + } + return new ArrayList<>(uniqueVariants.values()); + } + + private static @Nullable String resolveRegistryName(Object registryName) { + if (registryName == null) { + return null; + } + String value = registryName.toString(); + return value.isEmpty() ? null : value; + } + + private static String resolveItemLabel(ItemStack stack, String fallback) { + try { + String displayName = stack.getDisplayName(); + if (displayName != null && !displayName.trim() + .isEmpty()) { + return displayName; + } + } catch (Throwable ignored) {} + return fallback; + } + + private static String resolveBlockLabel(Block block, String fallback) { + try { + String localizedName = block.getLocalizedName(); + if (localizedName != null && !localizedName.trim() + .isEmpty()) { + return localizedName; + } + } catch (Throwable ignored) {} + return fallback; + } + + private static @Nullable SoundHandler resolveSoundHandler() { + Minecraft minecraft = Minecraft.getMinecraft(); + return minecraft != null ? minecraft.getSoundHandler() : null; + } + + private static void collectSoundIds(SoundHandler soundHandler, Set soundIds) { + try { + for (Field field : getAllFields(soundHandler.getClass())) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + field.setAccessible(true); + Object value = field.get(soundHandler); + collectSoundIdsFromValue(value, soundIds, 2); + } + } catch (IllegalAccessException ignored) {} + } + + private static void collectSoundIdsFromValue(@Nullable Object value, Set soundIds, int depth) { + if (value == null || depth < 0) { + return; + } + + if (value instanceof ResourceLocation resourceLocation) { + soundIds.add(resourceLocation.toString()); + return; + } + + if (value instanceof Mapmap) { + for (Map.Entry entry : map.entrySet()) { + collectSoundIdsFromValue(entry.getKey(), soundIds, depth - 1); + collectSoundIdsFromValue(entry.getValue(), soundIds, depth - 1); + } + return; + } + + if (value instanceof Iterableiterable) { + for (Object element : iterable) { + collectSoundIdsFromValue(element, soundIds, depth - 1); + } + return; + } + + Class type = value.getClass(); + if (type.isArray()) { + int length = Array.getLength(value); + for (int index = 0; index < length; index++) { + collectSoundIdsFromValue(Array.get(value, index), soundIds, depth - 1); + } + return; + } + + String typeName = type.getName() + .toLowerCase(Locale.ROOT); + if (!typeName.contains("sound")) { + return; + } + + for (Field field : getAllFields(type)) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + try { + field.setAccessible(true); + collectSoundIdsFromValue(field.get(value), soundIds, depth - 1); + } catch (IllegalAccessException ignored) {} + } + } + + private static List getAllFields(Class type) { + List fields = new ArrayList<>(); + for (Class current = type; current != null; current = current.getSuperclass()) { + Collections.addAll(fields, current.getDeclaredFields()); + } + return fields; + } + + private static String localizeKey(@Nullable String translationKey) { + if (translationKey == null || translationKey.trim() + .isEmpty()) { + return ""; + } + + try { + String localized = StatCollector.translateToLocal(translationKey); + if (localized != null && !localized.equals(translationKey)) { + return localized; + } + } catch (Throwable ignored) {} + + try { + String localized = I18n.format(translationKey); + if (localized != null && !localized.equals(translationKey)) { + return localized; + } + } catch (Throwable ignored) {} + + return translationKey; + } + + private static boolean isUuidLike(String value) { + try { + UUID.fromString(value); + return true; + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private static String formatCommandLabel(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + if (value.startsWith("--")) { + return value; + } + + StringBuilder builder = new StringBuilder(); + char previous = 0; + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (index > 0 && Character.isUpperCase(current) && Character.isLowerCase(previous)) { + builder.append(' '); + } else if (current == '-' || current == '_') { + builder.append(' '); + previous = current; + continue; + } + builder.append(current); + previous = current; + } + if (builder.length() == 0) { + return value; + } + builder.setCharAt(0, Character.toUpperCase(builder.charAt(0))); + return builder.toString(); + } + + private static void addCommandHandlerEntries(List> entries, CommandHandler commandHandler, + String sourceLabel) { + Map commands = commandHandler.getCommands(); + if (commands == null || commands.isEmpty()) { + return; + } + ICommandSender commandSender = resolveCommandSender(); + for (Map.Entry entry : commands.entrySet()) { + String commandName = trimToNull(entry.getKey()); + ICommand command = entry.getValue(); + if (commandName == null || command == null) { + continue; + } + String commandId = "/" + commandName; + entries.add( + createEntry( + commandId, + resolveCommandEntryLabel(command, commandName, sourceLabel), + resolveCommandEntryDetail(command, commandSender, sourceLabel))); + } + } + + private static void addFallbackServerCommandEntries(List> entries) { + for (ICommand command : createFallbackServerCommands()) { + String commandName = trimToNull(command.getCommandName()); + if (commandName == null) { + continue; + } + entries.add( + createEntry( + "/" + commandName, + resolveCommandEntryLabel(command, commandName, "Builtin server command"), + "Builtin server command")); + List aliases = command.getCommandAliases(); + if (aliases == null) { + continue; + } + for (Object rawAlias : aliases) { + if (!(rawAlias instanceof String)) { + continue; + } + String alias = trimToNull((String) rawAlias); + if (alias == null) { + continue; + } + entries.add( + createEntry( + "/" + alias, + resolveCommandEntryLabel(command, alias, "Builtin server alias"), + "Builtin server alias")); + } + } + } + + private static List createFallbackServerCommands() { + List commands = new ArrayList<>(); + commands.add(new CommandTime()); + commands.add(new CommandGameMode()); + commands.add(new CommandDifficulty()); + commands.add(new CommandDefaultGameMode()); + commands.add(new CommandKill()); + commands.add(new CommandToggleDownfall()); + commands.add(new CommandWeather()); + commands.add(new CommandXP()); + commands.add(new CommandTeleport()); + commands.add(new CommandGive()); + commands.add(new CommandEffect()); + commands.add(new CommandEnchant()); + commands.add(new CommandEmote()); + commands.add(new CommandShowSeed()); + commands.add(new CommandHelp()); + commands.add(new CommandDebug()); + commands.add(new CommandMessage()); + commands.add(new CommandBroadcast()); + commands.add(new CommandSetSpawnpoint()); + commands.add(new CommandSetDefaultSpawnpoint()); + commands.add(new CommandGameRule()); + commands.add(new CommandClearInventory()); + commands.add(new CommandTestFor()); + commands.add(new CommandSpreadPlayers()); + commands.add(new CommandPlaySound()); + commands.add(new CommandScoreboard()); + commands.add(new CommandAchievement()); + commands.add(new CommandSummon()); + commands.add(new CommandSetBlock()); + commands.add(new CommandTestForBlock()); + commands.add(new CommandMessageRaw()); + commands.add(new CommandPublishLocalServer()); + commands.add(new CommandOp()); + commands.add(new CommandDeOp()); + commands.add(new CommandStop()); + commands.add(new CommandSaveAll()); + commands.add(new CommandSaveOff()); + commands.add(new CommandSaveOn()); + commands.add(new CommandBanIp()); + commands.add(new CommandPardonIp()); + commands.add(new CommandBanPlayer()); + commands.add(new CommandListBans()); + commands.add(new CommandPardonPlayer()); + commands.add(new CommandServerKick()); + commands.add(new CommandListPlayers()); + commands.add(new CommandWhitelist()); + commands.add(new CommandSetPlayerTimeout()); + commands.add(new CommandNetstat()); + return commands; + } + + private static ICommandSender resolveCommandSender() { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft != null && minecraft.thePlayer != null) { + return minecraft.thePlayer; + } + MinecraftServer minecraftServer = MinecraftServer.getServer(); + return minecraftServer != null ? minecraftServer : null; + } + + private static String resolveCommandEntryLabel(ICommand command, String commandName, String sourceLabel) { + String translatedName = formatCommandLabel(commandName); + String commandLabel = trimToNull(command.getCommandName()); + if (commandLabel != null && !commandLabel.equalsIgnoreCase(commandName)) { + return translatedName + " (" + commandLabel + ")"; + } + return translatedName + " - " + sourceLabel; + } + + private static String resolveCommandEntryDetail(ICommand command, ICommandSender sender, String sourceLabel) { + if (sender != null) { + try { + String usage = trimToNull(command.getCommandUsage(sender)); + if (usage != null) { + return usage; + } + } catch (Throwable ignored) {} + } + return sourceLabel; + } + + private static String formatEntityLabel(String simpleToken) { + if (simpleToken == null || simpleToken.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + char previous = 0; + for (int index = 0; index < simpleToken.length(); index++) { + char current = simpleToken.charAt(index); + if (index > 0 && Character.isUpperCase(current) && Character.isLowerCase(previous)) { + builder.append(' '); + } + if (current == '_' || current == '-') { + builder.append(' '); + previous = current; + continue; + } + builder.append(current); + previous = current; + } + if (builder.length() == 0) { + return simpleToken; + } + builder.setCharAt(0, Character.toUpperCase(builder.charAt(0))); + return builder.toString(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/SoundSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/SoundSemanticProvider.java new file mode 100644 index 00000000..9c4bbcbb --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/SoundSemanticProvider.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; + +public class SoundSemanticProvider extends AbstractCollectionSemanticProvider { + + public SoundSemanticProvider() { + super(SemanticCapability.SOUNDS); + } + + @Override + protected List> loadEntries() { + List> entries = new ArrayList<>(); + RuntimeSemanticSupport.addSoundEntries(entries); + return entries; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StructureLibSemanticProvider.java b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StructureLibSemanticProvider.java new file mode 100644 index 00000000..119c7723 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/StructureLibSemanticProvider.java @@ -0,0 +1,377 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import net.minecraft.tileentity.TileEntity; + +import org.jetbrains.annotations.Nullable; + +import com.gtnewhorizon.structurelib.alignment.IAlignment; +import com.gtnewhorizon.structurelib.alignment.enumerable.ExtendedFacing; +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; +import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; +import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; +import com.hfstudio.guidenh.bridge.semantic.SemanticQueryResult; +import com.hfstudio.guidenh.integration.structurelib.StructureLibImportRequest; +import com.hfstudio.guidenh.integration.structurelib.StructureLibRuntimeFacade; +import com.hfstudio.structurelibexport.StructureLibControllerDiscovery; +import com.hfstudio.structurelibexport.StructureLibControllerSpec; + +public class StructureLibSemanticProvider implements SemanticProvider { + + @Override + public String getCapability() { + return SemanticCapability.STRUCTURELIB; + } + + @Override + public SemanticQueryResult query(SemanticQuery query) { + List> entries = loadEntries(query); + List> filteredEntries = filterEntries(entries, query.getPrefix()); + int cursor = parseCursor(query.getCursor(), filteredEntries.size()); + int limit = query.getLimit() > 0 ? query.getLimit() : filteredEntries.size(); + int end = Math.min(filteredEntries.size(), cursor + limit); + String nextCursor = end < filteredEntries.size() ? Integer.toString(end) : null; + return new SemanticQueryResult( + SemanticCapability.STRUCTURELIB, + computeVersion(entries), + new ArrayList<>(filteredEntries.subList(cursor, end)), + nextCursor); + } + + private List> loadEntries(SemanticQuery query) { + Map filters = query.getFilters(); + String attribute = normalizeValue(filters.get("attribute")); + if ("channel".equals(attribute)) { + return loadChannelEntries(filters); + } + if ("piece".equals(attribute)) { + return loadPieceEntries(filters); + } + if (isOrientationAttribute(attribute)) { + return loadOrientationEntries(filters, attribute); + } + return loadControllerEntries(); + } + + private List> loadControllerEntries() { + List> entries = new ArrayList<>(); + for (StructureLibControllerSpec controller : new StructureLibControllerDiscovery().discoverAllControllers()) { + String id = normalizeValue(controller.getControllerArgument()); + if (id == null) { + continue; + } + String label = normalizeValue(controller.getDisplayName()); + String detail = controller.getBlockId() + ":" + controller.getMeta(); + entries.add(createEntry(id, label, detail)); + } + return normalizeEntries(entries); + } + + private List> loadChannelEntries(Map filters) { + String controller = normalizeValue(filters.get("controller")); + if (controller == null) { + return Collections.emptyList(); + } + try { + StructureLibImportRequest request = new StructureLibImportRequest(controller, null, null, null, null, null); + StructureLibRuntimeFacade.ResolvedController resolvedController = StructureLibRuntimeFacade + .resolveController(request); + StructureLibRuntimeFacade.ControlAnalysis analysis = StructureLibRuntimeFacade + .analyzeControls(request, resolvedController); + int maxTier = analysis.getMaxTotalTier(); + if (maxTier <= 0) { + return Collections.emptyList(); + } + + List> entries = new ArrayList<>(); + String detail = describeTierRange(controller, analysis); + for (int value = 1; value <= maxTier; value++) { + entries.add(createEntry(Integer.toString(value), "StructureLib preview tier", detail)); + } + return normalizeEntries(entries); + } catch (IllegalArgumentException ignored) { + return Collections.emptyList(); + } catch (Throwable ignored) { + return Collections.emptyList(); + } + } + + private List> loadPieceEntries(Map filters) { + String controller = normalizeValue(filters.get("controller")); + if (controller == null) { + return Collections.emptyList(); + } + return Collections.singletonList(createEntry("main", "Main structure", controller + " default constructable")); + } + + private List> loadOrientationEntries(Map filters, String attribute) { + String controller = normalizeValue(filters.get("controller")); + if (controller == null || attribute == null) { + return Collections.emptyList(); + } + try { + StructureLibControllerSpec controllerSpec = StructureLibControllerSpec.parse(controller); + List allowedFacings = findAllowedFacings(controllerSpec); + if (allowedFacings.isEmpty()) { + return Collections.emptyList(); + } + List> entries = switch (attribute) { + case "facing" -> createFacingEntries(controllerSpec, allowedFacings, filters); + case "rotation" -> createRotationEntries(controllerSpec, allowedFacings, filters); + case "flip" -> createFlipEntries(controllerSpec, allowedFacings, filters); + default -> Collections.emptyList(); + }; + return normalizeEntries(entries); + } catch (IllegalArgumentException ignored) { + return Collections.emptyList(); + } catch (Throwable ignored) { + return Collections.emptyList(); + } + } + + private List> createFacingEntries(StructureLibControllerSpec controller, + List allowedFacings, Map filters) { + List> entries = new ArrayList<>(); + for (ExtendedFacing facing : allowedFacings) { + if (matchesOrientationFilters(facing, filters, "facing")) { + String value = facing.getDirection() + .name() + .toLowerCase(Locale.ROOT); + entries.add(createEntry(value, "StructureLib facing", describeOrientation(controller))); + } + } + return entries; + } + + private List> createRotationEntries(StructureLibControllerSpec controller, + List allowedFacings, Map filters) { + List> entries = new ArrayList<>(); + for (ExtendedFacing facing : allowedFacings) { + if (matchesOrientationFilters(facing, filters, "rotation")) { + entries.add( + createEntry( + facing.getRotation() + .getName(), + "StructureLib rotation", + describeOrientation(controller))); + } + } + return entries; + } + + private List> createFlipEntries(StructureLibControllerSpec controller, + List allowedFacings, Map filters) { + List> entries = new ArrayList<>(); + for (ExtendedFacing facing : allowedFacings) { + if (matchesOrientationFilters(facing, filters, "flip")) { + entries.add( + createEntry( + facing.getFlip() + .getName(), + "StructureLib flip", + describeOrientation(controller))); + } + } + return entries; + } + + private List findAllowedFacings(StructureLibControllerSpec controller) { + List allowedFacings = new ArrayList<>(); + StructureLibRuntimeFacade.BuildContext context = new StructureLibRuntimeFacade.BuildContext(); + try { + StructureLibRuntimeFacade.ResolvedController resolvedController = new StructureLibRuntimeFacade.ResolvedController( + controller.getBlockId(), + controller.getBlock(), + controller.getMeta()); + TileEntity tile = StructureLibRuntimeFacade + .placeControllerDirectly(context.getLevel(), context.getWorld(), resolvedController, new ArrayList<>()); + if (tile == null) { + return Collections.emptyList(); + } + IAlignment alignment = StructureLibRuntimeFacade.resolveAlignment(tile); + if (alignment == null) { + return Collections.emptyList(); + } + for (ExtendedFacing facing : ExtendedFacing.VALUES) { + if (alignment.getAlignmentLimits() != null ? alignment.getAlignmentLimits() + .isNewExtendedFacingValid(facing) : alignment.checkedSetExtendedFacing(facing)) { + allowedFacings.add(facing); + } + } + return allowedFacings; + } catch (Throwable ignored) { + return Collections.emptyList(); + } finally { + context.clear(); + } + } + + private boolean matchesOrientationFilters(ExtendedFacing facing, Map filters, + String targetAttribute) { + return matchesOrientationFilterValue( + facing.getDirection() + .name() + .toLowerCase(Locale.ROOT), + normalizeValue(filters.get("facing")), + targetAttribute, + "facing") + && matchesOrientationFilterValue( + facing.getRotation() + .getName(), + normalizeValue(filters.get("rotation")), + targetAttribute, + "rotation") + && matchesOrientationFilterValue( + facing.getFlip() + .getName(), + normalizeValue(filters.get("flip")), + targetAttribute, + "flip"); + } + + private boolean matchesOrientationFilterValue(String actualValue, @Nullable String requestedValue, + String targetAttribute, String attributeName) { + if (targetAttribute.equals(attributeName) || requestedValue == null) { + return true; + } + return actualValue.equalsIgnoreCase(requestedValue); + } + + private String describeOrientation(StructureLibControllerSpec controller) { + return "Allowed orientation for " + controller.getControllerArgument(); + } + + private boolean isOrientationAttribute(@Nullable String attribute) { + return "facing".equals(attribute) || "rotation".equals(attribute) || "flip".equals(attribute); + } + + private String describeTierRange(String controller, StructureLibRuntimeFacade.ControlAnalysis analysis) { + StringBuilder detail = new StringBuilder(); + detail.append("Preview tier for ") + .append(controller) + .append(" (max ") + .append(analysis.getMaxTotalTier()) + .append(')'); + if (analysis.getChannelMaxTierMap() + .isEmpty()) { + return detail.toString(); + } + + detail.append(" | Channel caps: "); + boolean first = true; + for (Map.Entry entry : analysis.getChannelMaxTierMap() + .entrySet()) { + String channelId = normalizeValue(entry.getKey()); + Integer maxValue = entry.getValue(); + if (channelId == null || maxValue == null || maxValue <= 0) { + continue; + } + if (!first) { + detail.append(", "); + } + detail.append(channelId) + .append('=') + .append(maxValue); + first = false; + } + return detail.toString(); + } + + private List> normalizeEntries(List> entries) { + Map> deduplicated = new LinkedHashMap<>(); + for (Map entry : entries) { + if (entry == null) { + continue; + } + String id = normalizeValue(entry.get("id")); + if (id == null) { + continue; + } + Map normalized = new LinkedHashMap<>(); + normalized.put("id", id); + String label = normalizeValue(entry.get("label")); + if (label != null) { + normalized.put("label", label); + } + String detail = normalizeValue(entry.get("detail")); + if (detail != null) { + normalized.put("detail", detail); + } + deduplicated.putIfAbsent(id.toLowerCase(Locale.ROOT), normalized); + } + List> normalizedEntries = new ArrayList<>(deduplicated.values()); + normalizedEntries.sort( + (left, right) -> left.get("id") + .compareToIgnoreCase(right.get("id"))); + return normalizedEntries; + } + + private List> filterEntries(List> entries, String prefix) { + String normalizedPrefix = prefix == null ? "" + : prefix.trim() + .toLowerCase(Locale.ROOT); + if (normalizedPrefix.isEmpty()) { + return entries; + } + + List> filteredEntries = new ArrayList<>(); + for (Map entry : entries) { + if (startsWithIgnoreCase(entry.get("id"), normalizedPrefix) + || startsWithIgnoreCase(entry.get("label"), normalizedPrefix) + || startsWithIgnoreCase(entry.get("detail"), normalizedPrefix)) { + filteredEntries.add(entry); + } + } + return filteredEntries; + } + + private int parseCursor(String cursor, int size) { + if (cursor == null || cursor.isEmpty()) { + return 0; + } + try { + return Math.max(0, Math.min(Integer.parseInt(cursor), size)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private int computeVersion(List> entries) { + int hash = entries.hashCode(); + if (hash == Integer.MIN_VALUE) { + return Integer.MAX_VALUE; + } + return Math.abs(hash) + 1; + } + + private boolean startsWithIgnoreCase(@Nullable String value, String prefix) { + return value != null && value.toLowerCase(Locale.ROOT) + .startsWith(prefix); + } + + private @Nullable String normalizeValue(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private Map createEntry(String id, @Nullable String label, @Nullable String detail) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + if (label != null) { + entry.put("label", label); + } + if (detail != null) { + entry.put("detail", detail); + } + return entry; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java index 414b94b1..677289a9 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java @@ -8,6 +8,12 @@ import java.util.function.Consumer; import com.hfstudio.guidenh.GuideNH; +import com.hfstudio.guidenh.bridge.preview.PreviewQueryFactory; +import com.hfstudio.guidenh.bridge.preview.PreviewResolveQuery; +import com.hfstudio.guidenh.bridge.preview.PreviewResolveResult; +import com.hfstudio.guidenh.bridge.preview.PreviewSearchQuery; +import com.hfstudio.guidenh.bridge.preview.PreviewSearchResult; +import com.hfstudio.guidenh.bridge.preview.RuntimePreviewFacade; import com.hfstudio.guidenh.bridge.protocol.BridgeEnvelope; import com.hfstudio.guidenh.bridge.protocol.BridgeMessageCodec; import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; @@ -26,24 +32,28 @@ public class RuntimeBridgeConnection implements Runnable { private final WebSocketFrameCodec frameCodec; private final BridgeTokenAuthenticator authenticator; private final SemanticProviderRegistry registry; + private final RuntimePreviewFacade previewFacade; private final BridgeProtocolLimits limits; private final Consumer closeCallback; private final AtomicBoolean closed = new AtomicBoolean(); private final BridgeResponseFactory responseFactory = new BridgeResponseFactory(); private final SemanticQueryFactory queryFactory; + private final PreviewQueryFactory previewQueryFactory; private boolean authenticated; public RuntimeBridgeConnection(Socket socket, BridgeMessageCodec messageCodec, - BridgeTokenAuthenticator authenticator, SemanticProviderRegistry registry, BridgeProtocolLimits limits, - Consumer closeCallback) { + BridgeTokenAuthenticator authenticator, SemanticProviderRegistry registry, RuntimePreviewFacade previewFacade, + BridgeProtocolLimits limits, Consumer closeCallback) { this.socket = socket; this.messageCodec = messageCodec; this.frameCodec = new WebSocketFrameCodec(limits.getMaxMessageBytes()); this.authenticator = authenticator; this.registry = registry; + this.previewFacade = previewFacade; this.limits = limits; this.closeCallback = closeCallback; this.queryFactory = new SemanticQueryFactory(limits); + this.previewQueryFactory = new PreviewQueryFactory(limits); } @Override @@ -124,6 +134,15 @@ private BridgeEnvelope dispatch(BridgeEnvelope envelope) { if ("semantic.query".equals(envelope.getMethod())) { return handleSemanticQuery(envelope); } + if ("document.validate".equals(envelope.getMethod())) { + return handleDocumentValidate(envelope); + } + if ("preview.search".equals(envelope.getMethod())) { + return handlePreviewSearch(envelope); + } + if ("preview.resolve".equals(envelope.getMethod())) { + return handlePreviewResolve(envelope); + } if ("capabilities".equals(envelope.getMethod())) { return responseFactory.capabilities(envelope.getId(), registry.getCapabilities()); } @@ -162,6 +181,46 @@ private BridgeEnvelope handleSemanticQuery(BridgeEnvelope envelope) { } } + private BridgeEnvelope handleDocumentValidate(BridgeEnvelope envelope) { + return responseFactory.documentValidate(envelope.getId(), envelope.getMethod()); + } + + private BridgeEnvelope handlePreviewSearch(BridgeEnvelope envelope) { + String capability = previewQueryFactory.readCapability(envelope.getPayload()); + if (capability.isEmpty()) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "invalid_capability", "Capability is required", false); + } + try { + PreviewSearchQuery query = previewQueryFactory.createSearchQuery(envelope.getPayload()); + PreviewSearchResult result = previewFacade.search(query); + return responseFactory.previewSearch(envelope.getId(), result); + } catch (IllegalArgumentException error) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "invalid_preview_query", error.getMessage(), false); + } + } + + private BridgeEnvelope handlePreviewResolve(BridgeEnvelope envelope) { + String capability = previewQueryFactory.readCapability(envelope.getPayload()); + if (capability.isEmpty()) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "invalid_capability", "Capability is required", false); + } + try { + PreviewResolveQuery query = previewQueryFactory.createResolveQuery(envelope.getPayload()); + PreviewResolveResult result = previewFacade.resolve(query); + responseFactory.validatePreviewResultSize(result, limits); + return responseFactory.previewResolve(envelope.getId(), result); + } catch (IllegalArgumentException error) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "invalid_preview_query", error.getMessage(), false); + } catch (IllegalStateException error) { + return responseFactory + .error(envelope.getId(), envelope.getMethod(), "preview_render_failed", error.getMessage(), true); + } + } + private void closeQuietly() { try { socket.close(); diff --git a/tools/runtime-bridge/README.txt b/tools/runtime-bridge/README.txt new file mode 100644 index 00000000..5289c6de --- /dev/null +++ b/tools/runtime-bridge/README.txt @@ -0,0 +1,14 @@ +GuideNH runtime bridge verification tools + +Files in this directory help verify the real GuideNH runtime bridge against a live `runClient25` client. + +Typical flow: +1. Copy `runtime-bridge-config.sample.json` to a local JSON file and adjust values if needed. +2. Run `powershell -ExecutionPolicy Bypass -File .\tools\runtime-bridge\verify-runtime-bridge.ps1`. +3. The script updates `run/client_new/config/guidenh/guidenh.cfg`, launches or reuses `runClient25`, waits for the bridge, runs raw protocol checks, compiles `guide-vsc`, and runs its live runtime verification. + +Notes: +- These scripts are UTF-8 files. +- The JSON config file is intended for local use and does not need to be committed. +- The raw protocol verification checks handshake, capabilities, document validation, and several semantic queries. +- The `guide-vsc` verification reuses its real runtime client and provider logic to validate live completions and hovers against the game bridge. diff --git a/tools/runtime-bridge/query-runtime-bridge.mjs b/tools/runtime-bridge/query-runtime-bridge.mjs new file mode 100644 index 00000000..f41ff9db --- /dev/null +++ b/tools/runtime-bridge/query-runtime-bridge.mjs @@ -0,0 +1,216 @@ +import process from 'process'; +import { WebSocket } from '../../../guide-vsc/node_modules/ws/wrapper.mjs'; + +function parseArgs(argv) { + const options = { + host: '127.0.0.1', + port: 8765, + token: '', + mode: 'smoke', + timeoutMs: 15000 + }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === '--host' && next) { + options.host = next; + index++; + continue; + } + if (arg === '--port' && next) { + options.port = Number(next); + index++; + continue; + } + if (arg === '--token' && next) { + options.token = next; + index++; + continue; + } + if (arg === '--mode' && next) { + options.mode = next; + index++; + continue; + } + if (arg === '--timeoutMs' && next) { + options.timeoutMs = Number(next); + index++; + } + } + return options; +} + +function createEnvelope(id, method, payload) { + return { + id, + type: 'request', + method, + protocol: 1, + payload + }; +} + +function waitForResponse(socket, id, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for response ${id}`)); + }, timeoutMs); + const onMessage = (raw) => { + const message = JSON.parse(String(raw)); + if (message.id !== id) { + return; + } + cleanup(); + resolve(message); + }; + const onError = (error) => { + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + const cleanup = () => { + clearTimeout(timer); + socket.off('message', onMessage); + socket.off('error', onError); + }; + socket.on('message', onMessage); + socket.on('error', onError); + }); +} + +async function send(socket, id, method, payload, timeoutMs) { + socket.send(JSON.stringify(createEnvelope(id, method, payload))); + return waitForResponse(socket, id, timeoutMs); +} + +async function querySemantic(socket, capability, prefix, filters, timeoutMs) { + const id = `semantic.${capability}.${Date.now()}`; + const response = await send(socket, id, 'semantic.query', { + capability, + cursor: '', + limit: 20, + prefix, + filters + }, timeoutMs); + if (response.type !== 'response') { + throw new Error(`Unexpected semantic response type for ${capability}: ${response.type}`); + } + return response.payload; +} + +async function validateDocument(socket, timeoutMs) { + const response = await send(socket, `document.validate.${Date.now()}`, 'document.validate', { + uri: 'file:///runtime-bridge-smoke.md', + languageId: 'markdown', + text: '\n' + }, timeoutMs); + if (response.type !== 'response') { + throw new Error(`Unexpected document validation response type: ${response.type}`); + } + return response.payload; +} + +async function collectBootstrapEntries(socket, capability, timeoutMs) { + const firstPage = await querySemantic(socket, capability, '', {}, timeoutMs); + const entries = Array.isArray(firstPage.entries) ? firstPage.entries.slice() : []; + let nextCursor = typeof firstPage.nextCursor === 'string' && firstPage.nextCursor.length > 0 + ? firstPage.nextCursor + : undefined; + while (nextCursor && entries.length < 60) { + const id = `semantic.${capability}.${nextCursor}.${Date.now()}`; + const response = await send(socket, id, 'semantic.query', { + capability, + cursor: nextCursor, + limit: 20, + prefix: '', + filters: {} + }, timeoutMs); + const payload = response.payload ?? {}; + if (Array.isArray(payload.entries)) { + entries.push(...payload.entries); + } + nextCursor = typeof payload.nextCursor === 'string' && payload.nextCursor.length > 0 + ? payload.nextCursor + : undefined; + } + return { + capability, + version: firstPage.version, + entries, + nextCursor: firstPage.nextCursor ?? null + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (!options.token) { + throw new Error('Missing required --token value.'); + } + const url = `ws://${options.host}:${options.port}`; + const socket = new WebSocket(url, { maxPayload: 262144 }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out connecting to ${url}`)), options.timeoutMs); + socket.once('open', () => { + clearTimeout(timer); + resolve(); + }); + socket.once('error', (error) => { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + + try { + const hello = await send(socket, 'hello', 'hello', { + token: options.token, + clientName: 'guidenh-runtime-bridge-script', + supportedProtocols: [1] + }, options.timeoutMs); + if (hello.type !== 'response') { + throw new Error(`Handshake failed: ${JSON.stringify(hello)}`); + } + + const capabilities = await send(socket, 'capabilities', 'capabilities', {}, options.timeoutMs); + const capabilityList = Array.isArray(capabilities.payload?.capabilities) ? capabilities.payload.capabilities : []; + const results = { + hello: hello.payload, + capabilities: capabilityList + }; + + if (options.mode === 'smoke') { + results.documentValidate = await validateDocument(socket, options.timeoutMs); + results.items = await querySemantic(socket, 'items', 'minecraft:stone', {}, options.timeoutMs); + results.pages = await querySemantic(socket, 'pages', 'index', {}, options.timeoutMs); + results.entities = { + zombie: await querySemantic(socket, 'entities', 'z', {}, options.timeoutMs), + player: await querySemantic(socket, 'entities', 'player', {}, options.timeoutMs), + upperPlayer: await querySemantic(socket, 'entities', 'Player', {}, options.timeoutMs) + }; + results.structurelib = await querySemantic( + socket, + 'structurelib', + 'gregtech', + {}, + options.timeoutMs + ); + } + + if (options.mode === 'bootstrap') { + results.bootstrap = { + items: await collectBootstrapEntries(socket, 'items', options.timeoutMs), + pages: await collectBootstrapEntries(socket, 'pages', options.timeoutMs), + entities: await collectBootstrapEntries(socket, 'entities', options.timeoutMs), + structurelib: await collectBootstrapEntries(socket, 'structurelib', options.timeoutMs) + }; + } + + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); + } finally { + socket.close(); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/tools/runtime-bridge/runtime-bridge-config.sample.json b/tools/runtime-bridge/runtime-bridge-config.sample.json new file mode 100644 index 00000000..deaa76a5 --- /dev/null +++ b/tools/runtime-bridge/runtime-bridge-config.sample.json @@ -0,0 +1,12 @@ +{ + "host": "127.0.0.1", + "port": 8765, + "token": "guide-runtime-bridge-token", + "guideNhRoot": "E:\\Github\\GuideNH", + "guideVscRoot": "E:\\Github\\guide-vsc", + "guideVscAllowRemote": false, + "guideVscTimeoutMs": 30000, + "runTask": "runClient25", + "launchClient": false, + "startupTimeoutSeconds": 240 +} diff --git a/tools/runtime-bridge/verify-runtime-bridge.ps1 b/tools/runtime-bridge/verify-runtime-bridge.ps1 new file mode 100644 index 00000000..2a1aa76c --- /dev/null +++ b/tools/runtime-bridge/verify-runtime-bridge.ps1 @@ -0,0 +1,192 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = ".\tools\runtime-bridge\runtime-bridge-config.sample.json", + [switch]$LaunchClient, + [switch]$SkipGuideVsc, + [switch]$WhatIf +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$PSDefaultParameterValues['*:Encoding'] = 'utf8' + +function Read-JsonFile { + param([string]$Path) + return Get-Content -LiteralPath $Path -Encoding utf8 -Raw | ConvertFrom-Json +} + +function Ensure-RuntimeBridgeConfigBlock { + param( + [string]$ConfigFile, + [string]$BridgeHost, + [int]$Port, + [string]$Token + ) + + $text = Get-Content -LiteralPath $ConfigFile -Encoding utf8 -Raw + $block = @" + + runtimebridge { + B:enabled=true + S:host=$BridgeHost + I:port=$Port + S:token=$Token + I:maxConnections=2 + I:maxDeltaEntries=200 + I:maxMessageBytes=262144 + I:maxPageSize=200 + I:maxSubscriptions=16 + } +"@ + + if ($text -match '(?ms)^\s*runtimebridge\s*\{.*?^\s*\}') { + $replacement = @" + runtimebridge { + B:enabled=true + S:host=$BridgeHost + I:port=$Port + S:token=$Token + I:maxConnections=2 + I:maxDeltaEntries=200 + I:maxMessageBytes=262144 + I:maxPageSize=200 + I:maxSubscriptions=16 + } +"@ + $updated = [System.Text.RegularExpressions.Regex]::Replace( + $text, + '(?ms)^\s*runtimebridge\s*\{.*?^\s*\}', + $replacement.TrimEnd() + ) + } else { + $updated = $text.TrimEnd() + "`r`n" + $block.TrimEnd() + "`r`n" + } + + if (-not $WhatIf) { + Set-Content -LiteralPath $ConfigFile -Encoding utf8 -Value $updated + } +} + +function Wait-ForBridge { + param( + [string]$NodeExe, + [string]$QueryScript, + [string]$BridgeHost, + [int]$Port, + [string]$Token, + [int]$TimeoutSeconds + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + try { + & $NodeExe $QueryScript --host $BridgeHost --port $Port --token $Token --mode smoke --timeoutMs 3000 | Out-Null + return + } catch { + Start-Sleep -Seconds 3 + } + } + + throw "Timed out waiting for the GuideNH runtime bridge at ws://$BridgeHost`:$Port." +} + +function Invoke-GuideVscRuntimeVerification { + param( + [string]$GuideVscRoot, + [string]$NodeExe, + [string]$BridgeHost, + [int]$Port, + [string]$Token, + [bool]$AllowRemote, + [int]$TimeoutMs + ) + + $tscExe = Join-Path $GuideVscRoot "node_modules\.bin\tsc.cmd" + $verifyScript = Join-Path $GuideVscRoot "out\scripts\verifyRuntimeBridgeLive.js" + + if (-not (Test-Path -LiteralPath $tscExe)) { + throw "Missing TypeScript compiler at $tscExe." + } + + Push-Location $GuideVscRoot + try { + & $tscExe -p "." + if ($LASTEXITCODE -ne 0) { + throw "guide-vsc TypeScript compile failed with exit code $LASTEXITCODE." + } + + $arguments = @( + $verifyScript, + "--host", $BridgeHost, + "--port", "$Port", + "--token", $Token, + "--timeoutMs", "$TimeoutMs" + ) + if ($AllowRemote) { + $arguments += "--allowRemote" + } + + & $NodeExe @arguments + if ($LASTEXITCODE -ne 0) { + throw "guide-vsc live runtime verification failed with exit code $LASTEXITCODE." + } + } finally { + Pop-Location + } +} + +$config = Read-JsonFile -Path $ConfigPath +$guideNhRoot = [System.IO.Path]::GetFullPath($config.guideNhRoot) +$guideVscRoot = [System.IO.Path]::GetFullPath($config.guideVscRoot) +$runTask = if ($config.runTask) { [string]$config.runTask } else { "runClient25" } +$startupTimeoutSeconds = if ($config.startupTimeoutSeconds) { [int]$config.startupTimeoutSeconds } else { 240 } +$bridgeHost = [string]$config.host +$port = [int]$config.port +$token = [string]$config.token +$shouldLaunchClient = $LaunchClient.IsPresent -or [bool]$config.launchClient +$shouldVerifyGuideVsc = -not $SkipGuideVsc.IsPresent +$guideVscAllowRemote = if ($null -ne $config.guideVscAllowRemote) { [bool]$config.guideVscAllowRemote } else { $false } +$guideVscTimeoutMs = if ($config.guideVscTimeoutMs) { [int]$config.guideVscTimeoutMs } else { 30000 } + +$configFile = Join-Path $guideNhRoot "run\client_new\config\guidenh\guidenh.cfg" +$nodeExe = (Get-Command node).Source +$queryScript = Join-Path $guideNhRoot "tools\runtime-bridge\query-runtime-bridge.mjs" + +Ensure-RuntimeBridgeConfigBlock -ConfigFile $configFile -BridgeHost $bridgeHost -Port $port -Token $token + +if ($WhatIf) { + Write-Host "Would update $configFile for runtime bridge host=$bridgeHost port=$port." + if ($shouldLaunchClient) { + Write-Host "Would launch gradle task $runTask." + } + Write-Host "Would query bridge with $queryScript." + if ($shouldVerifyGuideVsc) { + Write-Host "Would compile guide-vsc and run its live runtime verification." + } + return +} + +if ($shouldLaunchClient) { + Start-Process -FilePath "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -ArgumentList "-Command", "& .\gradlew.bat $runTask" ` + -WorkingDirectory $guideNhRoot ` + -WindowStyle Hidden | Out-Null +} + +Wait-ForBridge -NodeExe $nodeExe -QueryScript $queryScript -BridgeHost $bridgeHost -Port $port -Token $token -TimeoutSeconds $startupTimeoutSeconds + +& $nodeExe $queryScript --host $bridgeHost --port $port --token $token --mode smoke --timeoutMs 15000 +if ($LASTEXITCODE -ne 0) { + throw "Raw runtime bridge smoke verification failed with exit code $LASTEXITCODE." +} + +if ($shouldVerifyGuideVsc) { + Invoke-GuideVscRuntimeVerification ` + -GuideVscRoot $guideVscRoot ` + -NodeExe $nodeExe ` + -BridgeHost $bridgeHost ` + -Port $port ` + -Token $token ` + -AllowRemote $guideVscAllowRemote ` + -TimeoutMs $guideVscTimeoutMs +} From f59c4eca3ce98b7ca2ed0aad66a6596c07a43322 Mon Sep 17 00:00:00 2001 From: ABKQPO <93412322+ABKQPO@users.noreply.github.com> Date: Wed, 27 May 2026 23:14:55 +0800 Subject: [PATCH 5/5] Update ItemPreviewSearchService.java --- .../preview/ItemPreviewSearchService.java | 252 ++++++++++++++++-- 1 file changed, 224 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java index ef527504..89b71abe 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java @@ -18,6 +18,7 @@ public PreviewSearchResult search(PreviewSearchQuery query) { List> semanticEntries = new ArrayList<>(); RuntimeSemanticSupport.addItemEntries(semanticEntries); RuntimeSemanticSupport.addBlockOnlyEntries(semanticEntries); + Map familySizes = buildFamilySizes(semanticEntries); String normalizedPrefix = normalize(query.getPrefix()); List rankedEntries = new ArrayList<>(); @@ -32,14 +33,26 @@ public PreviewSearchResult search(PreviewSearchQuery query) { if (score == Integer.MAX_VALUE) { continue; } + String path = extractRawPath(id); + String compactPrefix = compact(normalizedPrefix); rankedEntries.add( new RankedPreviewSearchEntry( score, + computeStructuredMatchSpecificity(path, compactPrefix, score), + resolveFamilySize(familySizes, id), new PreviewSearchEntry(id, label, detail, buildPreviewKey(id), describeMatchKind(score)))); } rankedEntries.sort( Comparator.comparingInt(RankedPreviewSearchEntry::getScore) + .thenComparingInt( + entry -> prefersLargerFamilyForScore(entry.getScore()) ? -entry.getFamilySize() : Integer.MAX_VALUE) + .thenComparingInt( + entry -> prefersHigherStructuredSpecificityForScore(entry.getScore()) + ? -entry.getStructuredSpecificity() + : Integer.MAX_VALUE) + .thenComparingInt( + entry -> prefersShorterPathForScore(entry.getScore()) ? entry.getPathLength() : Integer.MAX_VALUE) .thenComparing( entry -> entry.getEntry() .getId(), @@ -67,12 +80,13 @@ private int scoreEntry(String id, String label, String detail, String prefix) { String namespace = normalizedId.contains(":") ? normalizedId.substring(0, normalizedId.indexOf(':')) : normalizedId; String path = normalizedId.contains(":") ? normalizedId.substring(normalizedId.indexOf(':') + 1) : normalizedId; + String rawPath = extractRawPath(id); String compactId = compact(normalizedId); String compactLabel = compact(normalizedLabel); String compactDetail = compact(normalizedDetail); String compactPath = compact(path); String compactPrefix = compact(prefix); - String tokenInitials = createTokenInitials(path); + String tokenInitials = createTokenInitials(rawPath); String labelInitials = createTokenInitials(normalizedLabel); boolean shortPrefix = isShortPrefix(prefix); @@ -103,7 +117,7 @@ private int scoreEntry(String id, String label, String detail, String prefix) { if (normalizedDetail.startsWith(prefix)) { return 8; } - if (!compactPrefix.isEmpty() && compactPrefix.length() >= 2 && labelInitials.startsWith(compactPrefix)) { + if (matchesStructuredPathAbbreviation(rawPath, compactPrefix)) { return 9; } if (!compactPrefix.isEmpty() && compactPrefix.length() >= 2 && tokenInitials.startsWith(compactPrefix)) { @@ -112,24 +126,27 @@ private int scoreEntry(String id, String label, String detail, String prefix) { if (!compactPrefix.isEmpty() && compactId.startsWith(compactPrefix)) { return 11; } - if (!compactPrefix.isEmpty() && compactLabel.startsWith(compactPrefix)) { + if (!compactPrefix.isEmpty() && compactPath.startsWith(compactPrefix)) { return 12; } - if (!compactPrefix.isEmpty() && compactPath.startsWith(compactPrefix)) { + if (!compactPrefix.isEmpty() && compactPrefix.length() >= 2 && labelInitials.startsWith(compactPrefix)) { return 13; } - if (!compactPrefix.isEmpty() && compactDetail.startsWith(compactPrefix)) { + if (!compactPrefix.isEmpty() && compactLabel.startsWith(compactPrefix)) { return 14; } - if (normalizedId.contains(prefix)) { + if (!compactPrefix.isEmpty() && compactDetail.startsWith(compactPrefix)) { return 15; } - if (normalizedLabel.contains(prefix)) { + if (normalizedId.contains(prefix)) { return 16; } - if (normalizedDetail.contains(prefix)) { + if (normalizedLabel.contains(prefix)) { return 17; } + if (normalizedDetail.contains(prefix)) { + return 18; + } return Integer.MAX_VALUE; } @@ -158,22 +175,24 @@ private String describeMatchKind(int score) { case 8: return "detail-prefix"; case 9: - return "label-acronym"; + return "path-structured"; case 10: return "path-acronym"; case 11: return "id-compact"; case 12: - return "label-compact"; - case 13: return "path-compact"; + case 13: + return "label-acronym"; case 14: - return "detail-compact"; + return "label-compact"; case 15: - return "id-contains"; + return "detail-compact"; case 16: - return "label-contains"; + return "id-contains"; case 17: + return "label-contains"; + case 18: return "detail-contains"; default: return "runtime"; @@ -230,26 +249,98 @@ private boolean matchesTokenPrefix(String value, String prefix) { } private String createTokenInitials(String value) { - if (value == null || value.isEmpty()) { + List tokens = splitSearchTokens(value); + if (tokens.isEmpty()) { return ""; } + StringBuilder builder = new StringBuilder(tokens.size()); + for (String token : tokens) { + builder.append(token.charAt(0)); + } + return builder.toString(); + } + + private boolean matchesStructuredPathAbbreviation(String path, String compactPrefix) { + if (compactPrefix == null || compactPrefix.length() < 2 || path == null || path.isEmpty()) { + return false; + } + List nonEmptyTokens = splitSearchTokens(path); + if (nonEmptyTokens.size() < 2) { + return false; + } + String firstToken = nonEmptyTokens.get(0); + if (!compactPrefix.startsWith(firstToken) || compactPrefix.length() <= firstToken.length()) { + return false; + } + int queryIndex = firstToken.length(); + for (int tokenIndex = 1; tokenIndex < nonEmptyTokens.size() + && queryIndex < compactPrefix.length(); tokenIndex++) { + String token = nonEmptyTokens.get(tokenIndex); + if (!token.startsWith(String.valueOf(compactPrefix.charAt(queryIndex)))) { + return false; + } + queryIndex++; + } + return queryIndex == compactPrefix.length(); + } + + private String extractRawPath(String id) { + if (id == null) { + return ""; + } + int separator = id.indexOf(':'); + return separator >= 0 ? id.substring(separator + 1) : id; + } + + private List splitSearchTokens(String value) { + List tokens = new ArrayList<>(); + if (value == null || value.isEmpty()) { + return tokens; + } StringBuilder builder = new StringBuilder(value.length()); - int length = value.length(); - int tokenStart = -1; - for (int index = 0; index <= length; index++) { - char current = index < length ? value.charAt(index) : 0; - boolean tokenCharacter = index < length - && ((current >= 'a' && current <= 'z') || (current >= '0' && current <= '9')); - if (tokenCharacter && tokenStart < 0) { - tokenStart = index; - builder.append(current); + char previous = 0; + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (!Character.isLetterOrDigit(current)) { + flushToken(tokens, builder); + previous = 0; continue; } - if (!tokenCharacter) { - tokenStart = -1; + if (shouldSplitToken( + previous, + current, + index + 1 < value.length() ? value.charAt(index + 1) : 0, + builder.length())) { + flushToken(tokens, builder); } + builder.append(Character.toLowerCase(current)); + previous = current; } - return builder.toString(); + flushToken(tokens, builder); + return tokens; + } + + private boolean shouldSplitToken(char previous, char current, char next, int currentLength) { + if (currentLength <= 0 || previous == 0) { + return false; + } + if (Character.isDigit(previous) != Character.isDigit(current)) { + return true; + } + if (Character.isLowerCase(previous) && Character.isUpperCase(current)) { + return true; + } + return Character.isUpperCase(previous) && Character.isUpperCase(current) + && next != 0 + && Character.isLowerCase(next); + } + + private void flushToken(List tokens, StringBuilder builder) { + if (builder.length() <= 0) { + return; + } + tokens.add(builder.toString()); + builder.setLength(0); } private String trimToNull(String value) { @@ -260,14 +351,107 @@ private String trimToNull(String value) { return trimmed.isEmpty() ? null : trimmed; } + private boolean prefersShorterPathForScore(int score) { + return score == 9 || score == 10 || score == 11 || score == 12; + } + + private boolean prefersHigherStructuredSpecificityForScore(int score) { + return score == 9; + } + + private boolean prefersLargerFamilyForScore(int score) { + return score == 9; + } + + private int computeStructuredMatchSpecificity(String path, String compactPrefix, int score) { + if (score != 9 || compactPrefix == null || compactPrefix.length() < 2 || path == null || path.isEmpty()) { + return 0; + } + List nonEmptyTokens = splitSearchTokens(path); + if (nonEmptyTokens.size() < 2) { + return 0; + } + String firstToken = nonEmptyTokens.get(0); + if (!compactPrefix.startsWith(firstToken) || compactPrefix.length() <= firstToken.length()) { + return 0; + } + int queryIndex = firstToken.length(); + int specificity = firstToken.length(); + for (int tokenIndex = 1; tokenIndex < nonEmptyTokens.size() + && queryIndex < compactPrefix.length(); tokenIndex++) { + String token = nonEmptyTokens.get(tokenIndex); + if (!token.startsWith(String.valueOf(compactPrefix.charAt(queryIndex)))) { + return 0; + } + specificity += token.length(); + queryIndex++; + } + return queryIndex == compactPrefix.length() ? specificity : 0; + } + + private Map buildFamilySizes(List> semanticEntries) { + Map familySizes = new java.util.HashMap<>(); + for (Map semanticEntry : semanticEntries) { + String id = trimToNull(semanticEntry.get("id")); + if (id == null) { + continue; + } + String familyKey = toFamilyKey(id); + familySizes.put(familyKey, familySizes.getOrDefault(familyKey, Integer.valueOf(0)) + 1); + } + return familySizes; + } + + private int resolveFamilySize(Map familySizes, String id) { + Integer value = familySizes.get(toFamilyKey(id)); + return value == null ? 0 : value.intValue(); + } + + private String toFamilyKey(String id) { + String rawPath = extractRawPath(id); + int metaSeparator = rawPath.lastIndexOf(':'); + if (metaSeparator >= 0) { + String trailing = rawPath.substring(metaSeparator + 1); + if (!trailing.isEmpty() && isDigitsOnly(trailing)) { + rawPath = rawPath.substring(0, metaSeparator); + } + } + String namespace = ""; + int namespaceSeparator = id.indexOf(':'); + if (namespaceSeparator >= 0) { + namespace = id.substring(0, namespaceSeparator + 1) + .toLowerCase(Locale.ROOT); + } + return namespace + rawPath.toLowerCase(Locale.ROOT); + } + + private boolean isDigitsOnly(String value) { + for (int index = 0; index < value.length(); index++) { + if (!Character.isDigit(value.charAt(index))) { + return false; + } + } + return !value.isEmpty(); + } + public static class RankedPreviewSearchEntry { private final int score; + private final int structuredSpecificity; + private final int familySize; private final PreviewSearchEntry entry; + private final int pathLength; - public RankedPreviewSearchEntry(int score, PreviewSearchEntry entry) { + public RankedPreviewSearchEntry(int score, int structuredSpecificity, int familySize, + PreviewSearchEntry entry) { this.score = score; + this.structuredSpecificity = structuredSpecificity; + this.familySize = familySize; this.entry = entry; + String id = entry.getId(); + int separator = id == null ? -1 : id.indexOf(':'); + String path = separator >= 0 ? id.substring(separator + 1) : id; + this.pathLength = path == null ? Integer.MAX_VALUE : path.length(); } public int getScore() { @@ -277,5 +461,17 @@ public int getScore() { public PreviewSearchEntry getEntry() { return entry; } + + public int getStructuredSpecificity() { + return structuredSpecificity; + } + + public int getFamilySize() { + return familySize; + } + + public int getPathLength() { + return pathLength; + } } }