Skip to content

Commit 12fc231

Browse files
f0reachARRclaude
andcommitted
feat(command): shopId 補完を近くのショップに絞りツールチップを付ける
/vshop appearance <shopId> の補完は登録済みショップを全件返していたため、 ショップ数が増えるとサーバー全体の ID が並び、8 文字の ID だけでは どれがどのショップか判別できなかった。 - 補完対象を実行者から 64 ブロック以内・同一ワールドのショップに限定し、 近い順に並べて 20 件で打ち切る。Brigadier が最終的にアルファベット順へ 並べ替えるため、距離順は 20 件に残すショップの選定にのみ効く - 候補にショップ名・種別・オーナー・距離・停止中マークのツールチップを 付ける。ショップ名は Placeholder.component で差し込み、名前に MiniMessage が含まれていても書式注入にならないようにする - オーナー名は補完が打鍵ごとに再計算される経路なので DB のプレイヤー キャッシュは使わず、オンライン → サーバーのプロファイルキャッシュ → 短縮 UUID の順で解決する - 位置を持たないコンソールは距離フィルタを外して名前順で全件返す - 補完を絞るだけで findShopByPrefix は変更しないため、遠くのショップも ID を入力すれば従来どおり操作できる ロジックは AppearanceCommand から ShopIdSuggestions に切り出し、他の shopId を取るサブコマンドからも使えるようにした。選定部分は ShopIdSuggestionsTest で範囲外・別ワールド・件数上限・コンソール・ prefix 一致を検証している。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0c935a1 commit 12fc231

7 files changed

Lines changed: 245 additions & 14 deletions

File tree

docs/guide/en/user-advanced.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ If your server has FancyNpcs installed, the shop can look like an **NPC** instea
117117
/vshop appearance <shopId> villager # back to a villager
118118
```
119119

120-
`<shopId>` is the first 8 characters of the shop ID, and Tab completes it. There is also `type` (entity kind), `scale`, `skin`, `attribute` and `reset`.
120+
`<shopId>` is the first 8 characters of the shop ID, and Tab completes it. Completion only lists shops within 64 blocks of you, and highlighting a suggestion shows its name, type, owner and distance. A distant shop still works if you type its ID out. There is also `type` (entity kind), `scale`, `skin`, `attribute` and `reset`.
121121

122122
While the shop renders as an NPC the profession button disappears, because professions only exist on villagers. Switching back with `villager` restores the profession you had.
123123

docs/guide/user-advanced.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ BUY 枠に対する納品は在庫に積み上がるので、それを SELL 枠
117117
/vshop appearance <shopId> villager # 村人に戻す
118118
```
119119

120-
`<shopId>` はショップ ID の先頭 8 文字で、Tab キーで補完できます。ほかに `type`(エンティティの種類)・`scale`(大きさ)・`skin``attribute``reset` があります。
120+
`<shopId>` はショップ ID の先頭 8 文字で、Tab キーで補完できます。補完候補に並ぶのは自分から 64 ブロック以内にあるショップだけで、候補を選ぶとショップ名・種別・オーナー・距離が表示されます。遠くのショップも ID を直接入力すれば操作できます。ほかに `type`(エンティティの種類)・`scale`(大きさ)・`skin``attribute``reset` があります。
121121

122122
NPC 表示にしている間は職業変更のボタンが消えます。職業は村人だけの設定だからです。`villager` に戻せば元の職業のまま復元されます。
123123

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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+
}

src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import io.papermc.paper.command.brigadier.CommandSourceStack;
1111
import io.papermc.paper.command.brigadier.Commands;
1212
import me.f0reach.vshop.command.CommandSupport;
13+
import me.f0reach.vshop.command.ShopIdSuggestions;
1314
import me.f0reach.vshop.config.PluginConfig;
1415
import me.f0reach.vshop.model.Shop;
1516
import me.f0reach.vshop.model.ShopAppearance;
@@ -49,9 +50,11 @@ public final class AppearanceCommand {
4950
private static final String CLEAR_TOKEN = "@none";
5051

5152
private final CommandSupport support;
53+
private final ShopIdSuggestions shopIds;
5254

5355
public AppearanceCommand(CommandSupport support) {
5456
this.support = support;
57+
this.shopIds = new ShopIdSuggestions(support);
5558
}
5659

5760
public LiteralArgumentBuilder<CommandSourceStack> node() {
@@ -60,7 +63,7 @@ public LiteralArgumentBuilder<CommandSourceStack> node() {
6063
|| s.getSender().hasPermission("modernvillagershop.edit.others")
6164
|| s.getSender().hasPermission("modernvillagershop.admin.appearance"))
6265
.then(Commands.argument("shopId", StringArgumentType.word())
63-
.suggests(shopIds())
66+
.suggests(shopIds.provider())
6467
.then(Commands.literal("show")
6568
.executes(ctx -> show(ctx, shopId(ctx))))
6669
.then(Commands.literal("npc")
@@ -401,17 +404,6 @@ private static String trim(float value) {
401404

402405
// ---- completions ----
403406

404-
private SuggestionProvider<CommandSourceStack> shopIds() {
405-
return (ctx, builder) -> {
406-
String prefix = builder.getRemaining().toLowerCase(Locale.ROOT);
407-
for (Shop shop : support.plugin().registry().all()) {
408-
String id = shop.id().toString().substring(0, 8);
409-
if (id.startsWith(prefix)) builder.suggest(id, () -> shop.name());
410-
}
411-
return builder.buildFuture();
412-
};
413-
}
414-
415407
private SuggestionProvider<CommandSourceStack> entityTypes() {
416408
return (ctx, builder) -> {
417409
String prefix = builder.getRemaining().toUpperCase(Locale.ROOT);

src/main/resources/lang/messages_en.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ command:
1818
no-permission: "<red>You don't have permission."
1919
player-only: "<red>This command can only be used by a player."
2020
shop-not-found: "<red>Shop not found: <shop_id>"
21+
shop-suggest:
22+
tooltip: "<white><shop_name></white><suspended><newline><gray><type> / <owner> / <distance>"
23+
distance: "<blocks> blocks away"
24+
distance-unknown: "distance unknown"
25+
owner-none: "no owner"
26+
suspended-mark: " <red>[suspended]"
2127
egg:
2228
issued: "<green>Gave a spawn egg to <player>."
2329
not-found: "<red>Player not found: <player>"

src/main/resources/lang/messages_ja.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ command:
1818
no-permission: "<red>権限がありません。"
1919
player-only: "<red>このコマンドはプレイヤーのみ実行できます。"
2020
shop-not-found: "<red>指定されたショップが見つかりません: <shop_id>"
21+
shop-suggest:
22+
tooltip: "<white><shop_name></white><suspended><newline><gray><type> / <owner> / <distance>"
23+
distance: "<blocks>ブロック先"
24+
distance-unknown: "距離不明"
25+
owner-none: "オーナーなし"
26+
suspended-mark: " <red>[停止中]"
2127
egg:
2228
issued: "<green>スポーンエッグを <player> に配布しました。"
2329
not-found: "<red>対象プレイヤーが見つかりません: <player>"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package me.f0reach.vshop.command;
2+
3+
import me.f0reach.vshop.model.Shop;
4+
import me.f0reach.vshop.model.ShopLocation;
5+
import me.f0reach.vshop.model.ShopType;
6+
import me.f0reach.vshop.testsupport.BukkitTestSupport;
7+
import org.bukkit.Location;
8+
import org.bukkit.World;
9+
import org.junit.jupiter.api.BeforeAll;
10+
import org.junit.jupiter.api.Test;
11+
import org.mockbukkit.mockbukkit.MockBukkit;
12+
13+
import java.time.Instant;
14+
import java.util.List;
15+
import java.util.UUID;
16+
17+
import static org.junit.jupiter.api.Assertions.assertEquals;
18+
import static org.junit.jupiter.api.Assertions.assertNull;
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
/**
22+
* Which shops the {@code <shopId>} completion offers. The tooltip rendering
23+
* needs a live plugin, so only the selection is covered here — that is where
24+
* the "nearby only" rule lives.
25+
*/
26+
class ShopIdSuggestionsTest {
27+
28+
private static World world;
29+
private static World other;
30+
31+
@BeforeAll
32+
static void bootBukkit() {
33+
BukkitTestSupport.ensureBukkit();
34+
world = MockBukkit.getMock().addSimpleWorld("suggest-test-" + System.nanoTime());
35+
other = MockBukkit.getMock().addSimpleWorld("suggest-other-" + System.nanoTime());
36+
}
37+
38+
private static Shop shopAt(String name, World w, double x, double z) {
39+
Instant now = Instant.now();
40+
return new Shop(UUID.randomUUID(), ShopType.PLAYER, UUID.randomUUID(),
41+
new ShopLocation(w.getUID(), x, 64, z, 0f, 0f),
42+
null, null, name, false, 3, now, now);
43+
}
44+
45+
private static List<String> names(List<ShopIdSuggestions.Candidate> candidates) {
46+
return candidates.stream().map(c -> c.shop().name()).toList();
47+
}
48+
49+
@Test
50+
void onlyShopsWithinRangeOfThePlayerAreOffered() {
51+
Shop near = shopAt("near", world, 10, 0);
52+
Shop far = shopAt("far", world, 400, 0);
53+
var picked = ShopIdSuggestions.select(List.of(near, far), "",
54+
new Location(world, 0, 64, 0), 20);
55+
assertEquals(List.of("near"), names(picked));
56+
}
57+
58+
@Test
59+
void shopsInAnotherWorldAreNeverNear() {
60+
Shop sameSpotOtherWorld = shopAt("elsewhere", other, 0, 0);
61+
var picked = ShopIdSuggestions.select(List.of(sameSpotOtherWorld), "",
62+
new Location(world, 0, 64, 0), 20);
63+
assertTrue(picked.isEmpty());
64+
}
65+
66+
@Test
67+
void nearestSurviveTheCapAndCarryTheirDistance() {
68+
Shop a = shopAt("a", world, 30, 0);
69+
Shop b = shopAt("b", world, 5, 0);
70+
Shop c = shopAt("c", world, 15, 0);
71+
var picked = ShopIdSuggestions.select(List.of(a, b, c), "",
72+
new Location(world, 0, 64, 0), 2);
73+
assertEquals(List.of("b", "c"), names(picked));
74+
assertEquals(5.0, picked.get(0).distance(), 1e-9);
75+
}
76+
77+
@Test
78+
void aSenderWithoutAPositionKeepsEveryShopOrderedByName() {
79+
Shop far = shopAt("zulu", world, 5000, 0);
80+
Shop elsewhere = shopAt("alpha", other, 0, 0);
81+
var picked = ShopIdSuggestions.select(List.of(far, elsewhere), "", null, 20);
82+
assertEquals(List.of("alpha", "zulu"), names(picked));
83+
assertNull(picked.get(0).distance());
84+
}
85+
86+
@Test
87+
void thePrefixStillFiltersByTheShortId() {
88+
Shop shop = shopAt("only", world, 1, 0);
89+
String id = shop.id().toString().substring(0, 8);
90+
Location origin = new Location(world, 0, 64, 0);
91+
assertEquals(1, ShopIdSuggestions.select(List.of(shop), id, origin, 20).size());
92+
assertTrue(ShopIdSuggestions.select(List.of(shop), "zzzzzzzz", origin, 20).isEmpty());
93+
}
94+
}

0 commit comments

Comments
 (0)