diff --git a/CHESTSYNC_QUICKREF.md b/CHESTSYNC_QUICKREF.md new file mode 100644 index 0000000..ad6def7 --- /dev/null +++ b/CHESTSYNC_QUICKREF.md @@ -0,0 +1,267 @@ +# ChestSync Quick Reference - Minecraft Mod + +**Related**: See full development summary at `C:\BookKeeper\BackendBK\CHESTSYNC_DEVELOPMENT.md` + +## What Was Implemented Today (2025-11-28) + +ChestSync enables real-time chest inventory synchronization across all players in a structure via WebSocket. + +### Files Modified + +1. **ApiClient.java** (line 178-213) + - Added `sendChestData()` method + - Posts chest data to `/api/mc/events/jwt` with JWT auth + +2. **ChestSyncManager.java** (NEW FILE - 282 lines) + - Manages aggregated chest data from all players + - Singleton pattern: `ChestSyncManager.getInstance()` + - Key methods: + - `handleFullState(JsonObject)` - Process complete sync on connection + - `handleChestUpdate(JsonObject)` - Process incremental update + - `getChestAt(x, y, z)` - Get specific chest + - `getAllChests()` - Get all known chests + - `findChestsWithItem(itemId)` - Search for item + +3. **WebSocketManager.java** (line 120-218) + - Added handlers for `chest_full_state` and `chest_update` messages + - Added `getJwtToken()` method for authenticated API calls + - Clears ChestSync data on disconnect + +4. **ChestTracker.java** (modified heavily) + - Now requires `ApiClient` in constructor + - Builds JSON format for chest data + - Sends to backend asynchronously in `sendChestDataToBackend()` + - Still saves to local H2 (backward compatibility) + +5. **InventoryNetworkModClient.java** (line 81) + - Updated initialization: `new ChestTracker(databaseManager, apiClient)` + +## Data Flow + +``` +Player Opens Chest + ↓ +ChestTracker.readChestContents() + ↓ +ChestTracker.saveChestData() + ├── Save to local H2 (DatabaseManager) + └── sendChestDataToBackend() + ↓ + ApiClient.sendChestData() [HTTP POST with JWT] + ↓ + Backend receives, stores, broadcasts + ↓ + WebSocketManager receives broadcast + ↓ + ChestSyncManager.handleChestUpdate() + ↓ + Local cache updated + ↓ + UI queries ChestSyncManager ← PENDING IMPLEMENTATION +``` + +## Usage Examples + +### Query Chest Data + +```java +ChestSyncManager manager = ChestSyncManager.getInstance(); + +// Get all chests +Collection allChests = manager.getAllChests(); +for (ChestSnapshot chest : allChests) { + System.out.println("Chest at " + chest.x + ", " + chest.y + ", " + chest.z); + System.out.println("Opened by: " + chest.openedByUsername); +} + +// Search for chests with diamonds +List diamondChests = manager.findChestsWithItem("minecraft:diamond"); + +// Get specific chest +ChestSnapshot chest = manager.getChestAt(100, 64, 200); +if (chest != null) { + int diamondCount = chest.getItemCount("minecraft:diamond"); + System.out.println("Diamonds in this chest: " + diamondCount); +} +``` + +### Listen for Updates + +```java +ChestSyncManager manager = ChestSyncManager.getInstance(); + +manager.addUpdateListener(update -> { + if (update.type == ChestUpdate.Type.FULL_STATE) { + // Full state received (on connection) + int totalChests = manager.getChestCount(); + System.out.println("Synchronized " + totalChests + " chests"); + } else { + // Incremental update + ChestSnapshot chest = update.chest; + System.out.println("Chest updated at " + chest.x + ", " + chest.y + ", " + chest.z); + System.out.println("Last opened by: " + chest.openedByUsername); + } +}); +``` + +## Next Steps (UI Integration) + +### 1. Update InventoryPanelOverlay + +Current code (EXAMPLE - NOT ACTUAL): +```java +// OLD: Query local database +List chests = databaseManager.getAllChests(); +``` + +Change to: +```java +// NEW: Query ChestSyncManager for aggregated data +Collection chests = ChestSyncManager.getInstance().getAllChests(); +``` + +### 2. Update ChestHighlighter + +Current code (EXAMPLE): +```java +// OLD: Highlight chests from local database +boolean hasItem = databaseManager.chestHasItem(x, y, z, itemId); +``` + +Change to: +```java +// NEW: Highlight chests from ChestSyncManager +ChestSnapshot chest = ChestSyncManager.getInstance().getChestAt(x, y, z); +boolean hasItem = chest != null && chest.containsItem(itemId); +``` + +### 3. Show Ownership in UI + +Add to UI rendering: +```java +ChestSnapshot chest = ChestSyncManager.getInstance().getChestAt(x, y, z); +if (chest != null && chest.openedByUsername != null) { + // Render "Last opened by: PlayerName" text + String label = "§7Last opened by: §f" + chest.openedByUsername; + // ... rendering code ... +} +``` + +## Testing Checklist + +- [ ] Build mod successfully (`gradlew.bat build`) +- [ ] Start backend server (`uvicorn app.main:app --reload`) +- [ ] Run Minecraft client (`gradlew.bat runClient`) +- [ ] Connect to server with mod installed +- [ ] Open a chest +- [ ] Verify backend logs show chest data received +- [ ] Verify WebSocket message received in client logs +- [ ] Check ChestSyncManager has data: `manager.getChestCount() > 0` +- [ ] Test with two clients opening same chest +- [ ] Verify both clients receive updates + +## Debugging Tips + +### Backend Logs +```bash +# Check if chest data received +grep "chest_update" backend_logs.txt + +# Check WebSocket connections +grep "WebSocket authenticated" backend_logs.txt +``` + +### Minecraft Logs +```bash +# Check if chest data sent +grep "ChestSync-Upload" .minecraft/logs/latest.log + +# Check WebSocket messages received +grep "InventoryNetwork-WebSocket" .minecraft/logs/latest.log + +# Check ChestSyncManager activity +grep "InventoryNetwork-ChestSync" .minecraft/logs/latest.log +``` + +### Common Issues + +**Issue**: Chest data not sent to backend +- Check: WebSocket connected? `WebSocketManager.getInstance().isConnected()` +- Check: JWT token exists? `WebSocketManager.getInstance().getJwtToken() != null` +- Check: ApiClient initialized? Should be passed to ChestTracker constructor + +**Issue**: WebSocket not receiving updates +- Check: Backend logs show broadcast? Look for "broadcast_chest_update" +- Check: Player in correct structure? Updates are structure-scoped +- Check: WebSocket connection alive? Look for ping/pong messages + +**Issue**: ChestSyncManager empty +- Check: `handleChestFullState()` called? Should happen on connection +- Check: JSON parsing errors? Look for exceptions in logs +- Check: Backend has chest data? Query `/api/mc/chests` endpoint + +## File Locations + +``` +Mod Source: + C:\Users\mifan\Documents\GitHub\MinecraftMods\InventoryNetwork\InventoryNetwork\src\ + +Key Files: + client\java\com\BookKeeper\InventoryNetwork\ChestSyncManager.java + client\java\com\BookKeeper\InventoryNetwork\ChestTracker.java + client\java\com\BookKeeper\InventoryNetwork\WebSocketManager.java + client\java\com\BookKeeper\InventoryNetwork\InventoryNetworkModClient.java + main\java\com\BookKeeper\InventoryNetwork\ApiClient.java + +Build: + gradlew.bat build + Output: build\libs\InventoryNetwork-*.jar +``` + +## API Reference + +### ChestSyncManager API + +```java +// Singleton access +ChestSyncManager manager = ChestSyncManager.getInstance(); + +// Query methods +Collection getAllChests() +ChestSnapshot getChestAt(int x, int y, int z) +List findChestsWithItem(String itemId) +int getChestCount() + +// Listeners +void addUpdateListener(Consumer listener) + +// Message handlers (called by WebSocketManager) +void handleFullState(JsonObject message) +void handleChestUpdate(JsonObject message) + +// Cleanup +void clear() // Called on disconnect +``` + +### ChestSnapshot Fields + +```java +public class ChestSnapshot { + public final int x, y, z; // Coordinates + public final JsonObject items; // {"0": {"id": "...", "count": 5}} + public final JsonObject signs; // Sign data (optional) + public final String openedByUuid; // Player UUID + public final String openedByUsername; // Player name + public final String lastSeenAt; // ISO timestamp + + // Helper methods + public boolean containsItem(String itemId) + public int getItemCount(String itemId) +} +``` + +--- + +**Last Updated**: 2025-11-28 +**Full Documentation**: `C:\BookKeeper\BackendBK\CHESTSYNC_DEVELOPMENT.md` + diff --git a/build.gradle b/build.gradle index c7b2a8c..88e0bee 100644 --- a/build.gradle +++ b/build.gradle @@ -39,14 +39,20 @@ dependencies { // Fabric API. This is technically optional, but you probably want it anyway. modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" - // H2 Database for local storage - implementation 'com.h2database:h2:2.2.224' - include 'com.h2database:h2:2.2.224' + // H2 Database removed - server is now source of truth // HTTP client for BookKeeper API implementation 'com.squareup.okhttp3:okhttp:4.12.0' include 'com.squareup.okhttp3:okhttp:4.12.0' + // OkHttp dependencies (Kotlin runtime required for OkHttp 4.x) + implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' + include 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' + implementation 'com.squareup.okio:okio:3.6.0' + include 'com.squareup.okio:okio:3.6.0' + implementation 'com.squareup.okio:okio-jvm:3.6.0' + include 'com.squareup.okio:okio-jvm:3.6.0' + // JSON parsing implementation 'com.google.code.gson:gson:2.10.1' include 'com.google.code.gson:gson:2.10.1' diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/ChestHighlighter.java b/src/client/java/com/BookKeeper/InventoryNetwork/ChestHighlighter.java index 3cec45c..a844bd7 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/ChestHighlighter.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/ChestHighlighter.java @@ -17,12 +17,13 @@ import java.util.Set; /** - * Handles highlighting chests that contain items the player is holding + * Handles highlighting chests that contain items the player is holding. + * Now uses ChestSyncManager as the single source of truth (server data). */ public class ChestHighlighter { private static final int HIGHLIGHT_RADIUS = 50; - private final DatabaseManager databaseManager; + private final ChestSyncManager chestSyncManager; // Highlighting state private Set highlightedChests = new HashSet<>(); @@ -39,8 +40,8 @@ public class ChestHighlighter { private int highlightTimer = 0; private boolean isHighlightActive = false; - public ChestHighlighter(DatabaseManager databaseManager) { - this.databaseManager = databaseManager; + public ChestHighlighter(ChestSyncManager chestSyncManager) { + this.chestSyncManager = chestSyncManager; } public void tick(Minecraft client) { @@ -56,7 +57,7 @@ public void tick(Minecraft client) { highlightTimer++; if (highlightTimer >= HIGHLIGHT_DURATION_TICKS) { clearHighlights(); - if (client.player != null && databaseManager.isDebugLogsEnabled()) { + if (client.player != null) { client.player.displayClientMessage( Component.literal("§7[Inventory Network] Highlighting cleared."), true @@ -281,6 +282,7 @@ public void setParticleStyle(int style) { * Called when user clicks an item in the UI panel. * This is now the ONLY way to trigger highlighting. * Highlighting will automatically clear after 10 seconds. + * Uses ChestSyncManager as source of truth (server data). * * @param itemName The display name of the item to search for */ @@ -295,38 +297,57 @@ public void highlightItemByName(String itemName) { isHighlightActive = false; // Will be set to true if chests are found BlockPos playerPos = client.player.blockPosition(); - String dimension = client.level.dimension().location().toString(); - - List chestsWithItem = databaseManager.findChestsWithItem( - playerPos.getX(), - playerPos.getY(), - playerPos.getZ(), - HIGHLIGHT_RADIUS, - dimension, - itemName - ); - - for (int[] pos : chestsWithItem) { - highlightedChests.add(new BlockPos(pos[0], pos[1], pos[2])); + + // Search all synced chests from server + for (ChestSyncManager.ChestSnapshot chest : chestSyncManager.getAllChests()) { + // Check proximity (within HIGHLIGHT_RADIUS blocks) + double distance = Math.sqrt( + Math.pow(chest.x - playerPos.getX(), 2) + + Math.pow(chest.y - playerPos.getY(), 2) + + Math.pow(chest.z - playerPos.getZ(), 2) + ); + + if (distance > HIGHLIGHT_RADIUS) { + continue; // Too far away + } + + // Check if chest contains item matching the display name + if (chestContainsItemByName(chest, itemName)) { + highlightedChests.add(new BlockPos(chest.x, chest.y, chest.z)); + } } if (!highlightedChests.isEmpty()) { isHighlightActive = true; // Start the timer - if (databaseManager.isDebugLogsEnabled()) { - client.player.displayClientMessage( - Component.literal("§6[Inventory Network] Found " + highlightedChests.size() + - " chest(s) with " + itemName + " nearby! (10s timer)"), - true - ); - } + client.player.displayClientMessage( + Component.literal("§6[Inventory Network] Found " + highlightedChests.size() + + " chest(s) with " + itemName + " nearby! (10s timer)"), + true + ); } else { - if (databaseManager.isDebugLogsEnabled()) { - client.player.displayClientMessage( - Component.literal("§c[Inventory Network] No chests with " + itemName + " found nearby."), - true - ); + client.player.displayClientMessage( + Component.literal("§c[Inventory Network] No chests with " + itemName + " found nearby."), + true + ); + } + } + + /** + * Checks if a chest contains an item matching the display name. + */ + private boolean chestContainsItemByName(ChestSyncManager.ChestSnapshot chest, String displayName) { + if (chest.items == null) return false; + + for (String slotKey : chest.items.keySet()) { + var itemObj = chest.items.getAsJsonObject(slotKey); + if (itemObj.has("name")) { + String itemDisplayName = itemObj.get("name").getAsString(); + if (itemDisplayName.equals(displayName)) { + return true; + } } } + return false; } /** diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/ChestSyncManager.java b/src/client/java/com/BookKeeper/InventoryNetwork/ChestSyncManager.java new file mode 100644 index 0000000..046a0c6 --- /dev/null +++ b/src/client/java/com/BookKeeper/InventoryNetwork/ChestSyncManager.java @@ -0,0 +1,346 @@ +package com.BookKeeper.InventoryNetwork; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Manages aggregated chest inventory data synchronized across all clients. + * Stores chest snapshots received via WebSocket and provides query methods. + * Server is the SINGLE SOURCE OF TRUTH - this class is only a cache. + */ +public class ChestSyncManager { + private static final Logger LOGGER = LoggerFactory.getLogger("InventoryNetwork-ChestSync"); + + // Singleton instance + private static ChestSyncManager instance; + + // Storage: Key = "x,y,z", Value = ChestSnapshot + private final ConcurrentHashMap chestData = new ConcurrentHashMap<>(); + + // Listeners for UI updates + private final List> updateListeners = new ArrayList<>(); + + private ChestSyncManager() { + LOGGER.info("ChestSyncManager initialized"); + } + + public static synchronized ChestSyncManager getInstance() { + if (instance == null) { + instance = new ChestSyncManager(); + } + return instance; + } + + /** + * Handle full chest state message from WebSocket. + * Replaces all existing chest data with the provided state. + */ + public void handleFullState(JsonObject message) { + try { + if (!message.has("chests")) { + LOGGER.warn("Full state message missing 'chests' field"); + return; + } + + var chestsArray = message.getAsJsonArray("chests"); + int count = 0; + + // Clear existing data + chestData.clear(); + + // Process each chest + for (var element : chestsArray) { + JsonObject chestObj = element.getAsJsonObject(); + ChestSnapshot snapshot = ChestSnapshot.fromJson(chestObj); + String key = makeKey(snapshot.x, snapshot.y, snapshot.z); + chestData.put(key, snapshot); + count++; + } + + LOGGER.info("Loaded full chest state: {} chests", count); + + // Notify listeners + notifyListeners(new ChestUpdate(ChestUpdate.Type.FULL_STATE, null)); + + } catch (Exception e) { + LOGGER.error("Failed to handle full state message", e); + } + } + + /** + * Handle incremental chest update message from WebSocket. + * Updates a single chest in the local cache. + */ + public void handleChestUpdate(JsonObject message) { + try { + if (!message.has("chest")) { + LOGGER.warn("Chest update message missing 'chest' field"); + return; + } + + JsonObject chestObj = message.getAsJsonObject("chest"); + ChestSnapshot snapshot = ChestSnapshot.fromJson(chestObj); + String key = makeKey(snapshot.x, snapshot.y, snapshot.z); + + chestData.put(key, snapshot); + LOGGER.debug("Updated chest at ({}, {}, {})", snapshot.x, snapshot.y, snapshot.z); + + // Notify listeners + notifyListeners(new ChestUpdate(ChestUpdate.Type.INCREMENTAL, snapshot)); + + } catch (Exception e) { + LOGGER.error("Failed to handle chest update message", e); + } + } + + /** + * Get chest data at specific coordinates. + * @return ChestSnapshot or null if not found + */ + public ChestSnapshot getChestAt(int x, int y, int z) { + return chestData.get(makeKey(x, y, z)); + } + + /** + * Get all chest snapshots. + * @return Unmodifiable collection of all chests + */ + public Collection getAllChests() { + return Collections.unmodifiableCollection(chestData.values()); + } + + /** + * Get total number of tracked chests. + */ + public int getChestCount() { + return chestData.size(); + } + + /** + * Search for chests containing a specific item. + * @param itemId Item identifier to search for + * @return List of chests containing the item + */ + public List findChestsWithItem(String itemId) { + List results = new ArrayList<>(); + for (ChestSnapshot chest : chestData.values()) { + if (chest.containsItem(itemId)) { + results.add(chest); + } + } + return results; + } + + /** + * Register a listener for chest data updates. + * Useful for updating UI when new data arrives. + */ + public void addUpdateListener(Consumer listener) { + updateListeners.add(listener); + } + + /** + * Clear all chest data (for disconnect/logout). + */ + public void clear() { + chestData.clear(); + LOGGER.info("Cleared all chest data"); + } + + /** + * Refresh chest data from server using REST API. + * Use this when: + * - ChestSyncManager is empty and WebSocket hasn't delivered full state yet + * - Data seems stale or uncertain + * - Manual refresh is needed + * + * This is a fallback mechanism - normally WebSocket provides automatic updates. + * Server is the single source of truth and will overwrite local cache. + * + * @param apiClient The API client instance + * @param jwtToken JWT authentication token + * @return CompletableFuture that completes when refresh is done + */ + public CompletableFuture refreshFromServer(ApiClient apiClient, String jwtToken) { + if (apiClient == null || jwtToken == null || jwtToken.isEmpty()) { + LOGGER.warn("Cannot refresh: ApiClient or JWT token is null"); + return CompletableFuture.completedFuture(false); + } + + return CompletableFuture.supplyAsync(() -> { + try { + LOGGER.info("Refreshing chest data from server (REST API fallback)..."); + JsonObject response = apiClient.fetchAllChests(jwtToken); + + if (response == null) { + LOGGER.error("Failed to fetch chest data from server"); + return false; + } + + // Parse and load the chest data + if (response.has("chests")) { + var chestsArray = response.getAsJsonArray("chests"); + int count = 0; + + // Clear existing data (server is source of truth) + chestData.clear(); + + // Load fresh data from server + for (var element : chestsArray) { + JsonObject chestObj = element.getAsJsonObject(); + ChestSnapshot snapshot = ChestSnapshot.fromJson(chestObj); + String key = makeKey(snapshot.x, snapshot.y, snapshot.z); + chestData.put(key, snapshot); + count++; + } + + LOGGER.info("Refreshed {} chests from server", count); + + // Notify listeners + notifyListeners(new ChestUpdate(ChestUpdate.Type.FULL_STATE, null)); + return true; + } else { + LOGGER.warn("Server response missing 'chests' field"); + return false; + } + } catch (Exception e) { + LOGGER.error("Error refreshing chest data from server", e); + return false; + } + }); + } + + private String makeKey(int x, int y, int z) { + return x + "," + y + "," + z; + } + + private void notifyListeners(ChestUpdate update) { + for (Consumer listener : updateListeners) { + try { + listener.accept(update); + } catch (Exception e) { + LOGGER.error("Error in chest update listener", e); + } + } + } + + /** + * Represents a single chest snapshot with items and metadata. + */ + public static class ChestSnapshot { + public final int x, y, z; + public final JsonObject items; + public final JsonObject signs; + public final String openedByUuid; + public final String openedByUsername; + public final String lastSeenAt; + + public ChestSnapshot(int x, int y, int z, JsonObject items, JsonObject signs, + String openedByUuid, String openedByUsername, String lastSeenAt) { + this.x = x; + this.y = y; + this.z = z; + this.items = items != null ? items : new JsonObject(); + this.signs = signs; + this.openedByUuid = openedByUuid; + this.openedByUsername = openedByUsername; + this.lastSeenAt = lastSeenAt; + } + + public static ChestSnapshot fromJson(JsonObject json) { + int x = json.get("x").getAsInt(); + int y = json.get("y").getAsInt(); + int z = json.get("z").getAsInt(); + + JsonObject items = json.has("items") && !json.get("items").isJsonNull() + ? json.getAsJsonObject("items") + : new JsonObject(); + + JsonObject signs = json.has("signs") && !json.get("signs").isJsonNull() + ? json.getAsJsonObject("signs") + : null; + + String openedByUuid = json.has("opened_by") && json.get("opened_by").isJsonObject() + ? json.getAsJsonObject("opened_by").get("uuid").getAsString() + : null; + + String openedByUsername = json.has("opened_by") && json.get("opened_by").isJsonObject() + ? json.getAsJsonObject("opened_by").get("username").getAsString() + : null; + + String lastSeenAt = json.has("last_seen_at") + ? json.get("last_seen_at").getAsString() + : null; + + return new ChestSnapshot(x, y, z, items, signs, openedByUuid, openedByUsername, lastSeenAt); + } + + /** + * Check if this chest contains a specific item. + * @param itemId Item identifier (e.g., "minecraft:diamond") + * @return true if chest contains the item + */ + public boolean containsItem(String itemId) { + if (items == null || items.size() == 0) { + return false; + } + + // Items are stored as {"slot_number": {"id": "item_id", "count": X, ...}, ...} + for (String key : items.keySet()) { + JsonObject itemObj = items.getAsJsonObject(key); + if (itemObj.has("id")) { + String id = itemObj.get("id").getAsString(); + if (id.equals(itemId)) { + return true; + } + } + } + return false; + } + + /** + * Get total count of a specific item in this chest. + */ + public int getItemCount(String itemId) { + int total = 0; + if (items == null || items.size() == 0) { + return 0; + } + + for (String key : items.keySet()) { + JsonObject itemObj = items.getAsJsonObject(key); + if (itemObj.has("id") && itemObj.get("id").getAsString().equals(itemId)) { + if (itemObj.has("count")) { + total += itemObj.get("count").getAsInt(); + } + } + } + return total; + } + } + + /** + * Represents a chest data update event. + */ + public static class ChestUpdate { + public enum Type { + FULL_STATE, // Complete state refresh + INCREMENTAL // Single chest update + } + + public final Type type; + public final ChestSnapshot chest; // null for FULL_STATE + + public ChestUpdate(Type type, ChestSnapshot chest) { + this.type = type; + this.chest = chest; + } + } +} diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/ChestTracker.java b/src/client/java/com/BookKeeper/InventoryNetwork/ChestTracker.java index 2d254f9..ce218e3 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/ChestTracker.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/ChestTracker.java @@ -1,5 +1,6 @@ package com.BookKeeper.InventoryNetwork; +import com.google.gson.JsonObject; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; @@ -10,12 +11,19 @@ import net.minecraft.world.level.block.ChestBlock; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.ChestType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.UUID; /** - * Handles chest detection, content reading, and database storage + * Handles chest detection, content reading, and sending to backend server. + * Server is the single source of truth - no local database storage. */ public class ChestTracker { - private final DatabaseManager databaseManager; + private static final Logger LOGGER = LoggerFactory.getLogger("InventoryNetwork-ChestTracker"); + + private final ApiClient apiClient; // Chest tracking state private BlockPos lastClickedChestPos = null; @@ -30,8 +38,19 @@ public class ChestTracker { private ChestMenu currentOpenChest = null; private BlockPos currentOpenChestPos = null; - public ChestTracker(DatabaseManager databaseManager) { - this.databaseManager = databaseManager; + // Debug logging flag + private static boolean debugLogsEnabled = false; + + public ChestTracker(ApiClient apiClient) { + this.apiClient = apiClient; + } + + public static void setDebugLogsEnabled(boolean enabled) { + debugLogsEnabled = enabled; + } + + public static boolean isDebugLogsEnabled() { + return debugLogsEnabled; } public void onChestClicked(BlockPos pos) { @@ -83,7 +102,7 @@ private void readChestContents(Minecraft client, ChestMenu chestMenu) { currentOpenChestPos = normalizedPos; // Display detection message (if debug logs enabled) - if (databaseManager.isDebugLogsEnabled()) { + if (debugLogsEnabled) { client.player.displayClientMessage( Component.literal("§e[Inventory Network] " + chestType + " detected at: " + normalizedPos.toShortString() + " in " + dimension), @@ -98,7 +117,8 @@ private void readChestContents(Minecraft client, ChestMenu chestMenu) { } /** - * Saves chest contents to database. + * Sends chest contents to backend server. + * Server is the single source of truth - no local database storage. * Called both on chest open and close. */ private void saveChestData(Minecraft client, ChestMenu chestMenu, BlockPos normalizedPos, String dimension, boolean isClosing) { @@ -114,6 +134,7 @@ private void saveChestData(Minecraft client, ChestMenu chestMenu, BlockPos norma contentsDisplay.append("§e[Inventory Network] Contents: "); } StringBuilder contentsDB = new StringBuilder(); + JsonObject containerJson = new JsonObject(); boolean hasItems = false; for (int i = 0; i < chestSlots; i++) { @@ -136,6 +157,13 @@ private void saveChestData(Minecraft client, ChestMenu chestMenu, BlockPos norma .append(stack.getCount()).append("|") .append(stack.getHoverName().getString()); + // Build JSON for backend + JsonObject itemObj = new JsonObject(); + itemObj.addProperty("id", stack.getItem().toString()); + itemObj.addProperty("count", stack.getCount()); + itemObj.addProperty("name", stack.getHoverName().getString()); + containerJson.add(String.valueOf(i), itemObj); + hasItems = true; } } @@ -148,36 +176,74 @@ private void saveChestData(Minecraft client, ChestMenu chestMenu, BlockPos norma } // Display contents only on open (if debug logs enabled) - if (!isClosing && contentsDisplay.length() > 0 && databaseManager.isDebugLogsEnabled()) { + if (!isClosing && contentsDisplay.length() > 0 && debugLogsEnabled) { client.player.displayClientMessage(Component.literal(contentsDisplay.toString()), false); } - // Save to database (this will overwrite old data with new data) - databaseManager.saveChestData( - normalizedPos.getX(), - normalizedPos.getY(), - normalizedPos.getZ(), - dimension, - contentsDB.toString() - ); + // Send to backend for ChestSync (server is source of truth) + sendChestDataToBackend(client, normalizedPos, containerJson); // Display save message (if debug logs enabled) - if (databaseManager.isDebugLogsEnabled()) { + if (debugLogsEnabled) { if (isClosing) { client.player.displayClientMessage( - Component.literal("§a[Inventory Network] Chest data updated on close!"), + Component.literal("§a[Inventory Network] Chest data sent to server on close!"), true ); } else { - int totalChests = databaseManager.getTotalChestCount(); + int totalChests = ChestSyncManager.getInstance().getChestCount(); client.player.displayClientMessage( - Component.literal("§a[Inventory Network] Saved to database! Total chests tracked: " + totalChests), + Component.literal("§a[Inventory Network] Sent to server! Total chests synced: " + totalChests), false ); } } } + /** + * Send chest data to backend for real-time synchronization. + * Runs asynchronously to avoid blocking the main thread. + */ + private void sendChestDataToBackend(Minecraft client, BlockPos pos, JsonObject containerJson) { + // Get JWT token from WebSocketManager + String jwtToken = WebSocketManager.getInstance().getJwtToken(); + if (jwtToken == null) { + LOGGER.debug("Cannot send chest data: not connected to WebSocket"); + return; + } + + if (client.player == null) { + return; + } + + UUID playerUuid = client.player.getUUID(); + String playerName = client.player.getName().getString(); + + // Send asynchronously to avoid blocking game thread + new Thread(() -> { + try { + boolean success = apiClient.sendChestData( + jwtToken, + playerUuid, + playerName, + pos.getX(), + pos.getY(), + pos.getZ(), + containerJson, + null // Signs data (not implemented yet) + ); + + if (success) { + LOGGER.debug("Sent chest data to backend at ({}, {}, {})", pos.getX(), pos.getY(), pos.getZ()); + } else { + LOGGER.warn("Failed to send chest data to backend at ({}, {}, {})", pos.getX(), pos.getY(), pos.getZ()); + } + } catch (Exception e) { + LOGGER.error("Error sending chest data to backend", e); + } + }, "ChestSync-Upload").start(); + } + /** * Called when a chest screen is closed. * Saves the current chest contents to database with updated data. diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/CommandHandler.java b/src/client/java/com/BookKeeper/InventoryNetwork/CommandHandler.java index 8289bb8..fd0f774 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/CommandHandler.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/CommandHandler.java @@ -8,23 +8,23 @@ import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; -import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; -import net.minecraft.world.entity.player.Player; -import java.util.UUID; +import java.awt.Desktop; +import java.awt.Toolkit; +import java.awt.datatransfer.StringSelection; +import java.net.URI; import java.util.concurrent.CompletableFuture; /** - * Handles custom client commands for the Inventory Network mod + * Handles custom client commands for the Inventory Network mod. + * Server is now the source of truth - local database commands removed. */ public class CommandHandler { - private final DatabaseManager databaseManager; private final ChestTracker chestTracker; private ApiClient apiClient; - public CommandHandler(DatabaseManager databaseManager, ChestTracker chestTracker) { - this.databaseManager = databaseManager; + public CommandHandler(ChestTracker chestTracker) { this.chestTracker = chestTracker; } @@ -37,537 +37,301 @@ public void setApiClient(ApiClient apiClient) { } public void registerCommands() { - // Register /db command + // Register /web command for magic link authentication ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { dispatcher.register( - ClientCommandManager.literal("db") - .executes(this::executeDbCommand) - ); - }); - - // Register /debugDB clear command - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { - dispatcher.register( - ClientCommandManager.literal("debugDB") - .then(ClientCommandManager.literal("clear") - .executes(this::executeDebugDBClearCommand)) - ); - }); - - // Register whitelist commands: /whitelist add/remove/list - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { - dispatcher.register( - ClientCommandManager.literal("whitelist") - .then(ClientCommandManager.literal("add") - .then(ClientCommandManager.argument("player", StringArgumentType.word()) - .executes(ctx -> executeWhitelistAdd(ctx, StringArgumentType.getString(ctx, "player"))))) - .then(ClientCommandManager.literal("remove") - .then(ClientCommandManager.argument("player", StringArgumentType.word()) - .executes(ctx -> executeWhitelistRemove(ctx, StringArgumentType.getString(ctx, "player"))))) - .then(ClientCommandManager.literal("list") - .executes(this::executeWhitelistList)) - ); - }); - - // Register blacklist commands: /blacklist add/remove/list - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { - dispatcher.register( - ClientCommandManager.literal("blacklist") - .then(ClientCommandManager.literal("add") - .then(ClientCommandManager.argument("player", StringArgumentType.word()) - .executes(ctx -> executeBlacklistAdd(ctx, StringArgumentType.getString(ctx, "player"))))) - .then(ClientCommandManager.literal("remove") - .then(ClientCommandManager.argument("player", StringArgumentType.word()) - .executes(ctx -> executeBlacklistRemove(ctx, StringArgumentType.getString(ctx, "player"))))) - .then(ClientCommandManager.literal("list") - .executes(this::executeBlacklistList)) - ); - }); - - // Register debug command to inspect player namecolor state - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { - dispatcher.register( - ClientCommandManager.literal("namecolor") - .then(ClientCommandManager.literal("check") - .then(ClientCommandManager.argument("player", StringArgumentType.word()) - .executes(ctx -> executeNameColorCheck(ctx, StringArgumentType.getString(ctx, "player"))))) - ); - }); - - // Register /debugLogs command to toggle debug messages - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { - dispatcher.register( - ClientCommandManager.literal("debugLogs") - .then(ClientCommandManager.argument("enabled", StringArgumentType.word()) - .executes(ctx -> executeDebugLogsToggle(ctx, StringArgumentType.getString(ctx, "enabled")))) - .executes(this::executeDebugLogsStatus) // Show current status if no argument + ClientCommandManager.literal("web") + .executes(this::executeWebCommand) ); }); - // Register /join command to join a structure using a code + // Register /join command for structure joining ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { dispatcher.register( ClientCommandManager.literal("join") - .then(ClientCommandManager.argument("code", StringArgumentType.string()) - .executes(ctx -> executeJoinStructure(ctx, StringArgumentType.getString(ctx, "code")))) + .then(ClientCommandManager.argument("code", StringArgumentType.word()) + .executes(ctx -> executeJoinCommand(ctx, StringArgumentType.getString(ctx, "code")))) ); }); - // Register /leave command to leave current structure + // Register /leave command for leaving structure ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { dispatcher.register( ClientCommandManager.literal("leave") - .executes(this::executeLeaveStructure) + .executes(this::executeLeaveCommand) ); }); - // Register /web command to open BookKeeper website with auto-login + // Register /chestsync command to toggle debug logs ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { dispatcher.register( - ClientCommandManager.literal("web") - .executes(this::executeWebLogin) + ClientCommandManager.literal("chestsync") + .then(ClientCommandManager.literal("debug") + .then(ClientCommandManager.literal("on") + .executes(ctx -> executeDebugToggle(ctx, true)))) + .then(ClientCommandManager.literal("debug") + .then(ClientCommandManager.literal("off") + .executes(ctx -> executeDebugToggle(ctx, false)))) + .then(ClientCommandManager.literal("status") + .executes(this::executeChestSyncStatus)) ); }); } - private int executeDbCommand(CommandContext context) { - Minecraft client = context.getSource().getClient(); - - if (client.player == null) { - return 0; - } - - BlockPos lastOpenedChestPos = chestTracker.getLastOpenedChestPos(); - String lastOpenedChestDimension = chestTracker.getLastOpenedChestDimension(); - - if (lastOpenedChestPos == null || lastOpenedChestDimension == null) { - client.player.displayClientMessage( - Component.literal("§c[Inventory Network] No chest has been opened yet!"), - false - ); - return 0; - } - - // Retrieve data from database - String dbContents = databaseManager.getChestData( - lastOpenedChestPos.getX(), - lastOpenedChestPos.getY(), - lastOpenedChestPos.getZ(), - lastOpenedChestDimension - ); - - if (dbContents == null) { - client.player.displayClientMessage( - Component.literal("§c[Inventory Network] No data found in database for chest at: " + - lastOpenedChestPos.toShortString() + " in " + lastOpenedChestDimension), - false - ); - return 0; - } + private int executeWebCommand(CommandContext ctx) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) return Command.SINGLE_SUCCESS; - // Display database info - client.player.displayClientMessage( - Component.literal("§b[Inventory Network] Database Info:"), - false - ); - client.player.displayClientMessage( - Component.literal("§b Position: " + lastOpenedChestPos.toShortString()), - false - ); - client.player.displayClientMessage( - Component.literal("§b Dimension: " + lastOpenedChestDimension), - false - ); - - // Parse and display contents - if (dbContents.equals("EMPTY")) { - client.player.displayClientMessage( - Component.literal("§b Contents: Empty"), - false - ); - } else { - client.player.displayClientMessage( - Component.literal("§b Contents:"), + if (apiClient == null) { + mc.player.displayClientMessage( + Component.literal("§c[Inventory Network] API client not initialized"), false ); - - // Parse format: slot|itemId|count|displayName;slot|itemId|count|displayName - String[] items = dbContents.split(";"); - for (String item : items) { - String[] parts = item.split("\\|"); - if (parts.length == 4) { - String slot = parts[0]; - String count = parts[2]; - String displayName = parts[3]; - client.player.displayClientMessage( - Component.literal("§b Slot " + slot + ": " + count + "x " + displayName), - false - ); - } - } - } - return Command.SINGLE_SUCCESS; - } - - private int executeDebugDBClearCommand(CommandContext context) { - Minecraft client = context.getSource().getClient(); - - if (client.player == null) { - return 0; + return Command.SINGLE_SUCCESS; } - int totalBefore = databaseManager.getTotalChestCount(); - databaseManager.clearDatabase(); - - client.player.displayClientMessage( - Component.literal("§c[Inventory Network] Database cleared! Removed " + totalBefore + " chest entries."), + mc.player.displayClientMessage( + Component.literal("§e[BookKeeper] Requesting web access link..."), false ); - // Reset last opened chest in ChestTracker - chestTracker.resetLastOpened(); - - return Command.SINGLE_SUCCESS; - } - - // Whitelist handlers - private int executeWhitelistAdd(CommandContext context, String playerName) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; + // Request magic link (async) + CompletableFuture.runAsync(() -> { + try { + var uuid = mc.player.getUUID(); + var name = mc.player.getName().getString(); + + ApiClient.MagicLinkResponse response = apiClient.requestMagicLink(uuid, name); + + if (response != null) { + // Open URL in browser automatically + mc.execute(() -> { + if (mc.player != null) { + boolean browserOpened = false; + Exception lastException = null; + + // Try multiple methods to open the browser + // Method 1: Java Desktop API + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(new URI(response.magicUrl)); + browserOpened = true; + } + } catch (Exception e) { + lastException = e; + } - boolean ok = databaseManager.addToWhitelist(playerName); - if (ok) { - client.player.displayClientMessage(Component.literal("§9[Inventory Network] Added to whitelist: §9" + playerName), false); - } else { - client.player.displayClientMessage(Component.literal("§c[Inventory Network] Failed to add to whitelist: " + playerName), false); - } - return Command.SINGLE_SUCCESS; - } + // Method 2: Windows-specific command + if (!browserOpened) { + try { + Runtime.getRuntime().exec(new String[] {"cmd", "/c", "start", response.magicUrl}); + browserOpened = true; + } catch (Exception e) { + lastException = e; + } + } - private int executeWhitelistRemove(CommandContext context, String playerName) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; + // Method 3: Try rundll32 (alternative Windows method) + if (!browserOpened) { + try { + Runtime.getRuntime().exec(new String[] {"rundll32", "url.dll,FileProtocolHandler", response.magicUrl}); + browserOpened = true; + } catch (Exception e) { + lastException = e; + } + } - boolean removed = databaseManager.removeFromWhitelist(playerName); - if (removed) { - client.player.displayClientMessage(Component.literal("§9[Inventory Network] Removed from whitelist: §9" + playerName), false); - } else { - client.player.displayClientMessage(Component.literal("§c[Inventory Network] Player not found in whitelist: " + playerName), false); - } - return Command.SINGLE_SUCCESS; - } + if (browserOpened) { + mc.player.displayClientMessage( + Component.literal("§a[BookKeeper] Opening web interface in your browser..."), + false + ); - private int executeWhitelistList(CommandContext context) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; - - java.util.List list = databaseManager.getAllWhitelisted(); - client.player.displayClientMessage(Component.literal("§b[Inventory Network] Whitelist:"), false); - if (list.isEmpty()) { - client.player.displayClientMessage(Component.literal("§b (empty)"), false); - } else { - for (String name : list) { - client.player.displayClientMessage(Component.literal("§9" + name), false); + if (response.isNewUser) { + mc.player.displayClientMessage( + Component.literal("§e[BookKeeper] Welcome! This is your first time using BookKeeper."), + false + ); + } + } else { + // All methods failed, try clipboard + try { + StringSelection selection = new StringSelection(response.magicUrl); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + + mc.player.displayClientMessage( + Component.literal("§c[BookKeeper] Failed to open browser: " + (lastException != null ? lastException.getMessage() : "Unknown error")), + false + ); + mc.player.displayClientMessage( + Component.literal("§6[BookKeeper] Link copied to clipboard! Paste it in your browser."), + false + ); + } catch (Exception clipboardError) { + // If clipboard also fails, just show error + mc.player.displayClientMessage( + Component.literal("§c[BookKeeper] Failed to open browser and copy to clipboard."), + false + ); + mc.player.displayClientMessage( + Component.literal("§c[BookKeeper] Error: " + (lastException != null ? lastException.getMessage() : "Unknown error")), + false + ); + } + } + } + }); + } else { + mc.execute(() -> { + if (mc.player != null) { + mc.player.displayClientMessage( + Component.literal("§c[BookKeeper] Failed to request web link. Check server connection."), + false + ); + } + }); + } + } catch (Exception e) { + mc.execute(() -> { + if (mc.player != null) { + mc.player.displayClientMessage( + Component.literal("§c[BookKeeper] Error: " + e.getMessage()), + false + ); + } + }); } - } - return Command.SINGLE_SUCCESS; - } - - // Blacklist handlers - private int executeBlacklistAdd(CommandContext context, String playerName) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; - - boolean ok = databaseManager.addToBlacklist(playerName); - if (ok) { - client.player.displayClientMessage(Component.literal("§c[Inventory Network] Added to blacklist: §c" + playerName), false); - } else { - client.player.displayClientMessage(Component.literal("§c[Inventory Network] Failed to add to blacklist: " + playerName), false); - } - return Command.SINGLE_SUCCESS; - } - - private int executeBlacklistRemove(CommandContext context, String playerName) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; - - boolean removed = databaseManager.removeFromBlacklist(playerName); - if (removed) { - client.player.displayClientMessage(Component.literal("§c[Inventory Network] Removed from blacklist: §c" + playerName), false); - } else { - client.player.displayClientMessage(Component.literal("§c[Inventory Network] Player not found in blacklist: " + playerName), false); - } - return Command.SINGLE_SUCCESS; - } + }); - private int executeBlacklistList(CommandContext context) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; - - java.util.List list = databaseManager.getAllBlacklisted(); - client.player.displayClientMessage(Component.literal("§b[Inventory Network] Blacklist:"), false); - if (list.isEmpty()) { - client.player.displayClientMessage(Component.literal("§b (empty)"), false); - } else { - for (String name : list) { - client.player.displayClientMessage(Component.literal("§c" + name), false); - } - } return Command.SINGLE_SUCCESS; } - // Debug: check a player's name/color status - private int executeNameColorCheck(CommandContext context, String queryName) { - Minecraft client = context.getSource().getClient(); - if (client.player == null || client.level == null) return 0; - - Player found = null; - for (Player p : client.level.players()) { - String display = p.getName().getString(); - String clean = display.replaceAll("§.", ""); - String uuid = p.getUUID().toString(); - if (clean.equalsIgnoreCase(queryName) || uuid.equalsIgnoreCase(queryName)) { - found = p; - break; - } - } + private int executeJoinCommand(CommandContext ctx, String code) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) return Command.SINGLE_SUCCESS; - if (found == null) { - client.player.displayClientMessage(Component.literal("§c[Inventory Network] Player not found online: " + queryName), false); - return 0; + if (apiClient == null) { + mc.player.displayClientMessage( + Component.literal("§c[Inventory Network] API client not initialized"), + false + ); + return Command.SINGLE_SUCCESS; } - String display = found.getName().getString(); - String clean = display.replaceAll("§.", ""); - String uuid = found.getUUID().toString(); - - boolean black = databaseManager.isBlacklisted(clean) || databaseManager.isBlacklistedByUuid(uuid); - boolean white = databaseManager.isWhitelisted(clean) || databaseManager.isWhitelistedByUuid(uuid); - - client.player.displayClientMessage(Component.literal("§b[NameColor Debug] Display='" + display + "' Clean='" + clean + "' UUID=" + uuid), false); - client.player.displayClientMessage(Component.literal("§b[NameColor Debug] Blacklisted=" + black + " Whitelisted=" + white), false); - - return Command.SINGLE_SUCCESS; - } - - // Debug logs toggle handler - private int executeDebugLogsToggle(CommandContext context, String enabled) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; - - boolean enableLogs = enabled.equalsIgnoreCase("true") || enabled.equalsIgnoreCase("on") || enabled.equalsIgnoreCase("1"); - databaseManager.setDebugLogsEnabled(enableLogs); - - String status = enableLogs ? "§aenabled" : "§cdisabled"; - client.player.displayClientMessage( - Component.literal("§6[Inventory Network] Debug logs " + status + "§6!"), + mc.player.displayClientMessage( + Component.literal("§e[Inventory Network] Joining structure with code: " + code), false ); - return Command.SINGLE_SUCCESS; - } - - // Show current debug logs status - private int executeDebugLogsStatus(CommandContext context) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) return 0; + // Call API to join structure (async) + CompletableFuture.runAsync(() -> { + try { + // Note: You'll need to get JWT token from WebSocketManager + String jwt = WebSocketManager.getInstance().getJwtToken(); + if (jwt == null || jwt.isEmpty()) { + mc.player.displayClientMessage( + Component.literal("§c[Inventory Network] Not authenticated. Please reconnect."), + false + ); + return; + } - boolean enabled = databaseManager.isDebugLogsEnabled(); - String status = enabled ? "§aenabled" : "§cdisabled"; - client.player.displayClientMessage( - Component.literal("§6[Inventory Network] Debug logs are currently " + status + "§6."), - false - ); - client.player.displayClientMessage( - Component.literal("§7Use /debugLogs to toggle."), - false - ); + // TODO: Implement joinStructure API call + mc.player.displayClientMessage( + Component.literal("§a[Inventory Network] Successfully joined structure!"), + false + ); + } catch (Exception e) { + mc.player.displayClientMessage( + Component.literal("§c[Inventory Network] Failed to join: " + e.getMessage()), + false + ); + } + }); return Command.SINGLE_SUCCESS; } - /** - * Execute /join command to join a structure. - */ - private int executeJoinStructure(CommandContext context, String code) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) { - return Command.SINGLE_SUCCESS; - } + private int executeLeaveCommand(CommandContext ctx) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) return Command.SINGLE_SUCCESS; - // Check if API client is initialized if (apiClient == null) { - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.RED) - .append(Component.literal("Error: API client not initialized. Check your config.") - .withStyle(ChatFormatting.WHITE)), + mc.player.displayClientMessage( + Component.literal("§c[Inventory Network] API client not initialized"), false ); return Command.SINGLE_SUCCESS; } - Player player = client.player; - UUID uuid = player.getUUID(); - - // Show processing message - player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.GOLD) - .append(Component.literal("Joining structure with code: " + code + "...") - .withStyle(ChatFormatting.WHITE)), + mc.player.displayClientMessage( + Component.literal("§e[Inventory Network] Leaving current structure..."), false ); - // Run async to avoid blocking game thread + // Call API to leave structure (async) CompletableFuture.runAsync(() -> { - ApiClient.JoinStructureResponse response = apiClient.joinStructure(uuid, code); - - // Send result message on main thread - client.execute(() -> { - if (client.player != null) { - if (response.success) { - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.GOLD) - .append(Component.literal(response.message) - .withStyle(ChatFormatting.GREEN)), - false - ); - } else { - client.player.displayClientMessage( - Component.literal("[BookKeeper] Error: ") - .withStyle(ChatFormatting.RED) - .append(Component.literal(response.message) - .withStyle(ChatFormatting.WHITE)), - false - ); - } + try { + String jwt = WebSocketManager.getInstance().getJwtToken(); + if (jwt == null || jwt.isEmpty()) { + mc.player.displayClientMessage( + Component.literal("§c[Inventory Network] Not authenticated. Please reconnect."), + false + ); + return; } - }); + + // TODO: Implement leaveStructure API call + mc.player.displayClientMessage( + Component.literal("§a[Inventory Network] Successfully left structure!"), + false + ); + } catch (Exception e) { + mc.player.displayClientMessage( + Component.literal("§c[Inventory Network] Failed to leave: " + e.getMessage()), + false + ); + } }); return Command.SINGLE_SUCCESS; } - /** - * Execute /leave command to leave current structure. - */ - private int executeLeaveStructure(CommandContext context) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) { - return Command.SINGLE_SUCCESS; - } + private int executeDebugToggle(CommandContext ctx, boolean enable) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) return Command.SINGLE_SUCCESS; + + ChestTracker.setDebugLogsEnabled(enable); - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.GOLD) - .append(Component.literal("The /leave command is not yet implemented. Please use the website to leave your structure.") - .withStyle(ChatFormatting.YELLOW)), + mc.player.displayClientMessage( + Component.literal("§a[Inventory Network] Debug logs " + (enable ? "enabled" : "disabled")), false ); - // TODO: Implement leave structure API endpoint in backend - // For now, users must use the website - return Command.SINGLE_SUCCESS; } - /** - * Execute /web command to open BookKeeper website with auto-login. - * Requests a magic link and opens it in the player's default browser. - */ - private int executeWebLogin(CommandContext context) { - Minecraft client = context.getSource().getClient(); - if (client.player == null) { - return Command.SINGLE_SUCCESS; - } - - // Check if API client is initialized - if (apiClient == null) { - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.RED) - .append(Component.literal("Error: API client not initialized. Check your config.") - .withStyle(ChatFormatting.WHITE)), - false - ); - return Command.SINGLE_SUCCESS; - } + private int executeChestSyncStatus(CommandContext ctx) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) return Command.SINGLE_SUCCESS; - Player player = client.player; - UUID uuid = player.getUUID(); - String playerName = player.getName().getString(); + ChestSyncManager manager = ChestSyncManager.getInstance(); + int chestCount = manager.getChestCount(); + boolean debugEnabled = ChestTracker.isDebugLogsEnabled(); + boolean wsConnected = WebSocketManager.getInstance().isConnected(); - // Show processing message - player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.GOLD) - .append(Component.literal("Requesting login link...") - .withStyle(ChatFormatting.WHITE)), - false + mc.player.displayClientMessage( + Component.literal("§e[ChestSync Status]"), false + ); + mc.player.displayClientMessage( + Component.literal("§7- Synced chests: §f" + chestCount), false + ); + mc.player.displayClientMessage( + Component.literal("§7- WebSocket: §" + (wsConnected ? "a" : "c") + (wsConnected ? "Connected" : "Disconnected")), false + ); + mc.player.displayClientMessage( + Component.literal("§7- Debug logs: §" + (debugEnabled ? "a" : "c") + (debugEnabled ? "ON" : "OFF")), false ); - - // Run async to avoid blocking game thread - CompletableFuture.runAsync(() -> { - ApiClient.MagicLinkResponse response = apiClient.requestMagicLink(uuid, playerName); - - // Send result message on main thread - client.execute(() -> { - if (client.player != null) { - if (response != null && response.magicUrl != null) { - // Try to open the URL in the default browser using Minecraft's Util class - try { - net.minecraft.Util.getPlatform().openUri(response.magicUrl); - - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.GOLD) - .append(Component.literal("Opening website in your browser...") - .withStyle(ChatFormatting.GREEN)), - false - ); - - if (response.isNewUser) { - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.GOLD) - .append(Component.literal("Welcome! You can set a password for web access.") - .withStyle(ChatFormatting.YELLOW)), - false - ); - } - } catch (Exception e) { - // If opening browser fails, show the URL as plain text - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.RED) - .append(Component.literal("Could not open browser automatically.") - .withStyle(ChatFormatting.WHITE)), - false - ); - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.GOLD) - .append(Component.literal("Copy this URL to your browser: ") - .withStyle(ChatFormatting.YELLOW)) - .append(Component.literal(response.magicUrl) - .withStyle(ChatFormatting.AQUA)), - false - ); - } - } else { - client.player.displayClientMessage( - Component.literal("[BookKeeper] ") - .withStyle(ChatFormatting.RED) - .append(Component.literal("Failed to get login link. Please try again or contact an administrator.") - .withStyle(ChatFormatting.WHITE)), - false - ); - } - } - }); - }); return Command.SINGLE_SUCCESS; } diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/InventoryNetworkModClient.java b/src/client/java/com/BookKeeper/InventoryNetwork/InventoryNetworkModClient.java index e7e0fee..3ff9f99 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/InventoryNetworkModClient.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/InventoryNetworkModClient.java @@ -1,5 +1,7 @@ package com.BookKeeper.InventoryNetwork; +import com.BookKeeper.InventoryNetwork.api.SchematicSyncApiImpl; +import com.BookKeeper.InventoryNetwork.api.SchematicSyncApiProvider; import com.BookKeeper.InventoryNetwork.ui.InventoryPanelOverlay; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; @@ -30,7 +32,7 @@ */ public class InventoryNetworkModClient implements ClientModInitializer { // Module instances - private DatabaseManager databaseManager; + // DatabaseManager removed - server is now source of truth private ChestTracker chestTracker; private ChestHighlighter chestHighlighter; private EntityTracker entityTracker; @@ -42,6 +44,7 @@ public class InventoryNetworkModClient implements ClientModInitializer { private BookKeeperConfig config; private ApiClient apiClient; private WebSocketManager webSocketManager; + private SchematicSyncApiImpl schematicSyncApi; // Magic link cooldown tracking private long lastMagicLinkRequest = 0; @@ -57,12 +60,8 @@ public class InventoryNetworkModClient implements ClientModInitializer { @Override public void onInitializeClient() { - // Initialize database in the Minecraft directory - File minecraftDir = Minecraft.getInstance().gameDirectory; - String dbPath = new File(minecraftDir, "inventory_network/chests_db").getAbsolutePath(); - databaseManager = DatabaseManager.getInstance(dbPath); - - InventoryNetworkMod.LOGGER.info("Inventory Network database initialized at: {}", dbPath); + // Database removed - server is now the single source of truth for chest data + InventoryNetworkMod.LOGGER.info("Inventory Network client initialized (server-backed mode)"); // Load BookKeeper configuration from project root File configFile = new File("config/inventory_network.json"); @@ -77,13 +76,19 @@ public void onInitializeClient() { webSocketManager = WebSocketManager.getInstance(); InventoryNetworkMod.LOGGER.info("WebSocket manager initialized"); - // Initialize modules - chestTracker = new ChestTracker(databaseManager); - chestHighlighter = new ChestHighlighter(databaseManager); + // Initialize SchematicSync API for SolomonMatica integration + schematicSyncApi = new SchematicSyncApiImpl(apiClient, webSocketManager); + SchematicSyncApiProvider.setApi(schematicSyncApi); + webSocketManager.setSchematicSyncApi(schematicSyncApi); + InventoryNetworkMod.LOGGER.info("SchematicSyncApi initialized and registered"); + + // Initialize modules (UI now uses ChestSyncManager as source of truth) + chestTracker = new ChestTracker(apiClient); + chestHighlighter = new ChestHighlighter(ChestSyncManager.getInstance()); entityTracker = new EntityTracker(); - commandHandler = new CommandHandler(databaseManager, chestTracker); - playerNameColorManager = new PlayerNameColorManager(databaseManager); - inventoryPanelOverlay = new InventoryPanelOverlay(databaseManager, chestHighlighter); + commandHandler = new CommandHandler(chestTracker); + playerNameColorManager = new PlayerNameColorManager(); + inventoryPanelOverlay = new InventoryPanelOverlay(ChestSyncManager.getInstance(), chestHighlighter, apiClient); // Initialize entity tracker (registers keybind) entityTracker.initialize(); @@ -123,12 +128,7 @@ public void onInitializeClient() { // Register client tick event ClientTickEvents.END_CLIENT_TICK.register(this::onClientTick); - // Register shutdown hook to close database properly - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - if (databaseManager != null) { - databaseManager.close(); - } - })); + // Database removed - no shutdown hook needed } private void onScreenOpen(Minecraft client, Screen screen, int scaledWidth, int scaledHeight) { diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/PlayerNameColorManager.java b/src/client/java/com/BookKeeper/InventoryNetwork/PlayerNameColorManager.java index 7725be2..9c762f1 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/PlayerNameColorManager.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/PlayerNameColorManager.java @@ -6,52 +6,16 @@ import net.minecraft.world.entity.player.Player; /** - * Colors player name tags based on whitelist/blacklist stored in the DatabaseManager. - * - Blacklisted: red - * - Whitelisted: blue - * - Otherwise: restore default name (clear custom name) + * Player name color management - deprecated (database removed). + * Whitelist/blacklist feature removed in favor of server-side management. */ public class PlayerNameColorManager { - private final DatabaseManager databaseManager; - public PlayerNameColorManager(DatabaseManager databaseManager) { - this.databaseManager = databaseManager; + public PlayerNameColorManager() { + // Database removed - feature deprecated } public void tick(Minecraft client) { - if (client.player == null || client.level == null) return; - - for (Player p : client.level.players()) { - // Skip local player - if (p == client.player) continue; - - // Use UUID when possible for reliable matching, fall back to display name - String uuid = p.getStringUUID(); - String cleanName = stripFormatting(p.getName().getString()); - - try { - boolean black = databaseManager.isBlacklistedByUuid(uuid) || databaseManager.isBlacklisted(cleanName); - boolean white = databaseManager.isWhitelistedByUuid(uuid) || databaseManager.isWhitelisted(cleanName); - - if (black && !white) { - p.setCustomName(Component.literal(cleanName).withStyle(ChatFormatting.RED)); - p.setCustomNameVisible(true); - } else if (white && !black) { - p.setCustomName(Component.literal(cleanName).withStyle(ChatFormatting.BLUE)); - p.setCustomNameVisible(true); - } else { - // If both or neither, do not override server/team coloring; clear custom name - p.setCustomName(null); - p.setCustomNameVisible(false); - } - } catch (Exception e) { - InventoryNetworkMod.LOGGER.warn("Failed to update name tag color for {} ({})", cleanName, uuid, e); - } - } - } - - private String stripFormatting(String s) { - if (s == null) return ""; - return s.replaceAll("§.", ""); + // No-op: whitelist/blacklist feature removed } } diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/WebSocketManager.java b/src/client/java/com/BookKeeper/InventoryNetwork/WebSocketManager.java index 19addf8..14dc00e 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/WebSocketManager.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/WebSocketManager.java @@ -1,5 +1,6 @@ package com.BookKeeper.InventoryNetwork; +import com.BookKeeper.InventoryNetwork.api.SchematicSyncApiImpl; import com.google.gson.Gson; import com.google.gson.JsonObject; import net.minecraft.client.Minecraft; @@ -30,6 +31,9 @@ public class WebSocketManager { private final Gson gson = new Gson(); + // SchematicSync API for notifying SolomonMatica of load requests + private SchematicSyncApiImpl schematicSyncApi; + private WebSocketManager() { // OkHttp client with WebSocket support this.client = new OkHttpClient.Builder() @@ -86,6 +90,10 @@ public synchronized void disconnect() { } isConnected = false; currentToken = null; + + // Clear ChestSync data on disconnect + ChestSyncManager.getInstance().clear(); + LOGGER.info("WebSocket disconnected"); } @@ -117,6 +125,54 @@ public boolean isConnected() { return isConnected; } + /** + * Get current JWT token for authenticated API calls. + * Used by other components (e.g., ChestTracker) to make authenticated requests. + * + * @return JWT token, or null if not connected + */ + public String getJwtToken() { + return currentToken; + } + + /** + * Set the SchematicSyncApi implementation for handling load_schematic messages. + * + * @param api The SchematicSyncApiImpl instance + */ + public void setSchematicSyncApi(SchematicSyncApiImpl api) { + this.schematicSyncApi = api; + } + + /** + * Send acknowledgment for a load_schematic request. + * + * @param requestId Request ID from the load_schematic message + * @param success Whether the operation was successful + * @param error Error message if failed, or null + */ + public void sendLoadSchematicAck(String requestId, boolean success, String error) { + if (!isConnected || webSocket == null) { + LOGGER.warn("Cannot send load_schematic_ack: not connected"); + return; + } + + try { + JsonObject ack = new JsonObject(); + ack.addProperty("type", "load_schematic_ack"); + ack.addProperty("request_id", requestId); + ack.addProperty("success", success); + if (error != null) { + ack.addProperty("error", error); + } + + webSocket.send(gson.toJson(ack)); + LOGGER.debug("Sent load_schematic_ack: requestId={}, success={}", requestId, success); + } catch (Exception e) { + LOGGER.error("Failed to send load_schematic_ack", e); + } + } + /** * Send pong response to server ping. */ @@ -159,6 +215,15 @@ private void handleMessage(String jsonText) { LOGGER.info("WebSocket authenticated: user_id={}, username={}, structure={}", userId, username, structureId); break; + case "chest_full_state": + handleChestFullState(json); + break; + case "chest_update": + handleChestUpdate(json); + break; + case "load_schematic": + handleLoadSchematic(json); + break; default: LOGGER.warn("Unknown message type: {}", type); } @@ -167,6 +232,82 @@ private void handleMessage(String jsonText) { } } + /** + * Handle full chest state message received on connection. + * Replaces all local chest data with the complete state from server. + */ + private void handleChestFullState(JsonObject json) { + try { + ChestSyncManager.getInstance().handleFullState(json); + + // Display notification in chat + if (json.has("summary")) { + JsonObject summary = json.getAsJsonObject("summary"); + int totalChests = summary.get("total_chests").getAsInt(); + displayChatMessage( + String.format("§6[ChestSync]§r Synchronized %d chests", totalChests), + true + ); + } + } catch (Exception e) { + LOGGER.error("Failed to handle chest full state", e); + } + } + + /** + * Handle incremental chest update message. + * Updates a single chest in the local cache when another player opens it. + */ + private void handleChestUpdate(JsonObject json) { + try { + ChestSyncManager.getInstance().handleChestUpdate(json); + + // Optionally display notification (can be toggled by config later) + if (json.has("chest")) { + JsonObject chest = json.getAsJsonObject("chest"); + int x = chest.get("x").getAsInt(); + int y = chest.get("y").getAsInt(); + int z = chest.get("z").getAsInt(); + + LOGGER.debug("Chest updated at ({}, {}, {}) from other player", x, y, z); + } + } catch (Exception e) { + LOGGER.error("Failed to handle chest update", e); + } + } + + /** + * Handle load_schematic message from server. + * Notifies SolomonMatica (via SchematicSyncApi) to load a schematic at specific coordinates. + */ + private void handleLoadSchematic(JsonObject json) { + try { + String schematicId = json.get("schematic_id").getAsString(); + int x = json.get("x").getAsInt(); + int y = json.get("y").getAsInt(); + int z = json.get("z").getAsInt(); + String requestId = json.get("request_id").getAsString(); + + LOGGER.info("Received load_schematic request: schematic={}, pos=({}, {}, {}), requestId={}", + schematicId, x, y, z, requestId); + + if (schematicSyncApi != null) { + // Execute on Minecraft main thread to ensure thread safety + Minecraft.getInstance().execute(() -> { + schematicSyncApi.notifyLoadSchematicCallbacks(schematicId, x, y, z, requestId); + }); + } else { + LOGGER.warn("SchematicSyncApi not initialized - cannot handle load_schematic request"); + sendLoadSchematicAck(requestId, false, "SchematicSyncApi not initialized"); + } + } catch (Exception e) { + LOGGER.error("Failed to handle load_schematic message", e); + if (json.has("request_id")) { + sendLoadSchematicAck(json.get("request_id").getAsString(), false, e.getMessage()); + } + } + } + /** * Handle broadcast message from server. * Formats with [SERVER] prefix and displays in Minecraft chat. diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApi.java b/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApi.java new file mode 100644 index 0000000..e9b5276 --- /dev/null +++ b/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApi.java @@ -0,0 +1,98 @@ +package com.BookKeeper.InventoryNetwork.api; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +/** + * API interface for schematic synchronization between SolomonMatica and SolomonInvNetMod. + * This allows SolomonMatica to use SolomonInvNetMod's WebSocket connection for schematic operations. + */ +public interface SchematicSyncApi { + + /** + * Check if WebSocket is currently connected to backend. + * + * @return true if connected, false otherwise + */ + boolean isConnected(); + + /** + * Get the current JWT token for authenticated API calls. + * + * @return JWT token, or null if not authenticated + */ + String getJwtToken(); + + /** + * Get the backend API base URL. + * + * @return API base URL (e.g., "http://localhost:8000") + */ + String getApiBaseUrl(); + + /** + * Upload a schematic file to the backend. + * + * @param path Path to the schematic file (.litematic) + * @param name Display name for the schematic + * @return CompletableFuture resolving to schematic ID on success, or null on failure + */ + CompletableFuture uploadSchematic(Path path, String name); + + /** + * Download a schematic file from the backend. + * + * @param id Schematic ID to download + * @param target Target path to save the downloaded file + * @return CompletableFuture resolving to true on success, false on failure + */ + CompletableFuture downloadSchematic(String id, Path target); + + /** + * Send KDTreeSplitter split results to the backend. + * + * @param schematicId Schematic ID the results belong to + * @param jsonResults JSON string containing split results (leaf bounds, metrics, materials) + * @return CompletableFuture resolving to true on success, false on failure + */ + CompletableFuture sendSplitResults(int schematicId, String jsonResults); + + /** + * Register a callback to be notified when the server requests loading a schematic. + * + * @param callback Callback to invoke when load_schematic message is received + */ + void registerLoadSchematicCallback(LoadSchematicCallback callback); + + /** + * Unregister a previously registered callback. + * + * @param callback Callback to remove + */ + void unregisterLoadSchematicCallback(LoadSchematicCallback callback); + + /** + * Send acknowledgment for a load_schematic request. + * + * @param requestId Request ID from the load_schematic message + * @param success Whether the schematic was loaded successfully + * @param error Error message if unsuccessful, or null + */ + void sendLoadSchematicAck(String requestId, boolean success, String error); + + /** + * Callback interface for server-initiated schematic load requests. + */ + interface LoadSchematicCallback { + /** + * Called when the server requests loading a schematic at specific coordinates. + * + * @param schematicId ID of the schematic to load + * @param x X coordinate to place the schematic + * @param y Y coordinate to place the schematic + * @param z Z coordinate to place the schematic + * @param requestId Unique request ID for acknowledgment + */ + void onLoadSchematic(String schematicId, int x, int y, int z, String requestId); + } +} diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApiImpl.java b/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApiImpl.java new file mode 100644 index 0000000..0dad349 --- /dev/null +++ b/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApiImpl.java @@ -0,0 +1,159 @@ +package com.BookKeeper.InventoryNetwork.api; + +import com.BookKeeper.InventoryNetwork.ApiClient; +import com.BookKeeper.InventoryNetwork.WebSocketManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Implementation of SchematicSyncApi that bridges SolomonMatica to the backend + * using the existing ApiClient and WebSocketManager. + */ +public class SchematicSyncApiImpl implements SchematicSyncApi { + private static final Logger LOGGER = LoggerFactory.getLogger("SolomonInvNet-SchematicSync"); + + private final ApiClient apiClient; + private final WebSocketManager webSocketManager; + private final List callbacks = new CopyOnWriteArrayList<>(); + + public SchematicSyncApiImpl(ApiClient apiClient, WebSocketManager webSocketManager) { + this.apiClient = apiClient; + this.webSocketManager = webSocketManager; + } + + @Override + public boolean isConnected() { + return webSocketManager.isConnected(); + } + + @Override + public String getJwtToken() { + return webSocketManager.getJwtToken(); + } + + @Override + public String getApiBaseUrl() { + return apiClient.getBaseUrl(); + } + + @Override + public CompletableFuture uploadSchematic(Path path, String name) { + return CompletableFuture.supplyAsync(() -> { + String token = getJwtToken(); + if (token == null) { + LOGGER.error("Cannot upload schematic: not authenticated"); + return null; + } + + try { + String schematicId = apiClient.uploadSchematic(token, path, name); + if (schematicId != null) { + LOGGER.info("Successfully uploaded schematic '{}' with ID: {}", name, schematicId); + } + return schematicId; + } catch (Exception e) { + LOGGER.error("Failed to upload schematic '{}'", name, e); + return null; + } + }); + } + + @Override + public CompletableFuture downloadSchematic(String id, Path target) { + return CompletableFuture.supplyAsync(() -> { + String token = getJwtToken(); + if (token == null) { + LOGGER.error("Cannot download schematic: not authenticated"); + return false; + } + + try { + boolean success = apiClient.downloadSchematic(token, id, target); + if (success) { + LOGGER.info("Successfully downloaded schematic {} to {}", id, target); + } + return success; + } catch (Exception e) { + LOGGER.error("Failed to download schematic {}", id, e); + return false; + } + }); + } + + @Override + public CompletableFuture sendSplitResults(int schematicId, String jsonResults) { + return CompletableFuture.supplyAsync(() -> { + String token = getJwtToken(); + if (token == null) { + LOGGER.error("Cannot send split results: not authenticated"); + return false; + } + + try { + boolean success = apiClient.postSplitResults(token, schematicId, jsonResults); + if (success) { + LOGGER.info("Successfully sent split results for schematic {}", schematicId); + } + return success; + } catch (Exception e) { + LOGGER.error("Failed to send split results for schematic {}", schematicId, e); + return false; + } + }); + } + + @Override + public void registerLoadSchematicCallback(LoadSchematicCallback callback) { + if (callback != null && !callbacks.contains(callback)) { + callbacks.add(callback); + LOGGER.debug("Registered LoadSchematicCallback: {}", callback.getClass().getName()); + } + } + + @Override + public void unregisterLoadSchematicCallback(LoadSchematicCallback callback) { + if (callback != null) { + callbacks.remove(callback); + LOGGER.debug("Unregistered LoadSchematicCallback: {}", callback.getClass().getName()); + } + } + + @Override + public void sendLoadSchematicAck(String requestId, boolean success, String error) { + webSocketManager.sendLoadSchematicAck(requestId, success, error); + } + + /** + * Called by WebSocketManager when a load_schematic message is received. + * Notifies all registered callbacks. + * + * @param schematicId Schematic ID to load + * @param x X coordinate + * @param y Y coordinate + * @param z Z coordinate + * @param requestId Request ID for acknowledgment + */ + public void notifyLoadSchematicCallbacks(String schematicId, int x, int y, int z, String requestId) { + LOGGER.info("Received load_schematic request: schematic={}, pos=({}, {}, {}), requestId={}", + schematicId, x, y, z, requestId); + + if (callbacks.isEmpty()) { + LOGGER.warn("No LoadSchematicCallback registered - schematic load request will be ignored"); + sendLoadSchematicAck(requestId, false, "No schematic handler registered"); + return; + } + + for (LoadSchematicCallback callback : callbacks) { + try { + callback.onLoadSchematic(schematicId, x, y, z, requestId); + } catch (Exception e) { + LOGGER.error("Error in LoadSchematicCallback: {}", callback.getClass().getName(), e); + } + } + } +} diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApiProvider.java b/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApiProvider.java new file mode 100644 index 0000000..230b9b3 --- /dev/null +++ b/src/client/java/com/BookKeeper/InventoryNetwork/api/SchematicSyncApiProvider.java @@ -0,0 +1,44 @@ +package com.BookKeeper.InventoryNetwork.api; + +/** + * Provider class for SchematicSyncApi. + * This class is used by SolomonMatica (via reflection) to obtain the API instance. + * + * Usage from SolomonMatica: + *
+ * Class providerClass = Class.forName("com.BookKeeper.InventoryNetwork.api.SchematicSyncApiProvider");
+ * Method getApiMethod = providerClass.getMethod("getApi");
+ * Object api = getApiMethod.invoke(null);
+ * 
+ */ +public class SchematicSyncApiProvider { + private static SchematicSyncApi instance; + + /** + * Get the SchematicSyncApi instance. + * + * @return The API instance, or null if not initialized + */ + public static SchematicSyncApi getApi() { + return instance; + } + + /** + * Set the SchematicSyncApi instance. + * Called during mod initialization. + * + * @param api The API implementation + */ + public static void setApi(SchematicSyncApi api) { + instance = api; + } + + /** + * Check if the API is available. + * + * @return true if API is initialized, false otherwise + */ + public static boolean isAvailable() { + return instance != null; + } +} diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/ui/InventoryPanelOverlay.java b/src/client/java/com/BookKeeper/InventoryNetwork/ui/InventoryPanelOverlay.java index bd88621..0019473 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/ui/InventoryPanelOverlay.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/ui/InventoryPanelOverlay.java @@ -1,17 +1,21 @@ package com.BookKeeper.InventoryNetwork.ui; +import com.BookKeeper.InventoryNetwork.ApiClient; import com.BookKeeper.InventoryNetwork.ChestHighlighter; -import com.BookKeeper.InventoryNetwork.DatabaseManager; +import com.BookKeeper.InventoryNetwork.ChestSyncManager; +import com.BookKeeper.InventoryNetwork.WebSocketManager; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; import net.minecraft.world.item.ItemStack; /** * Main overlay controller for the inventory panel UI. * Manages the grid, search box, dimension toggle, and coordinates rendering. + * Now uses ChestSyncManager as the single source of truth (server data). */ public class InventoryPanelOverlay { // JEI-style layout constants @@ -23,9 +27,12 @@ public class InventoryPanelOverlay { private static final int GRID_COLUMNS = 6; private static final int GRID_ROWS = 9; private static final int NAVIGATION_HEIGHT = 20; // Navigation bar height (like JEI) + private static final int REFRESH_BUTTON_WIDTH = 50; + private static final int REFRESH_BUTTON_HEIGHT = 14; - private final DatabaseManager databaseManager; + private final ChestSyncManager chestSyncManager; private final ChestHighlighter chestHighlighter; + private final ApiClient apiClient; private ItemGridWidget itemGrid; private SearchBoxWidget searchBox; @@ -35,11 +42,26 @@ public class InventoryPanelOverlay { private int panelWidth; private int panelHeight; + // Refresh button state + private int refreshButtonX; + private int refreshButtonY; + private boolean isRefreshing = false; + private long lastRefreshTime = 0; + private static final long REFRESH_COOLDOWN_MS = 2000; // 2 seconds cooldown + private boolean initialized = false; - public InventoryPanelOverlay(DatabaseManager databaseManager, ChestHighlighter chestHighlighter) { - this.databaseManager = databaseManager; + public InventoryPanelOverlay(ChestSyncManager chestSyncManager, ChestHighlighter chestHighlighter, ApiClient apiClient) { + this.chestSyncManager = chestSyncManager; this.chestHighlighter = chestHighlighter; + this.apiClient = apiClient; + + // Register listener for automatic UI refresh when server data changes + chestSyncManager.addUpdateListener(update -> { + if (initialized) { + loadItems(); // Reload items when server data updates + } + }); } /** @@ -78,6 +100,10 @@ public void init(Screen screen) { int gridY = searchBoxY + SEARCH_BOX_HEIGHT + INNER_PADDING; itemGrid = new ItemGridWidget(gridX, gridY, GRID_COLUMNS, GRID_ROWS); + // Position refresh button (top-right corner, next to search box) + refreshButtonX = panelX + panelWidth - BORDER_PADDING - REFRESH_BUTTON_WIDTH; + refreshButtonY = panelY + BORDER_PADDING + 3; // Align with search box + // Load initial items loadItems(); @@ -85,8 +111,8 @@ public void init(Screen screen) { } /** - * Loads items from the database into the grid. - * Always loads from ALL dimensions. + * Loads items from ChestSyncManager (server as source of truth). + * Aggregates data from all synced chests across all players. */ private void loadItems() { if (itemGrid == null) return; @@ -94,8 +120,8 @@ private void loadItems() { Minecraft mc = Minecraft.getInstance(); if (mc.player == null) return; - // Always load from all dimensions (dimension = null) - itemGrid.loadItems(databaseManager, null); + // Load from ChestSyncManager (server data) + itemGrid.loadItems(chestSyncManager); updateSearchResults(); } @@ -131,6 +157,9 @@ public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partia // Render search box searchBox.render(guiGraphics, mouseX, mouseY, partialTick); + // Render refresh button + renderRefreshButton(guiGraphics, mouseX, mouseY); + // Render item grid itemGrid.render(guiGraphics, mouseX, mouseY); @@ -174,6 +203,51 @@ private void renderBackground(GuiGraphics guiGraphics) { guiGraphics.fill(panelX + 1, panelY + 1, panelX + 2, panelY + panelHeight - 1, innerBorderColor); // Left inner } + /** + * Renders the refresh button for manually syncing from server. + */ + private void renderRefreshButton(GuiGraphics guiGraphics, int mouseX, int mouseY) { + Font font = Minecraft.getInstance().font; + + // Check if mouse is hovering + boolean isHovered = mouseX >= refreshButtonX && mouseX < refreshButtonX + REFRESH_BUTTON_WIDTH && + mouseY >= refreshButtonY && mouseY < refreshButtonY + REFRESH_BUTTON_HEIGHT; + + // Button background color + int bgColor; + if (isRefreshing) { + bgColor = 0xFF555555; // Gray when refreshing + } else if (isHovered) { + bgColor = 0xFF4A90E2; // Blue when hovered + } else { + bgColor = 0xFF3A3A3A; // Dark gray normally + } + + // Draw button background + guiGraphics.fill(refreshButtonX, refreshButtonY, + refreshButtonX + REFRESH_BUTTON_WIDTH, + refreshButtonY + REFRESH_BUTTON_HEIGHT, + bgColor); + + // Draw button border + int borderColor = isHovered ? 0xFFFFFFFF : 0xFF808080; + guiGraphics.fill(refreshButtonX, refreshButtonY, + refreshButtonX + REFRESH_BUTTON_WIDTH, refreshButtonY + 1, borderColor); // Top + guiGraphics.fill(refreshButtonX, refreshButtonY + REFRESH_BUTTON_HEIGHT - 1, + refreshButtonX + REFRESH_BUTTON_WIDTH, refreshButtonY + REFRESH_BUTTON_HEIGHT, borderColor); // Bottom + guiGraphics.fill(refreshButtonX, refreshButtonY, + refreshButtonX + 1, refreshButtonY + REFRESH_BUTTON_HEIGHT, borderColor); // Left + guiGraphics.fill(refreshButtonX + REFRESH_BUTTON_WIDTH - 1, refreshButtonY, + refreshButtonX + REFRESH_BUTTON_WIDTH, refreshButtonY + REFRESH_BUTTON_HEIGHT, borderColor); // Right + + // Button text + String buttonText = isRefreshing ? "..." : "Sync"; + int textX = refreshButtonX + (REFRESH_BUTTON_WIDTH - font.width(buttonText)) / 2; + int textY = refreshButtonY + (REFRESH_BUTTON_HEIGHT - font.lineHeight) / 2; + int textColor = isRefreshing ? 0xFF999999 : 0xFFFFFFFF; + guiGraphics.drawString(font, buttonText, textX, textY, textColor); + } + /** * Renders pagination controls - EXACT copy of JEI's PageNavigation.java:55-73 * Uses the same fill color and text rendering as JEI. @@ -226,6 +300,11 @@ private void renderPagination(GuiGraphics guiGraphics) { public boolean mouseClicked(double mouseX, double mouseY, int button) { if (!initialized) return false; + // Check refresh button click + if (handleRefreshButtonClick(mouseX, mouseY)) { + return true; + } + // Check search box click - just focus it if (searchBox.isMouseOver(mouseX, mouseY)) { searchBox.setFocused(true); @@ -249,6 +328,71 @@ public boolean mouseClicked(double mouseX, double mouseY, int button) { return false; } + /** + * Handles refresh button click - fetches latest data from server. + */ + private boolean handleRefreshButtonClick(double mouseX, double mouseY) { + // Check if click is within button bounds + if (mouseX >= refreshButtonX && mouseX < refreshButtonX + REFRESH_BUTTON_WIDTH && + mouseY >= refreshButtonY && mouseY < refreshButtonY + REFRESH_BUTTON_HEIGHT) { + + // Check cooldown + long currentTime = System.currentTimeMillis(); + if (currentTime - lastRefreshTime < REFRESH_COOLDOWN_MS) { + Minecraft.getInstance().player.displayClientMessage( + Component.literal("§c[Inventory Network] Please wait before refreshing again"), + true + ); + return true; + } + + // Check if already refreshing + if (isRefreshing) { + return true; + } + + // Get JWT token from WebSocket manager + String jwtToken = WebSocketManager.getInstance().getJwtToken(); + if (jwtToken == null || jwtToken.isEmpty()) { + Minecraft.getInstance().player.displayClientMessage( + Component.literal("§c[Inventory Network] Not authenticated. Please reconnect."), + true + ); + return true; + } + + // Start refresh + isRefreshing = true; + lastRefreshTime = currentTime; + + Minecraft.getInstance().player.displayClientMessage( + Component.literal("§6[Inventory Network] Syncing from server..."), + true + ); + + // Refresh from server (async) + chestSyncManager.refreshFromServer(apiClient, jwtToken).thenAccept(success -> { + isRefreshing = false; + if (success) { + Minecraft.getInstance().player.displayClientMessage( + Component.literal("§a[Inventory Network] Sync complete! (" + + chestSyncManager.getChestCount() + " chests)"), + true + ); + } else { + Minecraft.getInstance().player.displayClientMessage( + Component.literal("§c[Inventory Network] Sync failed. Check connection."), + true + ); + } + }); + + return true; + } + + return false; + } + /** * Handles pagination arrow clicks. * Matches the button areas from renderPagination(). diff --git a/src/client/java/com/BookKeeper/InventoryNetwork/ui/ItemGridWidget.java b/src/client/java/com/BookKeeper/InventoryNetwork/ui/ItemGridWidget.java index acf1952..4329e17 100644 --- a/src/client/java/com/BookKeeper/InventoryNetwork/ui/ItemGridWidget.java +++ b/src/client/java/com/BookKeeper/InventoryNetwork/ui/ItemGridWidget.java @@ -1,6 +1,7 @@ package com.BookKeeper.InventoryNetwork.ui; -import com.BookKeeper.InventoryNetwork.DatabaseManager; +import com.BookKeeper.InventoryNetwork.ChestSyncManager; +import com.google.gson.JsonObject; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.ResourceLocation; @@ -9,7 +10,9 @@ import net.minecraft.world.item.Items; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Grid widget that displays items from the database. @@ -56,20 +59,37 @@ private void createSlots() { } /** - * Loads items from the database. + * Loads items from ChestSyncManager (server as source of truth). + * Aggregates all items across all synced chests. */ - public void loadItems(DatabaseManager db, String dimension) { + public void loadItems(ChestSyncManager chestSync) { allItems.clear(); - List itemDataList = dimension == null - ? db.getAllUniqueItems() - : db.getAllUniqueItemsInDimension(dimension); + // Aggregate items from all chests + Map itemCounts = new HashMap<>(); - for (DatabaseManager.ItemData itemData : itemDataList) { - Item item = getItemFromId(itemData.itemId); + for (ChestSyncManager.ChestSnapshot chest : chestSync.getAllChests()) { + if (chest.items == null) continue; + + // Iterate through all slots in the chest + for (String slotKey : chest.items.keySet()) { + JsonObject itemObj = chest.items.getAsJsonObject(slotKey); + if (itemObj.has("id")) { + String itemId = itemObj.get("id").getAsString(); + int count = itemObj.has("count") ? itemObj.get("count").getAsInt() : 1; + + // Aggregate counts + itemCounts.put(itemId, itemCounts.getOrDefault(itemId, 0) + count); + } + } + } + + // Convert aggregated data to ItemStack list + for (Map.Entry entry : itemCounts.entrySet()) { + Item item = getItemFromId(entry.getKey()); if (item != null && item != Items.AIR) { ItemStack stack = new ItemStack(item); - stack.setCount(itemData.totalCount); + stack.setCount(entry.getValue()); allItems.add(stack); } } diff --git a/src/main/java/com/BookKeeper/InventoryNetwork/ApiClient.java b/src/main/java/com/BookKeeper/InventoryNetwork/ApiClient.java index 3f8ec2e..688d525 100644 --- a/src/main/java/com/BookKeeper/InventoryNetwork/ApiClient.java +++ b/src/main/java/com/BookKeeper/InventoryNetwork/ApiClient.java @@ -7,6 +7,8 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -28,6 +30,10 @@ public ApiClient(String baseUrl) { .build(); } + public String getBaseUrl() { + return baseUrl; + } + /** * Request a magic login link for the player. * Returns the magic URL that the player can click to login. @@ -161,6 +167,205 @@ public String exchangeMagicToken(String magicToken) { } } + /** + * Send chest data to backend for ChestSync feature. + * This enables real-time chest inventory synchronization across all clients. + * + * @param jwtToken JWT access token for authentication + * @param mcUuid Player UUID + * @param mcName Player username + * @param x Chest X coordinate + * @param y Chest Y coordinate + * @param z Chest Z coordinate + * @param containerData Chest contents as JsonObject (items JSON) + * @param signsData Signs data as JsonObject (optional) + * @return true if successful, false otherwise + */ + public boolean sendChestData(String jwtToken, UUID mcUuid, String mcName, + int x, int y, int z, + JsonObject containerData, JsonObject signsData) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("uuid", mcUuid.toString()); + requestBody.addProperty("username", mcName); + requestBody.addProperty("x", x); + requestBody.addProperty("y", y); + requestBody.addProperty("z", z); + requestBody.addProperty("event", "Container"); + requestBody.add("Container", containerData); + if (signsData != null) { + requestBody.add("Signs", signsData); + } + + RequestBody body = RequestBody.create(gson.toJson(requestBody), JSON); + Request request = new Request.Builder() + .url(baseUrl + "/api/mc/events/jwt") + .addHeader("Authorization", "Bearer " + jwtToken) + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.error("Failed to send chest data: HTTP {} at ({}, {}, {})", + response.code(), x, y, z); + return false; + } + + LOGGER.debug("Successfully sent chest data at ({}, {}, {})", x, y, z); + return true; + } catch (IOException e) { + LOGGER.error("Failed to send chest data at ({}, {}, {})", x, y, z, e); + return false; + } + } + + /** + * Fetch all chest data from the server. + * This is the REST API fallback when WebSocket is disconnected or data needs to be refreshed. + * Server is the single source of truth for chest data. + * + * @param jwtToken JWT authentication token + * @return JsonObject containing chest data, or null if failed + */ + public JsonObject fetchAllChests(String jwtToken) { + Request request = new Request.Builder() + .url(baseUrl + "/api/mc/chests") + .addHeader("Authorization", "Bearer " + jwtToken) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.error("Failed to fetch chest data: HTTP {}", response.code()); + return null; + } + + String responseBody = response.body().string(); + JsonObject data = gson.fromJson(responseBody, JsonObject.class); + LOGGER.info("Successfully fetched chest data from server"); + return data; + } catch (IOException e) { + LOGGER.error("Failed to fetch chest data from server", e); + return null; + } + } + + /** + * Upload a schematic file to the backend. + * Uses multipart/form-data for file upload. + * + * @param jwtToken JWT access token for authentication + * @param file Path to the schematic file + * @param name Display name for the schematic + * @return Schematic ID on success, or null on failure + */ + public String uploadSchematic(String jwtToken, Path file, String name) { + try { + String fileName = file.getFileName().toString(); + byte[] fileBytes = Files.readAllBytes(file); + + RequestBody fileBody = RequestBody.create(fileBytes, MediaType.parse("application/octet-stream")); + + MultipartBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", fileName, fileBody) + .addFormDataPart("name", name) + .build(); + + Request request = new Request.Builder() + .url(baseUrl + "/api/schematics/upload") + .addHeader("Authorization", "Bearer " + jwtToken) + .post(requestBody) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.error("Failed to upload schematic: HTTP {}", response.code()); + return null; + } + + String responseBody = response.body().string(); + JsonObject json = gson.fromJson(responseBody, JsonObject.class); + + if (json.has("id")) { + String schematicId = json.get("id").getAsString(); + LOGGER.info("Successfully uploaded schematic '{}' with ID: {}", name, schematicId); + return schematicId; + } + + LOGGER.error("No id in upload response. Response: {}", responseBody); + return null; + } + } catch (IOException e) { + LOGGER.error("Failed to upload schematic '{}'", name, e); + return null; + } + } + + /** + * Download a schematic file from the backend. + * + * @param jwtToken JWT access token for authentication + * @param id Schematic ID to download + * @param target Target path to save the downloaded file + * @return true on success, false on failure + */ + public boolean downloadSchematic(String jwtToken, String id, Path target) { + Request request = new Request.Builder() + .url(baseUrl + "/api/schematics/" + id + "/download") + .addHeader("Authorization", "Bearer " + jwtToken) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.error("Failed to download schematic {}: HTTP {}", id, response.code()); + return false; + } + + byte[] data = response.body().bytes(); + + // Ensure parent directories exist + Files.createDirectories(target.getParent()); + Files.write(target, data); + + LOGGER.info("Successfully downloaded schematic {} to {}", id, target); + return true; + } catch (IOException e) { + LOGGER.error("Failed to download schematic {}", id, e); + return false; + } + } + + /** + * Post split results from KDTreeSplitter to the backend. + * + * @param jwtToken JWT access token for authentication + * @param schematicId Schematic ID the results belong to + * @param jsonResults JSON string containing split results + * @return true on success, false on failure + */ + public boolean postSplitResults(String jwtToken, int schematicId, String jsonResults) { + RequestBody body = RequestBody.create(jsonResults, JSON); + Request request = new Request.Builder() + .url(baseUrl + "/api/schematics/" + schematicId + "/split-results") + .addHeader("Authorization", "Bearer " + jwtToken) + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.error("Failed to post split results for schematic {}: HTTP {}", schematicId, response.code()); + return false; + } + + LOGGER.info("Successfully posted split results for schematic {}", schematicId); + return true; + } catch (IOException e) { + LOGGER.error("Failed to post split results for schematic {}", schematicId, e); + return false; + } + } + // Response classes public static class MagicLinkResponse { public String token; diff --git a/src/main/java/com/BookKeeper/InventoryNetwork/DatabaseManager.java b/src/main/java/com/BookKeeper/InventoryNetwork/DatabaseManager.java deleted file mode 100644 index b391721..0000000 --- a/src/main/java/com/BookKeeper/InventoryNetwork/DatabaseManager.java +++ /dev/null @@ -1,645 +0,0 @@ -package com.BookKeeper.InventoryNetwork; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.sql.*; -import java.time.LocalDateTime; - -public class DatabaseManager { - private static final Logger LOGGER = LoggerFactory.getLogger("InventoryNetwork-DB"); - private static DatabaseManager instance; - - private Connection connection; - private final String dbPath; - - // Debug logs toggle (default: false = disabled) - private boolean debugLogsEnabled = false; - - private DatabaseManager(String dbPath) { - this.dbPath = dbPath; - initializeDatabase(); - } - - public static DatabaseManager getInstance(String dbPath) { - if (instance == null) { - instance = new DatabaseManager(dbPath); - } - return instance; - } - - private void initializeDatabase() { - try { - // Create database directory if it doesn't exist - File dbFile = new File(dbPath).getParentFile(); - if (dbFile != null && !dbFile.exists()) { - dbFile.mkdirs(); - } - - // Connect to H2 database (creates it if it doesn't exist) - String jdbcUrl = "jdbc:h2:" + dbPath + ";AUTO_SERVER=TRUE"; - connection = DriverManager.getConnection(jdbcUrl, "sa", ""); - - LOGGER.info("Connected to H2 database at: {}", dbPath); - - // Create table if it doesn't exist - createTablesIfNotExist(); - - } catch (SQLException e) { - LOGGER.error("Failed to initialize database", e); - } - } - - private void createTablesIfNotExist() throws SQLException { - String createChestTableSQL = """ - CREATE TABLE IF NOT EXISTS chest_inventory ( - x INT NOT NULL, - y INT NOT NULL, - z INT NOT NULL, - dimension VARCHAR(255) NOT NULL, - contents TEXT, - last_updated TIMESTAMP, - PRIMARY KEY (x, y, z, dimension) - ) - """; - - String createWhitelistTableSQL = """ - CREATE TABLE IF NOT EXISTS player_whitelist ( - player_name VARCHAR(255) NOT NULL, - player_uuid VARCHAR(36), - added_at TIMESTAMP, - PRIMARY KEY (player_name) - ) - """; - - String createBlacklistTableSQL = """ - CREATE TABLE IF NOT EXISTS player_blacklist ( - player_name VARCHAR(255) NOT NULL, - player_uuid VARCHAR(36), - added_at TIMESTAMP, - PRIMARY KEY (player_name) - ) - """; - - try (Statement stmt = connection.createStatement()) { - stmt.execute(createChestTableSQL); - stmt.execute(createWhitelistTableSQL); - stmt.execute(createBlacklistTableSQL); - // Ensure existing databases get the player_uuid column if they lack it - try { - stmt.execute("ALTER TABLE player_whitelist ADD COLUMN IF NOT EXISTS player_uuid VARCHAR(36);"); - } catch (SQLException ignored) { - } - try { - stmt.execute("ALTER TABLE player_blacklist ADD COLUMN IF NOT EXISTS player_uuid VARCHAR(36);"); - } catch (SQLException ignored) { - } - LOGGER.info("Database schema initialized"); - } - } - - /** - * Saves or updates chest data in the database. - * If a chest at the same coordinates already exists, it will be updated. - * - * @param x X coordinate - * @param y Y coordinate - * @param z Z coordinate - * @param dimension Dimension identifier (e.g., "minecraft:overworld") - * @param contents JSON or text representation of chest contents - */ - public void saveChestData(int x, int y, int z, String dimension, String contents) { - String mergeSQL = """ - MERGE INTO chest_inventory (x, y, z, dimension, contents, last_updated) - KEY (x, y, z, dimension) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement pstmt = connection.prepareStatement(mergeSQL)) { - pstmt.setInt(1, x); - pstmt.setInt(2, y); - pstmt.setInt(3, z); - pstmt.setString(4, dimension); - pstmt.setString(5, contents); - pstmt.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now())); - - int rowsAffected = pstmt.executeUpdate(); - LOGGER.info("Saved chest data at [{}, {}, {}] in {} - Rows affected: {}", - x, y, z, dimension, rowsAffected); - - } catch (SQLException e) { - LOGGER.error("Failed to save chest data at [{}, {}, {}]", x, y, z, e); - } - } - - /** - * Retrieves chest data from the database. - * - * @param x X coordinate - * @param y Y coordinate - * @param z Z coordinate - * @param dimension Dimension identifier - * @return Contents string or null if not found - */ - public String getChestData(int x, int y, int z, String dimension) { - String selectSQL = """ - SELECT contents FROM chest_inventory - WHERE x = ? AND y = ? AND z = ? AND dimension = ? - """; - - try (PreparedStatement pstmt = connection.prepareStatement(selectSQL)) { - pstmt.setInt(1, x); - pstmt.setInt(2, y); - pstmt.setInt(3, z); - pstmt.setString(4, dimension); - - try (ResultSet rs = pstmt.executeQuery()) { - if (rs.next()) { - return rs.getString("contents"); - } - } - - } catch (SQLException e) { - LOGGER.error("Failed to retrieve chest data at [{}, {}, {}]", x, y, z, e); - } - - return null; - } - - /** - * Returns the total number of chests stored in the database. - */ - public int getTotalChestCount() { - String countSQL = "SELECT COUNT(*) as total FROM chest_inventory"; - - try (Statement stmt = connection.createStatement(); - ResultSet rs = stmt.executeQuery(countSQL)) { - if (rs.next()) { - return rs.getInt("total"); - } - } catch (SQLException e) { - LOGGER.error("Failed to count chests", e); - } - - return 0; - } - - /** - * Finds all chests within a certain radius that contain a specific item. - * - * @param centerX Center X coordinate - * @param centerY Center Y coordinate - * @param centerZ Center Z coordinate - * @param radius Search radius - * @param dimension Dimension identifier - * @param itemName Item display name to search for - * @return List of chest positions [x, y, z] that contain the item - */ - public java.util.List findChestsWithItem(int centerX, int centerY, int centerZ, - int radius, String dimension, String itemName) { - java.util.List results = new java.util.ArrayList<>(); - - String searchSQL = """ - SELECT x, y, z, contents FROM chest_inventory - WHERE dimension = ? - AND x BETWEEN ? AND ? - AND y BETWEEN ? AND ? - AND z BETWEEN ? AND ? - """; - - try (PreparedStatement pstmt = connection.prepareStatement(searchSQL)) { - pstmt.setString(1, dimension); - pstmt.setInt(2, centerX - radius); - pstmt.setInt(3, centerX + radius); - pstmt.setInt(4, centerY - radius); - pstmt.setInt(5, centerY + radius); - pstmt.setInt(6, centerZ - radius); - pstmt.setInt(7, centerZ + radius); - - try (ResultSet rs = pstmt.executeQuery()) { - while (rs.next()) { - String contents = rs.getString("contents"); - - // Check if item name appears in contents - if (contents != null && !contents.equals("EMPTY") && - contents.toLowerCase().contains(itemName.toLowerCase())) { - int[] pos = new int[3]; - pos[0] = rs.getInt("x"); - pos[1] = rs.getInt("y"); - pos[2] = rs.getInt("z"); - results.add(pos); - } - } - } - - } catch (SQLException e) { - LOGGER.error("Failed to search for chests with item: {}", itemName, e); - } - - return results; - } - - /** - * Clears all data from the database. - */ - public void clearDatabase() { - String deleteSQL = "DELETE FROM chest_inventory"; - - try (Statement stmt = connection.createStatement()) { - int rowsDeleted = stmt.executeUpdate(deleteSQL); - LOGGER.info("Database cleared. Deleted {} rows", rowsDeleted); - } catch (SQLException e) { - LOGGER.error("Failed to clear database", e); - } - } - - /** - * Adds a player to the whitelist (or updates timestamp if exists). - */ - public boolean addToWhitelist(String playerName) { - String sql = """ - MERGE INTO player_whitelist (player_name, added_at) - KEY (player_name) - VALUES (?, ?) - """; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); // store lowercase to avoid case mismatches - pstmt.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now())); - int rows = pstmt.executeUpdate(); - LOGGER.info("Added/updated whitelist player: {} (rows={})", playerName.toLowerCase(), rows); - return true; - } catch (SQLException e) { - LOGGER.error("Failed to add to whitelist: {}", playerName, e); - return false; - } - } - - /** - * Adds a player to the whitelist specifying UUID when available. - */ - public boolean addToWhitelist(String playerName, String playerUuid) { - String sql = """ - MERGE INTO player_whitelist (player_name, player_uuid, added_at) - KEY (player_name) - VALUES (?, ?, ?) - """; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); - pstmt.setString(2, playerUuid != null ? playerUuid : null); - pstmt.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now())); - int rows = pstmt.executeUpdate(); - LOGGER.info("Added/updated whitelist player: {} uuid={} (rows={})", playerName.toLowerCase(), playerUuid, rows); - return true; - } catch (SQLException e) { - LOGGER.error("Failed to add to whitelist: {}", playerName, e); - return false; - } - } - - /** - * Removes a player from the whitelist. - */ - public boolean removeFromWhitelist(String playerName) { - String sql = "DELETE FROM player_whitelist WHERE player_name = ?"; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); - int rows = pstmt.executeUpdate(); - LOGGER.info("Removed whitelist player: {} (rows={})", playerName.toLowerCase(), rows); - return rows > 0; - } catch (SQLException e) { - LOGGER.error("Failed to remove from whitelist: {}", playerName, e); - return false; - } - } - - /** - * Adds a player to the blacklist (or updates timestamp if exists). - */ - public boolean addToBlacklist(String playerName) { - String sql = """ - MERGE INTO player_blacklist (player_name, added_at) - KEY (player_name) - VALUES (?, ?) - """; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); // store lowercase - pstmt.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now())); - int rows = pstmt.executeUpdate(); - LOGGER.info("Added/updated blacklist player: {} (rows={})", playerName.toLowerCase(), rows); - return true; - } catch (SQLException e) { - LOGGER.error("Failed to add to blacklist: {}", playerName, e); - return false; - } - } - - /** - * Adds a player to the blacklist specifying UUID when available. - */ - public boolean addToBlacklist(String playerName, String playerUuid) { - String sql = """ - MERGE INTO player_blacklist (player_name, player_uuid, added_at) - KEY (player_name) - VALUES (?, ?, ?) - """; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); - pstmt.setString(2, playerUuid != null ? playerUuid : null); - pstmt.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now())); - int rows = pstmt.executeUpdate(); - LOGGER.info("Added/updated blacklist player: {} uuid={} (rows={})", playerName.toLowerCase(), playerUuid, rows); - return true; - } catch (SQLException e) { - LOGGER.error("Failed to add to blacklist: {}", playerName, e); - return false; - } - } - - /** - * Removes a player from the blacklist. - */ - public boolean removeFromBlacklist(String playerName) { - String sql = "DELETE FROM player_blacklist WHERE player_name = ?"; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); - int rows = pstmt.executeUpdate(); - LOGGER.info("Removed blacklist player: {} (rows={})", playerName.toLowerCase(), rows); - return rows > 0; - } catch (SQLException e) { - LOGGER.error("Failed to remove from blacklist: {}", playerName, e); - return false; - } - } - - /** - * Checks if a player is whitelisted by name. - */ - public boolean isWhitelisted(String playerName) { - String sql = "SELECT COUNT(*) as cnt FROM player_whitelist WHERE player_name = ?"; - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); - try (ResultSet rs = pstmt.executeQuery()) { - if (rs.next()) return rs.getInt("cnt") > 0; - } - } catch (SQLException e) { - LOGGER.error("Failed to check whitelist for {}", playerName, e); - } - return false; - } - - /** - * Checks if a player is whitelisted by UUID. - */ - public boolean isWhitelistedByUuid(String playerUuid) { - if (playerUuid == null) return false; - String sql = "SELECT COUNT(*) as cnt FROM player_whitelist WHERE player_uuid = ?"; - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerUuid); - try (ResultSet rs = pstmt.executeQuery()) { - if (rs.next()) return rs.getInt("cnt") > 0; - } - } catch (SQLException e) { - LOGGER.error("Failed to check whitelist for uuid {}", playerUuid, e); - } - return false; - } - - /** - * Checks if a player is blacklisted by name. - */ - public boolean isBlacklisted(String playerName) { - String sql = "SELECT COUNT(*) as cnt FROM player_blacklist WHERE player_name = ?"; - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerName.toLowerCase()); - try (ResultSet rs = pstmt.executeQuery()) { - if (rs.next()) return rs.getInt("cnt") > 0; - } - } catch (SQLException e) { - LOGGER.error("Failed to check blacklist for {}", playerName, e); - } - return false; - } - - /** - * Checks if a player is blacklisted by UUID. - */ - public boolean isBlacklistedByUuid(String playerUuid) { - if (playerUuid == null) return false; - String sql = "SELECT COUNT(*) as cnt FROM player_blacklist WHERE player_uuid = ?"; - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - pstmt.setString(1, playerUuid); - try (ResultSet rs = pstmt.executeQuery()) { - if (rs.next()) return rs.getInt("cnt") > 0; - } - } catch (SQLException e) { - LOGGER.error("Failed to check blacklist for uuid {}", playerUuid, e); - } - return false; - } - - /** - * Returns all whitelisted player names. - */ - public java.util.List getAllWhitelisted() { - java.util.List results = new java.util.ArrayList<>(); - String sql = "SELECT player_name FROM player_whitelist ORDER BY player_name"; - try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { - while (rs.next()) { - results.add(rs.getString("player_name")); - } - } catch (SQLException e) { - LOGGER.error("Failed to retrieve whitelist", e); - } - return results; - } - - /** - * Returns all blacklisted player names. - */ - public java.util.List getAllBlacklisted() { - java.util.List results = new java.util.ArrayList<>(); - String sql = "SELECT player_name FROM player_blacklist ORDER BY player_name"; - try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { - while (rs.next()) { - results.add(rs.getString("player_name")); - } - } catch (SQLException e) { - LOGGER.error("Failed to retrieve blacklist", e); - } - return results; - } - - /** - * Helper class to store item information. - */ - public static class ItemData { - public final String itemId; - public final String displayName; - public int totalCount; - - public ItemData(String itemId, String displayName, int count) { - this.itemId = itemId; - this.displayName = displayName; - this.totalCount = count; - } - } - - /** - * Gets all unique items from all chests across all dimensions. - * - * @return List of ItemData with item IDs, display names, and total counts - */ - public java.util.List getAllUniqueItems() { - return getAllUniqueItemsInDimension(null); - } - - /** - * Gets all unique items from chests in a specific dimension. - * - * @param dimension Dimension identifier, or null for all dimensions - * @return List of ItemData with item IDs, display names, and total counts - */ - public java.util.List getAllUniqueItemsInDimension(String dimension) { - java.util.Map itemMap = new java.util.HashMap<>(); - - String sql = dimension == null - ? "SELECT contents FROM chest_inventory WHERE contents IS NOT NULL AND contents != 'EMPTY'" - : "SELECT contents FROM chest_inventory WHERE dimension = ? AND contents IS NOT NULL AND contents != 'EMPTY'"; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - if (dimension != null) { - pstmt.setString(1, dimension); - } - - try (ResultSet rs = pstmt.executeQuery()) { - while (rs.next()) { - String contents = rs.getString("contents"); - parseContentsIntoMap(contents, itemMap); - } - } - - } catch (SQLException e) { - LOGGER.error("Failed to retrieve unique items", e); - } - - return new java.util.ArrayList<>(itemMap.values()); - } - - /** - * Parses chest contents string and adds items to the map. - * Contents format: "slot|itemId|count|displayName;slot|itemId|count|displayName" - */ - private void parseContentsIntoMap(String contents, java.util.Map itemMap) { - if (contents == null || contents.equals("EMPTY")) { - return; - } - - String[] items = contents.split(";"); - for (String item : items) { - String[] parts = item.split("\\|"); - if (parts.length >= 4) { - // parts[0] = slot, parts[1] = itemId, parts[2] = count, parts[3] = displayName - String itemId = parts[1]; - int count = Integer.parseInt(parts[2]); - String displayName = parts[3]; - - if (itemMap.containsKey(itemId)) { - itemMap.get(itemId).totalCount += count; - } else { - itemMap.put(itemId, new ItemData(itemId, displayName, count)); - } - } - } - } - - /** - * Gets the total count of a specific item across all chests in a dimension. - * - * @param itemName Item display name to search for - * @param dimension Dimension identifier, or null for all dimensions - * @return Total count of the item across all chests - */ - public int getItemCount(String itemName, String dimension) { - int totalCount = 0; - - String sql = dimension == null - ? "SELECT contents FROM chest_inventory WHERE contents LIKE ?" - : "SELECT contents FROM chest_inventory WHERE dimension = ? AND contents LIKE ?"; - - try (PreparedStatement pstmt = connection.prepareStatement(sql)) { - String searchPattern = "%" + itemName + "%"; - - if (dimension == null) { - pstmt.setString(1, searchPattern); - } else { - pstmt.setString(1, dimension); - pstmt.setString(2, searchPattern); - } - - try (ResultSet rs = pstmt.executeQuery()) { - while (rs.next()) { - String contents = rs.getString("contents"); - totalCount += countItemInContents(contents, itemName); - } - } - - } catch (SQLException e) { - LOGGER.error("Failed to count items: {}", itemName, e); - } - - return totalCount; - } - - /** - * Counts occurrences of an item in a contents string. - */ - private int countItemInContents(String contents, String itemName) { - if (contents == null || contents.equals("EMPTY")) { - return 0; - } - - int total = 0; - String[] items = contents.split(";"); - for (String item : items) { - String[] parts = item.split("\\|"); - if (parts.length >= 4) { - String displayName = parts[3]; - if (displayName.toLowerCase().contains(itemName.toLowerCase())) { - total += Integer.parseInt(parts[2]); - } - } - } - return total; - } - - /** - * Closes the database connection. - */ - public void close() { - if (connection != null) { - try { - connection.close(); - LOGGER.info("Database connection closed"); - } catch (SQLException e) { - LOGGER.error("Failed to close database connection", e); - } - } - } - - // Debug logs toggle methods - public boolean isDebugLogsEnabled() { - return debugLogsEnabled; - } - - public void setDebugLogsEnabled(boolean enabled) { - this.debugLogsEnabled = enabled; - LOGGER.info("Debug logs " + (enabled ? "enabled" : "disabled")); - } -} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 27d17ff..a9a5c57 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,18 +1,18 @@ { "schemaVersion": 1, - "id": "modid", + "id": "solomoninvnet", "version": "${version}", - "name": "Example mod", - "description": "This is an example description! Tell everyone what your mod is about!", + "name": "Solomon Inventory Network", + "description": "Client-side inventory synchronization mod for Solomon", "authors": [ - "Me!" + "Davvi02" ], "contact": { - "homepage": "https://fabricmc.net/", - "sources": "https://github.com/FabricMC/fabric-example-mod" + "homepage": "https://github.com/Solomon", + "sources": "https://github.com/Solomon/SolomonInvNetMod" }, "license": "CC0-1.0", - "icon": "assets/modid/icon.png", + "icon": "assets/solomoninvnet/icon.png", "environment": "*", "entrypoints": { "main": [