diff --git a/src/main/java/com/hfstudio/guidenh/ClientProxy.java b/src/main/java/com/hfstudio/guidenh/ClientProxy.java index f35fe453..2dd945a4 100644 --- a/src/main/java/com/hfstudio/guidenh/ClientProxy.java +++ b/src/main/java/com/hfstudio/guidenh/ClientProxy.java @@ -5,6 +5,8 @@ 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; @@ -12,6 +14,7 @@ import com.hfstudio.guidenh.client.hotkey.OpenGuideHomeHotkey; 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; @@ -44,6 +47,8 @@ public class ClientProxy extends CommonProxy { + private final GuideNhRuntimeBridge runtimeBridge = new GuideNhRuntimeBridge(); + @Override public void preInit(FMLPreInitializationEvent event) { super.preInit(event); @@ -79,6 +84,24 @@ 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, + 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 @@ -96,6 +119,8 @@ 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(); GuideScreenMemory.clear(); GuideScreenHomeHistory.shared() 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..274e5b8b --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java @@ -0,0 +1,46 @@ +package com.hfstudio.guidenh.bridge; + +import com.hfstudio.guidenh.GuideNH; + +public class GuideNhRuntimeBridge { + + private GuideNhRuntimeBridgeServer server; + + 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; + } + } + + 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..6f04379d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java @@ -0,0 +1,167 @@ +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.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; +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 RuntimePreviewFacade previewFacade; + 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); + ItemPreviewCache previewCache = new ItemPreviewCache(256); + ItemPreviewSearchService previewSearchService = new ItemPreviewSearchService(); + ItemPreviewService previewService = new ItemPreviewService(previewCache, limits); + this.previewFacade = new RuntimePreviewFacade(previewSearchService, previewService); + } + + public void start() { + if (!settings.canStart() || !running.compareAndSet(false, true)) { + 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); + 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; + } + GuideNH.LOG.info("GuideNH runtime bridge server stopping"); + 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(); + 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; + } + RuntimeBridgeConnection connection = new RuntimeBridgeConnection( + socket, + messageCodec, + authenticator, + registry, + previewFacade, + limits, + 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()) { + GuideNH.LOG.warn("GuideNH runtime bridge accept loop failed", e); + } + } + } + } + + private void closeServerSocket() { + try { + if (serverSocket != null) { + serverSocket.close(); + } + } 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; + + @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/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..89b71abe --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewSearchService.java @@ -0,0 +1,477 @@ +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); + Map familySizes = buildFamilySizes(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; + } + 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(), + 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 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(rawPath); + 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 (matchesStructuredPathAbbreviation(rawPath, 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() && compactPath.startsWith(compactPrefix)) { + return 12; + } + if (!compactPrefix.isEmpty() && compactPrefix.length() >= 2 && labelInitials.startsWith(compactPrefix)) { + return 13; + } + if (!compactPrefix.isEmpty() && compactLabel.startsWith(compactPrefix)) { + return 14; + } + if (!compactPrefix.isEmpty() && compactDetail.startsWith(compactPrefix)) { + return 15; + } + if (normalizedId.contains(prefix)) { + return 16; + } + if (normalizedLabel.contains(prefix)) { + return 17; + } + if (normalizedDetail.contains(prefix)) { + return 18; + } + 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 "path-structured"; + case 10: + return "path-acronym"; + case 11: + return "id-compact"; + case 12: + return "path-compact"; + case 13: + return "label-acronym"; + case 14: + return "label-compact"; + case 15: + return "detail-compact"; + case 16: + return "id-contains"; + case 17: + return "label-contains"; + case 18: + 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) { + 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()); + 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 (shouldSplitToken( + previous, + current, + index + 1 < value.length() ? value.charAt(index + 1) : 0, + builder.length())) { + flushToken(tokens, builder); + } + builder.append(Character.toLowerCase(current)); + previous = current; + } + 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) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + 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, 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() { + return score; + } + + public PreviewSearchEntry getEntry() { + return entry; + } + + public int getStructuredSpecificity() { + return structuredSpecificity; + } + + public int getFamilySize() { + return familySize; + } + + public int getPathLength() { + return pathLength; + } + } +} 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/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..e4d5b774 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeProtocolLimits.java @@ -0,0 +1,63 @@ +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; + 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) { + this.maxMessageBytes = maxMessageBytes; + this.maxPageSize = maxPageSize; + 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() { + return maxMessageBytes; + } + + public int getMaxPageSize() { + return maxPageSize; + } + + public int getMaxSubscriptions() { + return maxSubscriptions; + } + + public int getMaxConnections() { + return maxConnections; + } + + 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 new file mode 100644 index 00000000..71e1f135 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/protocol/BridgeResponseFactory.java @@ -0,0 +1,74 @@ +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 { + + 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 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/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..f5b6ae2b --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/SemanticCapability.java @@ -0,0 +1,19 @@ +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 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"; + 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/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/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/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 new file mode 100644 index 00000000..b583accd --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/semantic/providers/RuntimeSemanticProviders.java @@ -0,0 +1,26 @@ +package com.hfstudio.guidenh.bridge.semantic.providers; + +import com.hfstudio.guidenh.bridge.semantic.SemanticCapability; +import com.hfstudio.guidenh.bridge.semantic.SemanticProvider; +import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; + +public class RuntimeSemanticProviders { + + private RuntimeSemanticProviders() {} + + 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/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/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 new file mode 100644 index 00000000..677289a9 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java @@ -0,0 +1,236 @@ +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.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; +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 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, 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 + 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 e) { + GuideNH.LOG.warn("GuideNH runtime bridge connection I/O failed for {}", describeRemote(), e); + closeQuietly(); + } finally { + close(); + } + } + + 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()); + 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 ("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()); + } + 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)) { + 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); + } + + 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 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(); + } catch (IOException ignored) {} + } + + private String describeRemote() { + if (socket.getRemoteSocketAddress() == null) { + return "unknown"; + } + return String.valueOf(socket.getRemoteSocketAddress()); + } +} 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 7b4ddd51..e232d584 100644 --- a/src/main/java/com/hfstudio/guidenh/config/ModConfig.java +++ b/src/main/java/com/hfstudio/guidenh/config/ModConfig.java @@ -23,6 +23,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 { @@ -211,6 +212,47 @@ public static int clampPositiveHomeLimit(int value, int fallback) { return value >= 1 ? value : fallback; } + @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); 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 +}