From 7b7667f2c56d50b92ce730c82987441dc7354afa Mon Sep 17 00:00:00 2001 From: bigguy345 Date: Sun, 4 Jan 2026 17:35:25 +0200 Subject: [PATCH] Autocomplete suggestions UsageTracker implementation --- .../script/autocomplete/AutocompleteItem.java | 8 + .../autocomplete/AutocompleteManager.java | 38 +++ .../autocomplete/JSAutocompleteProvider.java | 45 ++- .../JavaAutocompleteProvider.java | 37 ++- .../script/autocomplete/UsageTracker.java | 270 ++++++++++++++++++ 5 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java index 768abf2a3..3aee42085 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java @@ -373,9 +373,17 @@ public class AutocompleteItem implements Comparable { return calculateMatchScore(query, false); } + /** + * Add a boost to the match score (e.g., from usage tracking). + */ + public void addScoreBoost(int boost) { + this.matchScore += boost; + } + // ==================== GETTERS ==================== public String getName() { return name; } + public String getSearchName() { return searchName; } public String getInsertText() { return insertText; } public Kind getKind() { return kind; } public String getTypeLabel() { return typeLabel; } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java index ace3cab81..360d0633c 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java @@ -3,6 +3,7 @@ package noppes.npcs.client.gui.util.script.autocomplete; import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; import noppes.npcs.client.gui.util.script.interpreter.ScriptTextContainer; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; import org.lwjgl.input.Mouse; import java.util.ArrayList; @@ -53,6 +54,12 @@ public class AutocompleteManager { /** Whether this was an explicit trigger (Ctrl+Space) */ private boolean explicitTrigger = false; + /** The full class name of the current receiver type (for member access tracking) */ + private String currentReceiverFullName = null; + + /** Whether current context is member access */ + private boolean currentIsMemberAccess = false; + /** Callback for text insertion */ private InsertCallback insertCallback; @@ -360,6 +367,18 @@ public class AutocompleteManager { int prefixStart, boolean isMemberAccess, String receiverExpr) { if (insertCallback == null || document == null) return; + // Track context for usage recording when item is selected + currentIsMemberAccess = isMemberAccess; + currentReceiverFullName = null; + + // Resolve receiver type if member access + if (isMemberAccess && receiverExpr != null) { + TypeInfo receiverType = document.resolveExpressionType(receiverExpr, prefixStart); + if (receiverType != null && receiverType.isResolved()) { + currentReceiverFullName = receiverType.getFullName(); + } + } + // Build context int lineNumber = getLineNumber(text, cursorPosition); String currentLine = getCurrentLine(text, cursorPosition); @@ -491,6 +510,9 @@ public class AutocompleteManager { return; } + // Record usage for learning + recordUsage(item); + String insertText = item.getInsertText(); // Smart tab completion: replace till next separator @@ -528,6 +550,22 @@ public class AutocompleteManager { active = false; } + /** + * Record that the user selected an autocomplete item for usage tracking. + */ + private void recordUsage(AutocompleteItem item) { + if (document == null) return; + + UsageTracker tracker = document.isJavaScript() ? + UsageTracker.getJSInstance() : UsageTracker.getJavaInstance(); + + // For member access, use the resolved receiver type + // For standalone items (types, keywords, variables), owner is null + String owner = currentIsMemberAccess ? currentReceiverFullName : null; + + tracker.recordUsage(item, owner); + } + // ==================== DISMISS ==================== /** diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java index fc536102c..3fb105d7a 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java @@ -44,6 +44,15 @@ public class JSAutocompleteProvider implements AutocompleteProvider { public List getSuggestions(Context context) { List items = new ArrayList<>(); + // Resolve owner type for usage tracking + String ownerFullName = null; + if (context.isMemberAccess && context.receiverExpression != null) { + String receiverType = resolveReceiverType(context.receiverExpression); + if (receiverType != null) { + ownerFullName = receiverType; + } + } + if (context.isMemberAccess) { // Member access: resolve type of receiver and get its members addMemberSuggestions(context, items); @@ -52,8 +61,8 @@ public class JSAutocompleteProvider implements AutocompleteProvider { addScopeSuggestions(context, items); } - // Filter and score by prefix - filterAndScore(items, context.prefix); + // Filter and score by prefix, then apply usage boosts + filterAndScore(items, context.prefix, context.isMemberAccess, ownerFullName); // Sort by score Collections.sort(items); @@ -186,6 +195,13 @@ public class JSAutocompleteProvider implements AutocompleteProvider { * Infer the type of an expression. */ private String inferExpressionType(String expr) { + return resolveReceiverType(expr); + } + + /** + * Resolve the type of a receiver expression for member access. + */ + private String resolveReceiverType(String expr) { if (expr == null || expr.isEmpty()) { return null; } @@ -266,23 +282,42 @@ public class JSAutocompleteProvider implements AutocompleteProvider { } /** - * Filter items by prefix and calculate match scores. + * Filter items by prefix, calculate match scores, and apply usage boosts. */ - private void filterAndScore(List items, String prefix) { + private void filterAndScore(List items, String prefix, + boolean isMemberAccess, String ownerFullName) { + UsageTracker tracker = UsageTracker.getJSInstance(); + if (prefix == null || prefix.isEmpty()) { for (AutocompleteItem item : items) { item.calculateMatchScore(""); + applyUsageBoost(item, tracker, ownerFullName); } return; } + // For non-member access (first word), require strict prefix matching + // For member access (after dot), allow fuzzy/contains matching + boolean requirePrefix = !isMemberAccess; + Iterator iter = items.iterator(); while (iter.hasNext()) { AutocompleteItem item = iter.next(); - int score = item.calculateMatchScore(prefix); + int score = item.calculateMatchScore(prefix, requirePrefix); if (score < 0) { iter.remove(); + } else { + applyUsageBoost(item, tracker, ownerFullName); } } } + + /** + * Apply usage-based score boost to an item. + */ + private void applyUsageBoost(AutocompleteItem item, UsageTracker tracker, String ownerFullName) { + int usageCount = tracker.getUsageCount(item, ownerFullName); + int boost = UsageTracker.calculateUsageBoost(usageCount); + item.addScoreBoost(boost); + } } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java index d1fc2c017..56456d096 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java @@ -37,6 +37,16 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { return items; } + // Resolve owner type for usage tracking + String ownerFullName = null; + if (context.isMemberAccess && context.receiverExpression != null) { + TypeInfo receiverType = document.resolveExpressionType( + context.receiverExpression, context.prefixStart); + if (receiverType != null && receiverType.isResolved()) { + ownerFullName = receiverType.getFullName(); + } + } + if (context.isMemberAccess) { // Member access: resolve type of receiver and get its members addMemberSuggestions(context, items); @@ -45,8 +55,8 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { addScopeSuggestions(context, items); } - // Filter and score by prefix - filterAndScore(items, context.prefix, context.isMemberAccess); + // Filter and score by prefix, then apply usage boosts + filterAndScore(items, context.prefix, context.isMemberAccess, ownerFullName); // Sort by score Collections.sort(items); @@ -332,13 +342,17 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { } /** - * Filter items by prefix and calculate match scores. + * Filter items by prefix, calculate match scores, and apply usage boosts. */ - private void filterAndScore(List items, String prefix, boolean isMemberAccess) { + private void filterAndScore(List items, String prefix, + boolean isMemberAccess, String ownerFullName) { + UsageTracker tracker = UsageTracker.getJavaInstance(); + if (prefix == null || prefix.isEmpty()) { - // No filtering needed, all items get a base score + // No filtering needed, all items get a base score + usage boost for (AutocompleteItem item : items) { item.calculateMatchScore("", false); + applyUsageBoost(item, tracker, ownerFullName); } return; } @@ -347,17 +361,28 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { // For member access (after dot), allow fuzzy/contains matching boolean requirePrefix = !isMemberAccess; - // Filter and score + // Filter, score, and apply usage boosts Iterator iter = items.iterator(); while (iter.hasNext()) { AutocompleteItem item = iter.next(); int score = item.calculateMatchScore(prefix, requirePrefix); if (score < 0) { iter.remove(); + } else { + applyUsageBoost(item, tracker, ownerFullName); } } } + /** + * Apply usage-based score boost to an item. + */ + private void applyUsageBoost(AutocompleteItem item, UsageTracker tracker, String ownerFullName) { + int usageCount = tracker.getUsageCount(item, ownerFullName); + int boost = UsageTracker.calculateUsageBoost(usageCount); + item.addScoreBoost(boost); + } + /** * Builder class for AutocompleteItem to handle cases without source data. */ diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java new file mode 100644 index 000000000..6ee55bd31 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java @@ -0,0 +1,270 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import noppes.npcs.CustomNpcs; + +import java.io.*; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Tracks user autocomplete selections to improve suggestion ranking. + * Usage counts are persisted to JSON and used to boost frequently selected items. + * + * Key format: "ownerFullName|memberName|KIND" + * - ownerFullName: Full class name for members, empty for standalone items + * - memberName: The item name (method, field, type, etc.) + * - KIND: The item kind (METHOD, FIELD, CLASS, ENUM, etc.) + * + * Examples: + * - "net.minecraft.client.Minecraft|getMinecraft|METHOD" + * - "net.minecraft.entity.player.EntityPlayer|inventory|FIELD" + * - "|EntityPlayer|CLASS" (standalone type suggestion) + * - "|if|KEYWORD" + */ +public class UsageTracker { + + private static final String JAVA_FILE = "java_usages.json"; + private static final String JS_FILE = "js_usages.json"; + private static final long SAVE_INTERVAL_MS = 60_000; // Auto-save every 60 seconds + private static final int USAGE_SCORE_MULTIPLIER = 50; // Score boost per usage + private static final int MAX_USAGE_BOOST = 5000; // Cap the boost to prevent runaway scores + + private static UsageTracker javaInstance; + private static UsageTracker jsInstance; + private static boolean initialized = false; + + private final Map usageCounts = new ConcurrentHashMap<>(); + private final File file; + private final AtomicBoolean dirty = new AtomicBoolean(false); + private final AtomicBoolean loaded = new AtomicBoolean(false); + + private static ScheduledExecutorService scheduler; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + // ==================== SINGLETON ACCESS ==================== + + public static synchronized UsageTracker getJavaInstance() { + ensureInitialized(); + if (javaInstance == null) { + javaInstance = new UsageTracker(JAVA_FILE); + javaInstance.load(); + } + return javaInstance; + } + + public static synchronized UsageTracker getJSInstance() { + ensureInitialized(); + if (jsInstance == null) { + jsInstance = new UsageTracker(JS_FILE); + jsInstance.load(); + } + return jsInstance; + } + + /** + * Ensure the tracker system is initialized with auto-save and shutdown hook. + */ + private static void ensureInitialized() { + if (!initialized) { + initialized = true; + initialize(); + + // Add shutdown hook to save on JVM exit + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + shutdown(); + }, "UsageTracker-Shutdown")); + } + } + + /** + * Initialize the auto-save scheduler. Call once on client startup. + */ + public static void initialize() { + if (scheduler == null || scheduler.isShutdown()) { + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "UsageTracker-AutoSave"); + t.setDaemon(true); + return t; + }); + + scheduler.scheduleAtFixedRate(() -> { + saveAllIfDirty(); + }, SAVE_INTERVAL_MS, SAVE_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + } + + /** + * Shutdown and save all data. Call on client shutdown. + */ + public static void shutdown() { + saveAllIfDirty(); + if (scheduler != null && !scheduler.isShutdown()) { + scheduler.shutdown(); + } + } + + private static void saveAllIfDirty() { + if (javaInstance != null) { + javaInstance.saveIfDirty(); + } + if (jsInstance != null) { + jsInstance.saveIfDirty(); + } + } + + // ==================== INSTANCE METHODS ==================== + + private UsageTracker(String filename) { + File dir = getDir(); + this.file = new File(dir, filename); + } + + private File getDir() { + File dir = new File(CustomNpcs.Dir, "tracked_usages"); + if (!dir.exists()) { + dir.mkdir(); + } + return dir; + } + + /** + * Record that the user selected an autocomplete item. + * + * @param owner The owner context (full class name for members, null/empty for standalone) + * @param name The item name + * @param kind The item kind + */ + public void recordUsage(String owner, String name, AutocompleteItem.Kind kind) { + String key = buildKey(owner, name, kind); + usageCounts.merge(key, 1, Integer::sum); + dirty.set(true); + } + + /** + * Record usage directly from an AutocompleteItem. + * + * @param item The selected autocomplete item + * @param ownerFullName The full class name of the owner (for member access), or null + */ + public void recordUsage(AutocompleteItem item, String ownerFullName) { + String name = item.getSearchName() != null ? item.getSearchName() : item.getName(); + recordUsage(ownerFullName, name, item.getKind()); + } + + /** + * Get the usage count for an item. + * + * @param owner The owner context + * @param name The item name + * @param kind The item kind + * @return The number of times this item was selected + */ + public int getUsageCount(String owner, String name, AutocompleteItem.Kind kind) { + String key = buildKey(owner, name, kind); + return usageCounts.getOrDefault(key, 0); + } + + /** + * Get usage count directly from an AutocompleteItem. + */ + public int getUsageCount(AutocompleteItem item, String ownerFullName) { + String name = item.getSearchName() != null ? item.getSearchName() : item.getName(); + return getUsageCount(ownerFullName, name, item.getKind()); + } + + /** + * Calculate the score boost based on usage count. + * Uses a logarithmic scale to prevent very frequent items from dominating completely. + * + * @param usageCount The number of times the item was selected + * @return The score boost to add + */ + public static int calculateUsageBoost(int usageCount) { + if (usageCount <= 0) return 0; + + // Logarithmic scaling: log2(count + 1) * multiplier + // This gives diminishing returns for very high counts + int boost = (int) (Math.log(usageCount + 1) / Math.log(2) * USAGE_SCORE_MULTIPLIER); + return Math.min(boost, MAX_USAGE_BOOST); + } + + /** + * Build a unique key for an item. + */ + public static String buildKey(String owner, String name, AutocompleteItem.Kind kind) { + String ownerPart = owner != null ? owner : ""; + String kindPart = kind != null ? kind.name() : "UNKNOWN"; + return ownerPart + "|" + name + "|" + kindPart; + } + + // ==================== PERSISTENCE ==================== + + /** + * Load usage data from file. + */ + public void load() { + if (loaded.get()) return; + + if (!file.exists()) { + loaded.set(true); + return; + } + + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + Type type = new TypeToken>(){}.getType(); + Map data = GSON.fromJson(reader, type); + if (data != null) { + usageCounts.putAll(data); + } + loaded.set(true); + } catch (Exception e) { + System.err.println("[UsageTracker] Failed to load " + file.getName() + ": " + e.getMessage()); + loaded.set(true); // Mark as loaded even on failure to prevent retry loops + } + } + + /** + * Save usage data to file if there are pending changes. + */ + public void saveIfDirty() { + if (!dirty.compareAndSet(true, false)) { + return; // Not dirty, nothing to save + } + save(); + } + + /** + * Force save usage data to file. + */ + public void save() { + try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { + GSON.toJson(usageCounts, writer); + } catch (Exception e) { + System.err.println("[UsageTracker] Failed to save " + file.getName() + ": " + e.getMessage()); + dirty.set(true); // Mark dirty again so we retry later + } + } + + /** + * Clear all usage data (for testing/reset purposes). + */ + public void clear() { + usageCounts.clear(); + dirty.set(true); + } + + /** + * Get the number of tracked items. + */ + public int size() { + return usageCounts.size(); + } +}