diff --git a/build.gradle.kts b/build.gradle.kts index 2e6be01..67b0422 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,8 @@ repositories { } maven { url = uri("https://maven.enginehub.org/repo/") } + + maven { url = uri("https://repo.codemc.io/repository/maven-public/") } } dependencies { @@ -33,6 +35,7 @@ dependencies { plexLibrary("org.eclipse.jetty:jetty-server:12.1.9") plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9") plexLibrary("org.eclipse.jetty:jetty-proxy:12.1.9") + plexLibrary("de.tr7zw:item-nbt-api:2.15.7") implementation(platform("com.intellectualsites.bom:bom-newest:1.56")) // Ref: https://github.com/IntellectualSites/bom compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core") implementation("commons-io:commons-io:2.22.0") diff --git a/src/main/java/dev/plex/HTTPDModule.java b/src/main/java/dev/plex/HTTPDModule.java index c93f2bf..510c53d 100644 --- a/src/main/java/dev/plex/HTTPDModule.java +++ b/src/main/java/dev/plex/HTTPDModule.java @@ -8,6 +8,7 @@ import dev.plex.module.PlexModule; import dev.plex.ratelimit.RateLimitFilter; import dev.plex.request.AbstractServlet; import dev.plex.request.PlayerActionServlet; +import dev.plex.request.PlayerInventoryStreamServlet; import dev.plex.request.PlayersStreamServlet; import dev.plex.request.SchematicUploadServlet; import dev.plex.request.StaffPlayersStreamServlet; @@ -99,6 +100,7 @@ public class HTTPDModule extends PlexModule StatsBroadcaster.get().start(); PlayersBroadcaster.get().start(); + PlayerInventoryBroadcaster.get().start(); new IndefBansEndpoint(); new IndexEndpoint(); @@ -118,6 +120,7 @@ public class HTTPDModule extends PlexModule HTTPDModule.context.addServlet(PlayersStreamServlet.class, "/api/players/stream"); HTTPDModule.context.addServlet(StaffPlayersStreamServlet.class, "/api/players/stream/staff"); HTTPDModule.context.addServlet(PlayerActionServlet.class, "/api/admin/action"); + HTTPDModule.context.addServlet(PlayerInventoryStreamServlet.class, "/api/player/inventory/stream"); ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading"); @@ -167,6 +170,14 @@ public class HTTPDModule extends PlexModule t.printStackTrace(); } try + { + PlayerInventoryBroadcaster.get().shutdown(); + } + catch (Throwable t) + { + t.printStackTrace(); + } + try { atomicServer.get().stop(); atomicServer.get().destroy(); diff --git a/src/main/java/dev/plex/request/AbstractServlet.java b/src/main/java/dev/plex/request/AbstractServlet.java index 4eb8643..abdaea3 100644 --- a/src/main/java/dev/plex/request/AbstractServlet.java +++ b/src/main/java/dev/plex/request/AbstractServlet.java @@ -174,16 +174,22 @@ public class AbstractServlet extends HttpServlet { String base = HTTPDModule.template; String page = readFileReal(filename); - String[] info = page.split("\n", 3); - base = base.replace("${TITLE}", info[0]); - base = base.replace("${ACTIVE_" + info[1] + "}", "active"); + String[] info = page.split("\\r?\\n", 3); + String title = info.length > 0 ? info[0] : ""; + String activeKey = info.length > 1 ? info[1] : ""; + String content = info.length > 2 ? info[2] : ""; + base = base.replace("${TITLE}", title); + if (!activeKey.isEmpty()) + { + base = base.replace("${ACTIVE_" + activeKey + "}", "active"); + } base = base.replace("${ACTIVE_HOME}", ""); base = base.replace("${ACTIVE_PLAYERS}", ""); base = base.replace("${ACTIVE_INDEFBANS}", ""); base = base.replace("${ACTIVE_COMMANDS}", ""); base = base.replace("${ACTIVE_PUNISHMENTS}", ""); base = base.replace("${ACTIVE_SCHEMATICS}", ""); - base = base.replace("${CONTENT}", info[2]); + base = base.replace("${CONTENT}", content); return base; } diff --git a/src/main/java/dev/plex/request/PlayerInventoryStreamServlet.java b/src/main/java/dev/plex/request/PlayerInventoryStreamServlet.java new file mode 100644 index 0000000..399aceb --- /dev/null +++ b/src/main/java/dev/plex/request/PlayerInventoryStreamServlet.java @@ -0,0 +1,116 @@ +package dev.plex.request; + +import dev.plex.logging.Log; +import dev.plex.request.impl.PlayerInventoryBroadcaster; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.UUID; + +public class PlayerInventoryStreamServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + if (AbstractServlet.currentStaff(request) == null) + { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; + } + + String uuidStr = request.getParameter("uuid"); + if (uuidStr == null) + { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + final UUID uuid; + try + { + uuid = UUID.fromString(uuidStr); + } + catch (IllegalArgumentException e) + { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + String ipAddress = request.getRemoteAddr(); + if ("127.0.0.1".equals(ipAddress)) + { + String forwarded = request.getHeader("X-FORWARDED-FOR"); + if (forwarded != null) ipAddress = forwarded; + } + Log.log(ipAddress + " opened inventory stream for " + uuid); + + PlayerInventoryBroadcaster broadcaster = PlayerInventoryBroadcaster.get(); + if (broadcaster.atCapacity()) + { + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + response.setHeader("Retry-After", "30"); + return; + } + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("text/event-stream"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Cache-Control", "no-cache, no-transform"); + response.setHeader("Connection", "keep-alive"); + response.setHeader("X-Accel-Buffering", "no"); + + final AsyncContext ctx = request.startAsync(); + ctx.setTimeout(0L); + ctx.addListener(new AsyncListener() + { + @Override public void onComplete(AsyncEvent event) { broadcaster.removeSubscriber(uuid, ctx); } + @Override public void onTimeout(AsyncEvent event) { broadcaster.removeSubscriber(uuid, ctx); } + @Override public void onError(AsyncEvent event) { broadcaster.removeSubscriber(uuid, ctx); } + @Override public void onStartAsync(AsyncEvent event) {} + }); + + PrintWriter writer; + try + { + writer = response.getWriter(); + } + catch (IOException e) + { + ctx.complete(); + return; + } + + if (!broadcaster.addSubscriber(uuid, ctx, writer)) + { + response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + ctx.complete(); + return; + } + + try + { + writer.write("retry: 5000\n\n"); + writer.write("data: "); + writer.write(broadcaster.currentPayload(uuid)); + writer.write("\n\n"); + writer.flush(); + if (writer.checkError()) + { + broadcaster.removeSubscriber(uuid, ctx); + ctx.complete(); + } + } + catch (Throwable t) + { + broadcaster.removeSubscriber(uuid, ctx); + try { ctx.complete(); } catch (Throwable ignored) {} + } + } +} diff --git a/src/main/java/dev/plex/request/impl/AssetsEndpoint.java b/src/main/java/dev/plex/request/impl/AssetsEndpoint.java index a85f46f..b70f60b 100644 --- a/src/main/java/dev/plex/request/impl/AssetsEndpoint.java +++ b/src/main/java/dev/plex/request/impl/AssetsEndpoint.java @@ -9,9 +9,13 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.regex.Pattern; public class AssetsEndpoint extends AbstractServlet { + private static final Pattern TEXTURE_PATH = Pattern.compile("(item|block)/[a-z0-9_]+\\.png"); + + @GetMapping(endpoint = "/assets/dashboard.js") @MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"}) public String dashboardJs(HttpServletRequest request, HttpServletResponse response) @@ -41,6 +45,27 @@ public class AssetsEndpoint extends AbstractServlet return null; } + @GetMapping(endpoint = "/assets/textures/") + @MappingHeaders(headers = {"content-type;image/png", "cache-control;public, max-age=86400"}) + public String texture(HttpServletRequest request, HttpServletResponse response) + { + String uri = request.getRequestURI(); + String prefix = "/assets/textures/"; + if (!uri.startsWith(prefix)) + { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return null; + } + String resourcePath = uri.substring(prefix.length()); + if (!TEXTURE_PATH.matcher(resourcePath).matches()) + { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return null; + } + serveResource("/httpd/assets/textures/" + resourcePath, response); + return null; + } + private static void serveResource(String classpathPath, HttpServletResponse response) { try (InputStream in = AssetsEndpoint.class.getResourceAsStream(classpathPath); diff --git a/src/main/java/dev/plex/request/impl/PlayerInventoryBroadcaster.java b/src/main/java/dev/plex/request/impl/PlayerInventoryBroadcaster.java new file mode 100644 index 0000000..12b9b3c --- /dev/null +++ b/src/main/java/dev/plex/request/impl/PlayerInventoryBroadcaster.java @@ -0,0 +1,472 @@ +package dev.plex.request.impl; + +import com.google.gson.GsonBuilder; +import dev.plex.HTTPDModule; +import dev.plex.Plex; +import dev.plex.util.PlexLog; +import jakarta.servlet.AsyncContext; +import org.bukkit.Bukkit; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.scheduler.BukkitTask; + +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import de.tr7zw.changeme.nbtapi.NBT; +import de.tr7zw.changeme.nbtapi.iface.ReadableItemNBT; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.persistence.PersistentDataContainer; + +/** + * Streams a single player's live inventory + armor + offhand to staff SSE + * subscribers. Samples on the Bukkit main thread once per second; only + * touches UUIDs that have at least one subscriber so it stays free when + * nobody is watching anyone. + */ +public final class PlayerInventoryBroadcaster +{ + private static final PlayerInventoryBroadcaster INSTANCE = new PlayerInventoryBroadcaster(); + private static final long REFRESH_TICKS = 20L; // 1 second + private static final Map TEXTURE_EXISTS = new ConcurrentHashMap<>(); + private static final Map> TEXTURE_RESOLVED = new ConcurrentHashMap<>(); + + public static PlayerInventoryBroadcaster get() + { + return INSTANCE; + } + + private final Map> subscribers = new ConcurrentHashMap<>(); + private final AtomicInteger subscriberCount = new AtomicInteger(); + + private ScheduledExecutorService executor; + private BukkitTask refreshTask; + private int maxConnections = 32; + + private PlayerInventoryBroadcaster() {} + + public synchronized void start() + { + if (executor != null) return; + + maxConnections = HTTPDModule.moduleConfig.getInt("server.sse.max-connections", 32); + int threads = Math.max(1, HTTPDModule.moduleConfig.getInt("server.sse.threads", 2)); + + executor = Executors.newScheduledThreadPool(threads, r -> + { + Thread t = new Thread(r, "Plex-HTTPD-Inv-SSE"); + t.setDaemon(true); + return t; + }); + + try + { + refreshTask = Bukkit.getScheduler().runTaskTimer( + Plex.get(), this::tick, 0L, REFRESH_TICKS); + } + catch (Throwable t) + { + PlexLog.debug("PlayerInventoryBroadcaster: could not register refresh task: " + t.getMessage()); + } + + try + { + NBT.preloadApi(); + } + catch (Throwable t) + { + PlexLog.debug("PlayerInventoryBroadcaster: NBT-API preload failed: " + t.getMessage()); + } + } + + public synchronized void shutdown() + { + if (refreshTask != null) + { + try { refreshTask.cancel(); } catch (Throwable ignored) {} + refreshTask = null; + } + if (executor != null) + { + executor.shutdownNow(); + executor = null; + } + for (Set set : subscribers.values()) + { + for (Subscriber sub : set) + { + try { sub.ctx.complete(); } catch (Throwable ignored) {} + } + } + subscribers.clear(); + subscriberCount.set(0); + } + + public boolean atCapacity() + { + return subscriberCount.get() >= maxConnections; + } + + public boolean addSubscriber(UUID uuid, AsyncContext ctx, PrintWriter writer) + { + if (subscriberCount.get() >= maxConnections) return false; + Subscriber sub = new Subscriber(ctx, writer); + Set set = subscribers.computeIfAbsent(uuid, k -> ConcurrentHashMap.newKeySet()); + if (set.add(sub)) + { + subscriberCount.incrementAndGet(); + return true; + } + return false; + } + + public void removeSubscriber(UUID uuid, AsyncContext ctx) + { + Set set = subscribers.get(uuid); + if (set == null) return; + Subscriber match = null; + for (Subscriber sub : set) + { + if (sub.ctx == ctx) { match = sub; break; } + } + if (match != null && set.remove(match)) + { + subscriberCount.decrementAndGet(); + if (set.isEmpty()) subscribers.remove(uuid, set); + } + } + + public String currentPayload(UUID uuid) + { + Player p = Bukkit.getPlayer(uuid); + if (p == null) return "{\"online\":false}"; + return buildPayload(p); + } + + // Runs on the Bukkit main thread. + private void tick() + { + if (subscribers.isEmpty()) return; + for (Map.Entry> entry : subscribers.entrySet()) + { + Set set = entry.getValue(); + if (set.isEmpty()) continue; + UUID uuid = entry.getKey(); + String json; + try + { + Player p = Bukkit.getPlayer(uuid); + json = (p == null) ? "{\"online\":false}" : buildPayload(p); + } + catch (Throwable t) + { + json = "{\"online\":false}"; + } + final String frame = "data: " + json + "\n\n"; + ScheduledExecutorService exec = executor; + if (exec == null) return; + for (Subscriber sub : set) + { + try + { + exec.execute(() -> writeFrame(uuid, sub, frame)); + } + catch (Throwable t) + { + drop(uuid, sub); + } + } + } + } + + private void writeFrame(UUID uuid, Subscriber sub, String frame) + { + try + { + sub.writer.write(frame); + sub.writer.flush(); + if (sub.writer.checkError()) drop(uuid, sub); + } + catch (Throwable t) + { + drop(uuid, sub); + } + } + + private void drop(UUID uuid, Subscriber sub) + { + Set set = subscribers.get(uuid); + if (set != null && set.remove(sub)) + { + subscriberCount.decrementAndGet(); + if (set.isEmpty()) subscribers.remove(uuid, set); + } + try { sub.ctx.complete(); } catch (Throwable ignored) {} + } + + private String buildPayload(Player p) + { + Map root = new LinkedHashMap<>(); + root.put("online", true); + root.put("name", p.getName()); + + PlayerInventory inv = p.getInventory(); + List> hotbar = new ArrayList<>(9); + for (int i = 0; i < 9; i++) hotbar.add(serializeItem(inv.getItem(i))); + List> storage = new ArrayList<>(27); + for (int i = 9; i < 36; i++) storage.add(serializeItem(inv.getItem(i))); + + Map armor = new LinkedHashMap<>(); + armor.put("helmet", serializeItem(inv.getHelmet())); + armor.put("chest", serializeItem(inv.getChestplate())); + armor.put("legs", serializeItem(inv.getLeggings())); + armor.put("boots", serializeItem(inv.getBoots())); + + root.put("hotbar", hotbar); + root.put("storage", storage); + root.put("armor", armor); + root.put("offhand", serializeItem(inv.getItemInOffHand())); + + return new GsonBuilder().serializeNulls().create().toJson(root); + } + + private static Map serializeItem(ItemStack item) + { + if (item == null || item.getType().isAir()) return null; + Map m = new LinkedHashMap<>(); + String type = item.getType().name(); + m.put("type", type); + m.put("amount", item.getAmount()); + + Map texture = resolveTextures(item.getType()); + if (texture != null && !texture.isEmpty()) m.put("texture", texture); + + try + { + short maxDur = item.getType().getMaxDurability(); + if (maxDur > 0) + { + m.put("maxDamage", (int) maxDur); + if (item.hasItemMeta() && item.getItemMeta() instanceof Damageable d) + { + m.put("damage", d.getDamage()); + } + } + } + catch (Throwable ignored) {} + + if (item.hasItemMeta()) + { + ItemMeta meta = item.getItemMeta(); + try + { + Component name = meta.displayName(); + if (name != null) m.put("name", PlainTextComponentSerializer.plainText().serialize(name)); + } + catch (Throwable ignored) {} + try + { + List lore = meta.lore(); + if (lore != null && !lore.isEmpty()) + { + List out = new ArrayList<>(lore.size()); + for (Component c : lore) + { + out.add(PlainTextComponentSerializer.plainText().serialize(c)); + } + m.put("lore", out); + } + } + catch (Throwable ignored) {} + try + { + Map enchants = meta.getEnchants(); + if (enchants != null && !enchants.isEmpty()) + { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : enchants.entrySet()) + { + out.put(e.getKey().getKey().getKey(), e.getValue()); + } + m.put("enchants", out); + } + } + catch (Throwable ignored) {} + try + { + if (meta.isUnbreakable()) m.put("unbreakable", true); + } + catch (Throwable ignored) {} + try + { + Set flags = meta.getItemFlags(); + if (flags != null && !flags.isEmpty()) + { + List out = new ArrayList<>(flags.size()); + for (ItemFlag f : flags) out.add(f.name()); + m.put("flags", out); + } + } + catch (Throwable ignored) {} + try + { + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + Set keys = pdc.getKeys(); + if (!keys.isEmpty()) + { + Set out = new TreeSet<>(); + for (NamespacedKey k : keys) out.add(k.toString()); + m.put("pdcKeys", out); + } + } + catch (Throwable ignored) {} + + try + { + Function toSnbt = ReadableItemNBT::toString; + String snbt = NBT.get(item, toSnbt); + if (snbt != null && !snbt.isEmpty() && !"{}".equals(snbt)) + { + m.put("nbt", snbt); + } + } + catch (Throwable ignored) {} + } + return m; + } + + /** + * Resolves textures for a Material. For blocks held in 3D form (no + * dedicated item sprite, but has block face textures) returns + * {@code {top, side}} so the client can render an isometric cube. Items + * with a dedicated item sprite — including blocks that render as 2D + * sprites in inventory like doors and signs — return {@code {flat}}. + * Variant blocks (slab, stairs, wall, fence, etc.) fall back to the + * parent block's textures when no dedicated texture exists, mirroring how + * Minecraft itself reuses the parent's faces. Results are cached per-material. + */ + private static Map resolveTextures(Material material) + { + if (material == null) return null; + String key = material.name().toLowerCase(); + Map cached = TEXTURE_RESOLVED.get(key); + if (cached != null) return cached.isEmpty() ? null : cached; + + Map result = resolveTexturesForName(material, key); + + if (result.isEmpty()) + { + String base = stripVariantSuffix(key); + if (base != null) + { + // Stone-style variants reuse the base block (cobblestone_slab → cobblestone); + // wood variants reuse planks (oak_slab → oak_planks); + // brick variants use the plural form (stone_brick_slab → stone_bricks). + for (String candidate : List.of(base, base + "_planks", base + "s")) + { + result = resolveTexturesForName(material, candidate); + if (!result.isEmpty()) break; + } + } + } + + TEXTURE_RESOLVED.put(key, result); + return result.isEmpty() ? null : result; + } + + private static String stripVariantSuffix(String key) + { + String[] suffixes = { + "_slab", "_stairs", "_wall", "_fence_gate", "_fence", + "_pressure_plate", "_button" + }; + for (String suffix : suffixes) + { + if (key.endsWith(suffix)) return key.substring(0, key.length() - suffix.length()); + } + return null; + } + + private static Map resolveTexturesForName(Material material, String key) + { + Map result = new LinkedHashMap<>(); + boolean hasItemSprite = textureExists("item/" + key + ".png"); + + if (material.isBlock() && !hasItemSprite) + { + String top = pickFirstTexture( + "block/" + key + "_top.png", + "block/" + key + ".png", + "block/" + key + "_side.png", + "block/" + key + "_front.png"); + String side = pickFirstTexture( + "block/" + key + "_side.png", + "block/" + key + ".png", + "block/" + key + "_front.png", + "block/" + key + "_top.png"); + if (top != null) + { + result.put("top", "/assets/textures/" + top); + result.put("side", "/assets/textures/" + (side != null ? side : top)); + } + } + + if (result.isEmpty()) + { + String flat = pickFirstTexture( + "item/" + key + ".png", + "block/" + key + ".png", + "block/" + key + "_side.png", + "block/" + key + "_front.png", + "block/" + key + "_top.png"); + if (flat != null) result.put("flat", "/assets/textures/" + flat); + } + + return result; + } + + private static String pickFirstTexture(String... candidates) + { + for (String c : candidates) + { + if (textureExists(c)) return c; + } + return null; + } + + private static boolean textureExists(String relative) + { + return TEXTURE_EXISTS.computeIfAbsent(relative, p -> + PlayerInventoryBroadcaster.class.getResource("/httpd/assets/textures/" + p) != null); + } + + private static final class Subscriber + { + final AsyncContext ctx; + final PrintWriter writer; + Subscriber(AsyncContext ctx, PrintWriter writer) + { + this.ctx = ctx; + this.writer = writer; + } + } +} diff --git a/src/main/resources/httpd/assets/player.js b/src/main/resources/httpd/assets/player.js index c72c321..9b1dbd7 100644 --- a/src/main/resources/httpd/assets/player.js +++ b/src/main/resources/httpd/assets/player.js @@ -7,12 +7,36 @@ const uuid = pingEl.getAttribute('data-uuid'); if (!uuid) return; + // ---- Helpers ---- + + function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function toTitle(snake) { + if (!snake) return ''; + return snake.toLowerCase().replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + } + + const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']; + function toRoman(n) { + return ROMAN[n] || String(n); + } + function pingColor(ping) { if (ping < 80) return 'text-success'; if (ping < 200) return 'text-warning'; return 'text-destructive'; } + // ---- Live header (ping/world/gamemode/status) ---- + function setOffline() { pingEl.textContent = '—'; pingEl.classList.remove('text-success', 'text-warning', 'text-destructive'); @@ -45,40 +69,309 @@ else setOffline(); } - const es = new EventSource('/api/players/stream/staff'); - es.addEventListener('message', (evt) => { + const staffSrc = new EventSource('/api/players/stream/staff'); + staffSrc.addEventListener('message', (evt) => { try { handle(JSON.parse(evt.data)); } catch (e) {} }); - // Action dialog wiring. + // ---- Action dialog wiring ---- + const dialog = document.getElementById('action-dialog'); const form = document.getElementById('action-form'); - if (!dialog || !form) return; - const actionInput = form.querySelector('[data-action-input]'); - const actionLabel = form.querySelector('[data-action-label]'); - const durationField = form.querySelector('[data-duration-field]'); - const reasonInput = form.querySelector('input[name="reason"]'); + if (dialog && form) { + const actionInput = form.querySelector('[data-action-input]'); + const actionLabel = form.querySelector('[data-action-label]'); + const durationField = form.querySelector('[data-duration-field]'); + const reasonInput = form.querySelector('input[name="reason"]'); - document.querySelectorAll('[data-admin-action]').forEach(btn => { - btn.addEventListener('click', () => { - const action = btn.getAttribute('data-admin-action'); - const isTemp = btn.getAttribute('data-admin-temp') === 'true'; - actionInput.value = action; - actionLabel.textContent = action; - durationField.hidden = !isTemp; - durationField.querySelector('select').disabled = !isTemp; - if (reasonInput) reasonInput.value = ''; - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } else { - dialog.setAttribute('open', ''); + document.querySelectorAll('[data-admin-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.getAttribute('data-admin-action'); + const isTemp = btn.getAttribute('data-admin-temp') === 'true'; + actionInput.value = action; + actionLabel.textContent = action; + durationField.hidden = !isTemp; + durationField.querySelector('select').disabled = !isTemp; + if (reasonInput) reasonInput.value = ''; + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', ''); + if (reasonInput) setTimeout(() => reasonInput.focus(), 0); + }); + }); + + form.querySelectorAll('[data-dialog-cancel]').forEach(btn => { + btn.addEventListener('click', () => dialog.close()); + }); + } + + // ---- Live inventory ---- + + const invRoot = document.getElementById('inv-root'); + if (!invRoot) return; + + // Latest inventory snapshot, used by the click handler. + let lastInv = null; + // Slot currently rendered in the detail panel (key like "storage-5"); kept across re-renders so the highlight survives data refreshes. + let selectedKey = null; + + function renderDurabilityBar(item) { + if (!item.maxDamage) return ''; + const damage = item.damage || 0; + const remaining = (item.maxDamage - damage) / item.maxDamage; + if (remaining >= 0.999) return ''; + const cls = remaining > 0.5 ? 'bg-success' : remaining > 0.25 ? 'bg-warning' : 'bg-destructive'; + const pct = Math.max(0, Math.min(100, remaining * 100)); + return `
+
+
`; + } + + function tooltipFor(item) { + const parts = []; + parts.push(item.name || toTitle(item.type)); + if (item.amount > 1) parts[0] += ' ×' + item.amount; + if (item.enchants) { + for (const [k, v] of Object.entries(item.enchants)) { + parts.push(toTitle(k) + ' ' + toRoman(v)); } - if (reasonInput) setTimeout(() => reasonInput.focus(), 0); + } + if (item.maxDamage) { + const remaining = item.maxDamage - (item.damage || 0); + parts.push('Durability: ' + remaining + ' / ' + item.maxDamage); + } + return escapeHtml(parts.join(' • ')); + } + + function renderItemIcon(item, large = false) { + const tex = item.texture || {}; + if (tex.top) { + const side = tex.side || tex.top; + const sizeClass = large ? 'iso-cube--lg' : 'iso-cube--sm'; + return ` +
+
+
+
+
+ `; + } + if (tex.flat) { + return `${escapeHtml(item.type)}`; + } + return `${escapeHtml(item.type.toLowerCase().replace(/_/g, ' '))}`; + } + + function renderSlot(item, key) { + if (!item) { + return `
`; + } + const tooltip = tooltipFor(item); + const amount = item.amount > 1 + ? `${item.amount}` + : ''; + const enchanted = item.enchants + ? '' + : ''; + const selected = key === selectedKey + ? 'ring-2 ring-primary' + : 'ring-card'; + return ` + + `; + } + + function renderInventoryGrid(inv) { + const armor = inv.armor || {}; + const storage = inv.storage || []; + const hotbar = inv.hotbar || []; + return ` +
+
+

Main

+
+
+ ${storage.map((s, i) => renderSlot(s, 'storage-' + i)).join('')} +
+
+ ${hotbar.map((s, i) => renderSlot(s, 'hotbar-' + i)).join('')} +
+
+
+
+
+

Armor

+
+ ${renderSlot(armor.helmet, 'armor-helmet')} + ${renderSlot(armor.chest, 'armor-chest')} + ${renderSlot(armor.legs, 'armor-legs')} + ${renderSlot(armor.boots, 'armor-boots')} +
+
+
+

Offhand

+ ${renderSlot(inv.offhand, 'offhand')} +
+
+
+ `; + } + + function renderDetailPanel(item) { + if (!item) { + return `
+ Click a slot to inspect the item. +
`; + } + const safeType = escapeHtml(item.type); + const safeName = item.name ? escapeHtml(item.name) : null; + const lines = []; + lines.push(`
+
+ ${renderItemIcon(item, true)} +
+
+ ${safeName ? `

${safeName}

` : ''} +

${safeType}

+

Count: ${item.amount}

+
+
`); + + if (item.lore && item.lore.length) { + lines.push(`
+

Lore

+
    + ${item.lore.map(l => `
  • ${escapeHtml(l)}
  • `).join('')} +
+
`); + } + + if (item.enchants && Object.keys(item.enchants).length) { + const rows = Object.entries(item.enchants) + .map(([k, v]) => `
  • ${escapeHtml(toTitle(k))}${toRoman(v)}
  • `) + .join(''); + lines.push(`
    +

    Enchantments

    +
      ${rows}
    +
    `); + } + + if (item.maxDamage) { + const remaining = item.maxDamage - (item.damage || 0); + const pct = Math.max(0, Math.min(100, (remaining / item.maxDamage) * 100)); + const cls = pct > 50 ? 'bg-success' : pct > 25 ? 'bg-warning' : 'bg-destructive'; + lines.push(`
    +

    Durability

    +
    +
    +
    +
    + ${remaining} / ${item.maxDamage} +
    +
    `); + } + + const tags = []; + if (item.unbreakable) tags.push('Unbreakable'); + if (item.flags) item.flags.forEach(f => tags.push(toTitle(f.replace(/^HIDE_/, 'Hide ')))); + if (tags.length) { + lines.push(`
    +

    Tags

    +
    + ${tags.map(t => `${escapeHtml(t)}`).join('')} +
    +
    `); + } + + if (item.pdcKeys && item.pdcKeys.length) { + lines.push(`
    +

    Plugin NBT keys

    +
      + ${item.pdcKeys.map(k => `
    • ${escapeHtml(k)}
    • `).join('')} +
    +
    `); + } + + if (item.nbt) { + lines.push(`
    +
    +

    NBT

    + +
    +
    ${escapeHtml(item.nbt)}
    +
    `); + } + + return `
    ${lines.join('')}
    `; + } + + function getItemBySlotKey(inv, key) { + if (!inv || !inv.online || !key) return null; + if (key === 'offhand') return inv.offhand || null; + if (key.startsWith('storage-')) return (inv.storage || [])[parseInt(key.substring(8), 10)] || null; + if (key.startsWith('hotbar-')) return (inv.hotbar || [])[parseInt(key.substring(7), 10)] || null; + if (key.startsWith('armor-')) return (inv.armor || {})[key.substring(6)] || null; + return null; + } + + function render(inv) { + lastInv = inv; + if (!inv.online) { + selectedKey = null; + invRoot.innerHTML = `

    Player is offline.

    `; + return; + } + invRoot.innerHTML = ` +
    +
    ${renderInventoryGrid(inv)}
    +
    + ${renderDetailPanel(getItemBySlotKey(inv, selectedKey))} +
    +
    + `; + } + + invRoot.addEventListener('click', (evt) => { + const copyBtn = evt.target.closest('[data-copy-nbt]'); + if (copyBtn) { + const pre = copyBtn.closest('div').parentElement.querySelector('[data-nbt-text]'); + if (pre && navigator.clipboard) { + navigator.clipboard.writeText(pre.textContent).then(() => { + const original = copyBtn.textContent; + copyBtn.textContent = 'Copied'; + setTimeout(() => { copyBtn.textContent = original; }, 1500); + }).catch(() => {}); + } + evt.stopPropagation(); + return; + } + const btn = evt.target.closest('[data-slot-key]'); + if (!btn) return; + selectedKey = btn.getAttribute('data-slot-key'); + const item = getItemBySlotKey(lastInv, selectedKey); + const detail = invRoot.querySelector('[data-inv-detail]'); + if (detail) detail.innerHTML = renderDetailPanel(item); + invRoot.querySelectorAll('[data-slot-key]').forEach(el => { + const isSelected = el.getAttribute('data-slot-key') === selectedKey; + el.classList.toggle('ring-2', isSelected); + el.classList.toggle('ring-primary', isSelected); + el.classList.toggle('ring-card', !isSelected); }); }); - form.querySelectorAll('[data-dialog-cancel]').forEach(btn => { - btn.addEventListener('click', () => dialog.close()); + const invSrc = new EventSource('/api/player/inventory/stream?uuid=' + encodeURIComponent(uuid)); + invSrc.addEventListener('message', (evt) => { + try { render(JSON.parse(evt.data)); } + catch (e) {} }); })(); diff --git a/src/main/resources/httpd/player.html b/src/main/resources/httpd/player.html index d1352f0..17d25ee 100644 --- a/src/main/resources/httpd/player.html +++ b/src/main/resources/httpd/player.html @@ -90,6 +90,37 @@ PLAYERS + + +
    +
    +

    Live inventory

    +
    Waiting for data…
    +
    +
    +