|
| 1 | +package me.f0reach.vshop.command; |
| 2 | + |
| 3 | +import com.mojang.brigadier.Message; |
| 4 | +import com.mojang.brigadier.suggestion.SuggestionProvider; |
| 5 | +import io.papermc.paper.command.brigadier.CommandSourceStack; |
| 6 | +import io.papermc.paper.command.brigadier.MessageComponentSerializer; |
| 7 | +import me.f0reach.vshop.locale.MessageManager; |
| 8 | +import me.f0reach.vshop.model.Shop; |
| 9 | +import me.f0reach.vshop.ui.text.Displays; |
| 10 | +import net.kyori.adventure.text.Component; |
| 11 | +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; |
| 12 | +import org.bukkit.Bukkit; |
| 13 | +import org.bukkit.Location; |
| 14 | +import org.bukkit.entity.Player; |
| 15 | + |
| 16 | +import java.util.ArrayList; |
| 17 | +import java.util.Collection; |
| 18 | +import java.util.Comparator; |
| 19 | +import java.util.List; |
| 20 | +import java.util.Locale; |
| 21 | +import java.util.UUID; |
| 22 | + |
| 23 | +/** |
| 24 | + * Completions for the {@code <shopId>} command argument. |
| 25 | + * |
| 26 | + * <p>A server can hold hundreds of shops, so the full id list is noise to a |
| 27 | + * player standing in front of one. Only shops within {@link #NEARBY_RADIUS} |
| 28 | + * blocks of the sender are offered, nearest first, each carrying a tooltip with |
| 29 | + * name / type / owner / distance so the truncated id is identifiable.</p> |
| 30 | + * |
| 31 | + * <p>This restricts suggestions only — {@link CommandSupport#findShopByPrefix} |
| 32 | + * still resolves any id that is typed out, so a distant shop stays reachable |
| 33 | + * (and console, which has no "near", keeps seeing the whole list).</p> |
| 34 | + */ |
| 35 | +@SuppressWarnings("UnstableApiUsage") |
| 36 | +public final class ShopIdSuggestions { |
| 37 | + |
| 38 | + /** Generous enough to cover a base, small enough to stay a local list. */ |
| 39 | + private static final double NEARBY_RADIUS = 64.0; |
| 40 | + |
| 41 | + /** The client only shows ~10 rows; the cap keeps the packet small. */ |
| 42 | + private static final int MAX_SUGGESTIONS = 20; |
| 43 | + |
| 44 | + private final CommandSupport support; |
| 45 | + |
| 46 | + public ShopIdSuggestions(CommandSupport support) { |
| 47 | + this.support = support; |
| 48 | + } |
| 49 | + |
| 50 | + public SuggestionProvider<CommandSourceStack> provider() { |
| 51 | + return (ctx, builder) -> { |
| 52 | + Location origin = ctx.getSource().getSender() instanceof Player player |
| 53 | + ? player.getLocation() |
| 54 | + : null; |
| 55 | + for (Candidate candidate : select(support.plugin().registry().all(), |
| 56 | + builder.getRemaining().toLowerCase(Locale.ROOT), origin, MAX_SUGGESTIONS)) { |
| 57 | + builder.suggest(candidate.id(), tooltip(candidate)); |
| 58 | + } |
| 59 | + return builder.buildFuture(); |
| 60 | + }; |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * The shops to offer for {@code prefix}, nearest first and capped at |
| 65 | + * {@code limit}. A null {@code origin} means the sender has no position |
| 66 | + * (console), which drops the distance filter and orders by name instead. |
| 67 | + */ |
| 68 | + static List<Candidate> select(Collection<Shop> shops, String prefix, Location origin, int limit) { |
| 69 | + List<Candidate> candidates = new ArrayList<>(); |
| 70 | + for (Shop shop : shops) { |
| 71 | + String id = shop.id().toString().substring(0, 8); |
| 72 | + if (!id.startsWith(prefix)) continue; |
| 73 | + Double distance = origin == null ? null : distanceTo(origin, shop); |
| 74 | + if (origin != null && distance == null) continue; // other world / out of range |
| 75 | + candidates.add(new Candidate(id, shop, distance)); |
| 76 | + } |
| 77 | + // Brigadier re-sorts the built suggestion list alphabetically, so this |
| 78 | + // ordering only decides which shops survive the cap. |
| 79 | + candidates.sort(Comparator |
| 80 | + .<Candidate>comparingDouble(c -> c.distance() == null ? Double.MAX_VALUE : c.distance()) |
| 81 | + .thenComparing(c -> c.shop().name(), Comparator.nullsLast(String::compareTo))); |
| 82 | + return candidates.subList(0, Math.min(limit, candidates.size())); |
| 83 | + } |
| 84 | + |
| 85 | + /** Distance in blocks, or null when the shop is in another world or too far. */ |
| 86 | + private static Double distanceTo(Location origin, Shop shop) { |
| 87 | + Location loc = shop.location() == null ? null : shop.location().toBukkit(); |
| 88 | + if (loc == null || !origin.getWorld().equals(loc.getWorld())) return null; |
| 89 | + double squared = loc.distanceSquared(origin); |
| 90 | + if (squared > NEARBY_RADIUS * NEARBY_RADIUS) return null; |
| 91 | + return Math.sqrt(squared); |
| 92 | + } |
| 93 | + |
| 94 | + private Message tooltip(Candidate candidate) { |
| 95 | + MessageManager messages = support.messages(); |
| 96 | + Shop shop = candidate.shop(); |
| 97 | + Component owner = shop.ownerUuid() == null |
| 98 | + ? messages.get("command.shop-suggest.owner-none") |
| 99 | + : Component.text(ownerName(shop.ownerUuid())); |
| 100 | + Component distance = candidate.distance() == null |
| 101 | + ? messages.get("command.shop-suggest.distance-unknown") |
| 102 | + : messages.get("command.shop-suggest.distance", |
| 103 | + Placeholder.parsed("blocks", String.valueOf(Math.round(candidate.distance())))); |
| 104 | + |
| 105 | + // Shop names are player-supplied: inserted as a component so a name |
| 106 | + // containing MiniMessage syntax cannot inject formatting. |
| 107 | + Component text = messages.get("command.shop-suggest.tooltip", |
| 108 | + Placeholder.component("shop_name", |
| 109 | + Component.text(Displays.truncate(shop.name(), 32))), |
| 110 | + Placeholder.component("type", support.enumLabels().label(shop.type())), |
| 111 | + Placeholder.component("owner", owner), |
| 112 | + Placeholder.component("distance", distance), |
| 113 | + Placeholder.component("suspended", shop.suspended() |
| 114 | + ? messages.get("command.shop-suggest.suspended-mark") |
| 115 | + : Component.empty())); |
| 116 | + return MessageComponentSerializer.message().serialize(text); |
| 117 | + } |
| 118 | + |
| 119 | + /** |
| 120 | + * Suggestions are recomputed on every keystroke, so this deliberately skips |
| 121 | + * the DB-backed player cache: online player, then the server's local profile |
| 122 | + * cache, then the shortened id. |
| 123 | + */ |
| 124 | + private static String ownerName(UUID uuid) { |
| 125 | + Player online = Bukkit.getPlayer(uuid); |
| 126 | + if (online != null) return online.getName(); |
| 127 | + String cached = Bukkit.getOfflinePlayer(uuid).getName(); |
| 128 | + return cached != null ? cached : uuid.toString().substring(0, 8); |
| 129 | + } |
| 130 | + |
| 131 | + /** {@code distance} is null only for senders without a location (console). */ |
| 132 | + record Candidate(String id, Shop shop, Double distance) {} |
| 133 | +} |
0 commit comments