Add /playersync inventory viewer

New op command to pretty-print a player's stored inventory from the DB.
Works on offline players — reads the serialized columns directly and
deserializes each slot through the same deserializeAndCreatePlaceholderIfNeeded
path used by the normal restore.

Usage:
  /playersync inventory <player>              — everything (main + armor + ender + curios)
  /playersync inventory <player> main         — 36-slot hotbar + main inventory only
  /playersync inventory <player> armor        — 4 armor slots (0=boots, 1=legs, 2=chest, 3=helm)
  /playersync inventory <player> ender        — 27 ender chest slots
  /playersync inventory <player> curios       — Curios slots (funct + cosmetic), composite-keyed

Output per section lists only non-empty slots:
  [5] minecraft:diamond_sword x1
  [8] sophisticatedbackpacks:backpack x1 (Gilded Backpack)
  [cos🔙0] [placeholder] minecraft:paper x1   <- cross-server missing mod

Placeholder items (items from a mod not loaded on this server) are tagged
[placeholder] in magenta so admins can see at a glance which slots contain
'travelling' items. Parse errors on a single slot don't break the listing —
the affected slot shows <parse error: ClassName> and the rest continues.

Help listing updated. No other behavior changed.
This commit is contained in:
laforetbrut 2026-04-22 07:03:08 +02:00
parent 4597041b1a
commit 131aa64eb1

View File

@ -87,6 +87,21 @@ public class CommandInit {
.then(Commands.literal("confirm")
.executes(CommandInit::runWipe))))
// ---- Inventory viewer ----
.then(Commands.literal("inventory")
.then(Commands.argument("player", GameProfileArgument.gameProfile())
.executes(ctx -> runInventoryView(ctx, "all"))
.then(Commands.literal("main")
.executes(ctx -> runInventoryView(ctx, "main")))
.then(Commands.literal("armor")
.executes(ctx -> runInventoryView(ctx, "armor")))
.then(Commands.literal("ender")
.executes(ctx -> runInventoryView(ctx, "ender")))
.then(Commands.literal("curios")
.executes(ctx -> runInventoryView(ctx, "curios")))
.then(Commands.literal("all")
.executes(ctx -> runInventoryView(ctx, "all")))))
// ---- Cluster ops ----
.then(Commands.literal("orphans").executes(CommandInit::runOrphans))
.then(Commands.literal("clearorphans")
@ -465,6 +480,155 @@ public class CommandInit {
return 1;
}
/**
* Pretty-prints a player's inventory / armor / ender chest / curios from the DB.
* Works on offline players too reads the serialized columns directly instead
* of requiring the entity to be online. Output is compact, per-section, with
* item ID and count per non-empty slot.
*/
private static int runInventoryView(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx, String section)
throws CommandSyntaxException {
Collection<com.mojang.authlib.GameProfile> profiles =
GameProfileArgument.getGameProfiles(ctx, "player");
if (profiles.isEmpty()) {
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
return 0;
}
com.mojang.authlib.GameProfile profile = profiles.iterator().next();
UUID uuid = profile.getId();
String name = profile.getName();
CommandSourceStack src = ctx.getSource();
String inventoryRaw = null, armorRaw = null, enderRaw = null;
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT inventory, armor, enderchest FROM " + Tables.playerData() + " WHERE uuid=?",
uuid.toString())) {
ResultSet rs = qr.resultSet();
if (!rs.next()) {
src.sendFailure(Component.literal("§cNo DB row for " + name + " (" + uuid + ")"));
return 0;
}
inventoryRaw = rs.getString("inventory");
armorRaw = rs.getString("armor");
enderRaw = rs.getString("enderchest");
} catch (Exception e) {
src.sendFailure(Component.literal("§cDB query failed: " + e.getMessage()));
return 0;
}
String curiosRaw = null;
if ("all".equals(section) || "curios".equals(section)) {
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT curios_item FROM " + Tables.curios() + " WHERE uuid=?", uuid.toString())) {
ResultSet rs = qr.resultSet();
if (rs.next()) curiosRaw = rs.getString("curios_item");
} catch (Exception ignored) {}
}
src.sendSuccess(() -> Component.literal("§a=== Inventory of §f" + name + " §7(" + uuid + ")"), false);
int totalShown = 0;
if ("all".equals(section) || "main".equals(section)) {
totalShown += printSection(src, "§6Main inventory", inventoryRaw, 36);
}
if ("all".equals(section) || "armor".equals(section)) {
totalShown += printSection(src, "§6Armor §8(0=boots,1=legs,2=chest,3=helm)", armorRaw, 4);
}
if ("all".equals(section) || "ender".equals(section)) {
totalShown += printSection(src, "§6Ender chest", enderRaw, 27);
}
if ("all".equals(section) || "curios".equals(section)) {
totalShown += printCurios(src, curiosRaw);
}
final int shown = totalShown;
src.sendSuccess(() -> Component.literal("§7— §f" + shown + " §7non-empty slot(s) shown"), false);
return 1;
}
/** Prints a vanilla-style slot section (Map<Integer,String>). Returns non-empty count. */
private static int printSection(CommandSourceStack src, String header, String raw, int expectedSize) {
if (raw == null || raw.length() <= 2) {
src.sendSuccess(() -> Component.literal(header + "§7: §8(empty)"), false);
return 0;
}
java.util.Map<Integer, String> map;
try {
map = vip.fubuki.playersync.util.LocalJsonUtil.StringToEntryMap(raw);
} catch (Exception e) {
src.sendSuccess(() -> Component.literal(header + "§7: §c<parse error: " + e.getMessage() + ">"), false);
return 0;
}
if (map.isEmpty()) {
src.sendSuccess(() -> Component.literal(header + "§7: §8(empty)"), false);
return 0;
}
src.sendSuccess(() -> Component.literal(header + "§7 (" + map.size() + " slot(s) filled of " + expectedSize + "):"), false);
int shown = 0;
for (java.util.Map.Entry<Integer, String> e : new java.util.TreeMap<>(map).entrySet()) {
String line = formatSlotLine(e.getKey().toString(), e.getValue());
if (line != null) {
src.sendSuccess(() -> Component.literal(line), false);
shown++;
}
}
return shown;
}
/** Curios has composite keys ("slotType:index" and "cos:slotType:index"). */
private static int printCurios(CommandSourceStack src, String raw) {
if (raw == null || raw.length() <= 2) {
src.sendSuccess(() -> Component.literal("§6Curios§7: §8(empty)"), false);
return 0;
}
java.util.Map<String, String> map;
try {
map = vip.fubuki.playersync.util.LocalJsonUtil.StringToMap(raw);
} catch (Exception e) {
src.sendSuccess(() -> Component.literal("§6Curios§7: §c<parse error>"), false);
return 0;
}
if (map.isEmpty()) {
src.sendSuccess(() -> Component.literal("§6Curios§7: §8(empty)"), false);
return 0;
}
src.sendSuccess(() -> Component.literal("§6Curios§7 (" + map.size() + " slot(s) filled):"), false);
int shown = 0;
for (java.util.Map.Entry<String, String> e : new java.util.TreeMap<>(map).entrySet()) {
String line = formatSlotLine(e.getKey(), e.getValue());
if (line != null) {
src.sendSuccess(() -> Component.literal(line), false);
shown++;
}
}
return shown;
}
/** Deserializes a single slot payload into a human-readable line. */
private static String formatSlotLine(String slotKey, String payload) {
try {
net.minecraft.world.item.ItemStack stack =
vip.fubuki.playersync.sync.VanillaSync.deserializeAndCreatePlaceholderIfNeeded(payload);
if (stack == null || stack.isEmpty()) return null;
net.minecraft.resources.ResourceLocation id =
net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(stack.getItem());
String idStr = id == null ? "unknown" : id.toString();
String display = stack.getHoverName().getString();
// Placeholder items (items from a mod not loaded on this server) show up with their
// original id preserved inside CustomData the deserializer already handled that.
boolean placeholder = idStr.equals("minecraft:paper")
&& stack.getComponents().has(net.minecraft.core.component.DataComponents.CUSTOM_DATA)
&& stack.getComponents().get(net.minecraft.core.component.DataComponents.CUSTOM_DATA)
.copyTag().contains("playersync:original_item_nbt");
String prefix = placeholder ? "§d[placeholder] " : "§f";
return "§7 [" + slotKey + "] " + prefix + idStr + "§7 x§f" + stack.getCount()
+ (display.equals(stack.getItem().getDescription().getString()) ? "" : " §8(" + display + ")");
} catch (Throwable t) {
return "§7 [" + slotKey + "] §c<parse error: " + t.getClass().getSimpleName() + ">";
}
}
private static int runHelp(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
src.sendSuccess(() -> Component.literal("§a=== /playersync command reference ==="), false);
@ -473,6 +637,7 @@ public class CommandInit {
"§e/playersync poolstats §7— log pool stats immediately",
"§e/playersync flush [player] §7— force save all / one",
"§e/playersync info <player> §7— DB row metadata",
"§e/playersync inventory <player> [main|armor|ender|curios|all] §7— pretty-print stored inventory",
"§e/playersync dump <player> §7— dump DB row to server log",
"§e/playersync resync <player> §7— kick to force re-sync",
"§e/playersync wipe <player> confirm §7— DELETE rows (DANGER)",