From 0241eb3a5d0bbc35412cc2adcb7adaad4629f1b6 Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 19:50:38 +0900 Subject: [PATCH 1/9] build: add the FancyNpcs API as a compile-only dependency Groundwork for backing shops with a FancyNpcs NPC instead of a Villager. Pinned to 2.9.2 rather than the current 2.11.0: FancyNpcs API jars are Java 25 class files from 2.10.0 onward, which a JDK 21 toolchain cannot read. 2.9.2 is the last Java 17 build, and a signature diff shows all 392 of its public API members are present unchanged in 2.10.x and 2.11.x, so binaries compiled here run against the current plugin releases. Also registers FancyNpcs as an optional BEFORE dependency so it is enabled by the time our onEnable runs. Co-Authored-By: Claude Opus 5 (1M context) --- build.gradle | 9 +++++++++ src/main/resources/paper-plugin.yml | 3 +++ 2 files changed, 12 insertions(+) diff --git a/build.gradle b/build.gradle index 5524792..2b40e8c 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,10 @@ repositories { name = "placeholderapi" url = "https://repo.extendedclip.com/content/repositories/placeholderapi/" } + maven { + name = "fancyinnovations" + url = "https://repo.fancyinnovations.com/releases" + } exclusiveContent { forRepository { maven { @@ -39,6 +43,11 @@ dependencies { compileOnly("me.clip:placeholderapi:2.11.6") // BedrockDialog (Modrinth Maven). Version slug pinned to the latest available 1.21.8+ compatible build. compileOnly("maven.modrinth:bedrockdialog:1.0.3") + // FancyNpcs API. Pinned to 2.9.2 — the last release whose API jar is a Java 17 + // class file (2.10.0+ ship Java 25 bytecode, unreadable by our JDK 21 toolchain). + // 2.9.2's full public API is present unchanged in 2.10.x / 2.11.x, so binaries + // compiled here run against the current plugin releases. + compileOnly("de.oliver:FancyNpcs:2.9.2") implementation("com.zaxxer:HikariCP:5.1.0") runtimeOnly("org.xerial:sqlite-jdbc:3.49.1.0") runtimeOnly("com.mysql:mysql-connector-j:9.1.0") diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 4ab9613..fe6d4a7 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -16,6 +16,9 @@ dependencies: PlaceholderAPI: load: BEFORE required: false + FancyNpcs: + load: BEFORE + required: false permissions: modernvillagershop.use: From 9edf6b846e0d3f36615a0aa268194020b986b4d2 Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 19:50:54 +0900 Subject: [PATCH 2/9] refactor(shop): extract ShopEntityBackend from ShopVillagerManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shop's in-world representation is about to become pluggable (Villager today, FancyNpcs NPC next), so put a seam in before adding the second implementation. - ShopEntityBackend: spawn / refresh / refreshDisplayName / remove. spawn returns a nullable entity id so a backend without a Bukkit entity can report "there is none" instead of the caller special-casing it. - VillagerBackend: the old ShopVillagerManager, moved. Villager-only hooks (villagerKey, findEntity) stay off the interface. - ShopEntityService: facade with a single backendFor(shop) decision point. Callers depend on this, so adding a backend touches one method. No behaviour change. VillagerBackend now takes PluginConfig in its constructor, which removes configSnapshot() — the old workaround that cast Plugin back to ModernVillagerShopPlugin to reach the config. PluginConfig is refreshed in place on reload, so holding the reference matches what ShopService already does. ShopVillagerListener loses its now-unused PluginConfig field, since spawn no longer takes one. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 +- .../vshop/ModernVillagerShopPlugin.java | 20 ++-- .../me/f0reach/vshop/shop/ShopService.java | 15 +-- .../vshop/shop/coowner/CoOwnerFlow.java | 10 +- .../vshop/shop/edit/ShopActionMenu.java | 5 +- .../vshop/shop/entity/ShopEntityBackend.java | 35 +++++++ .../vshop/shop/entity/ShopEntityService.java | 55 +++++++++++ .../VillagerBackend.java} | 96 ++++++++----------- .../shop/listener/ShopVillagerListener.java | 11 +-- 9 files changed, 166 insertions(+), 86 deletions(-) create mode 100644 src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java create mode 100644 src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java rename src/main/java/me/f0reach/vshop/shop/{ShopVillagerManager.java => entity/VillagerBackend.java} (63%) diff --git a/CLAUDE.md b/CLAUDE.md index 3b71c30..f259f4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,8 @@ CI runs both backends — see [.github/workflows/ci.yml](.github/workflows/ci.ym - `locale` — `MessageManager` loads `lang/messages_.yml`, parses MiniMessage, and is the only place that emits player-facing text. - `storage` — `StorageManager` owns the `DataSourceProvider` (Hikari) and exposes one repository per concern (`shops()`, `slots()`, `inventory()`, `transactions()`, `notifications()`, `limits()`, `coOwners()`, `playerCache()`, `playerPreferences()`). Repository implementations live under `storage/sqlite` and `storage/mysql`; the SQL-agnostic schema bootstrap is in `storage/repo/SchemaInitializer`. Cross-backend data movement is in `storage/migrate/MigrationService` (invoked by `/vshop migrate`). - `economy` — `EconomyService` is the only caller of Vault. Fee/share math is centralized here; never call `Economy` directly elsewhere. -- `shop` — domain. `ShopRegistry` is the in-memory authoritative map of `UUID -> Shop`. `ShopService` is the lifecycle coordinator (create/load/delete, persistence + registry + villager state in lockstep). `ShopVillagerManager` handles the live entity (AI lock, invulnerability, respawn on chunk load, custom name regen). Subpackages mirror flows: `trade` (purchase/sell), `edit` (slot/menu editing), `coowner` (PRIMARY/MANAGER/STAFF), `egg` (spawn-egg crafting), `listener` (Bukkit events that fan into the services), `cache` (player-head/online cache). +- `shop` — domain. `ShopRegistry` is the in-memory authoritative map of `UUID -> Shop`. `ShopService` is the lifecycle coordinator (create/load/delete, persistence + registry + villager state in lockstep). Subpackages mirror flows: `entity` (the shop's in-world representation), `trade` (purchase/sell), `edit` (slot/menu editing), `coowner` (PRIMARY/MANAGER/STAFF), `egg` (spawn-egg crafting), `listener` (Bukkit events that fan into the services), `cache` (player-head/online cache). +- `shop/entity` — how a shop shows up in the world. `ShopEntityBackend` is the strategy interface (spawn / refresh / refreshDisplayName / remove); `VillagerBackend` is the only implementation today and owns the live Villager (AI lock, invulnerability, custom name regen, the `shop_id` PDC key). `ShopEntityService` is the facade that resolves a shop to its backend — **depend on the facade, not on a concrete backend**, so a second backend can be added without touching callers. The villager-specific escape hatches (`villagerKey()`, `findEntity()`) live on `VillagerBackend` and are reached via `ShopEntityService#villagers()`. - `ui` — `ui/dialog` is the BedrockDialog adapter (`DialogService`); `ui/chest` builds the inventory-based browse/edit/restock/player-picker UIs; `ui/text` renders chat output (history, search, list). - `command` — `VShopCommand` builds the Brigadier tree and delegates per-subcommand classes in `command/sub`. - `integration` — `MvshopPlaceholders` is an optional PAPI expansion, registered only when both the plugin is present and `placeholderapi.enabled` is true. @@ -51,7 +52,7 @@ CI runs both backends — see [.github/workflows/ci.yml](.github/workflows/ci.ym ### Cross-cutting invariants - Persistence is repository-per-table. There is no ORM; each repository is hand-written SQL with a SQLite and a MySQL implementation. Schema differences are kept inside the backend-specific class, not branched in shared code. -- The Villager is the source of truth for "is there a shop here," but its position/customName are derived state regenerated from DB on load. Never mutate the entity directly — go through `ShopVillagerManager` or `ShopService`. +- The Villager is the source of truth for "is there a shop here," but its position/customName are derived state regenerated from DB on load. Never mutate the entity directly — go through `ShopEntityService` or `ShopService`. - Co-owner role (`PRIMARY` / `MANAGER` / `STAFF`) gates *what an owner can do in their own shop*; the `modernvillagershop.*` permissions in [paper-plugin.yml](src/main/resources/paper-plugin.yml) gate *whether the command/feature is available at all*. `*.others` permissions bypass role for moderation. - BedrockDialog callbacks may fire off the main thread. Anything that touches Bukkit API must be wrapped in `Bukkit.getScheduler().runTask(plugin, ...)` — see existing flows in `shop/trade/TradeFlow` and `shop/edit/SlotEditFlow` for the pattern. - BedrockDialog only ships `ConfirmDialog` / `NoticeDialog` / `MultiButtonDialog` / `InputDialog` and has no `onClose` on Bedrock — design flows around explicit cancel buttons, not close detection. Sliders are banned for amount/price (use `InputDialog`). diff --git a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java index 91c47ae..2f36352 100644 --- a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java +++ b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java @@ -11,8 +11,9 @@ import me.f0reach.vshop.shop.ShopOpenService; import me.f0reach.vshop.shop.ShopRegistry; import me.f0reach.vshop.shop.ShopService; -import me.f0reach.vshop.shop.ShopVillagerManager; import me.f0reach.vshop.shop.VillagerTeleportGuard; +import me.f0reach.vshop.shop.entity.ShopEntityService; +import me.f0reach.vshop.shop.entity.VillagerBackend; import me.f0reach.vshop.shop.admin.AdminShopSlotIO; import me.f0reach.vshop.shop.cache.PlayerCacheService; import me.f0reach.vshop.shop.coowner.CoOwnerFlow; @@ -55,7 +56,8 @@ public final class ModernVillagerShopPlugin extends JavaPlugin { private ShopRegistry registry; private ShopService shopService; private SpawnEggFactory eggFactory; - private ShopVillagerManager villagerManager; + private VillagerBackend villagerBackend; + private ShopEntityService shopEntities; private DialogService dialogService; private IconConfig iconConfig; private ShopBrowseUi browseUi; @@ -107,8 +109,9 @@ public void onEnable() { this.registry = new ShopRegistry(); this.villagerTeleportGuard = new VillagerTeleportGuard(); - this.villagerManager = new ShopVillagerManager(this, messages, storage.coOwners()); - this.shopService = new ShopService(storage, registry, villagerManager, config); + this.villagerBackend = new VillagerBackend(this, messages, storage.coOwners(), config); + this.shopEntities = new ShopEntityService(villagerBackend); + this.shopService = new ShopService(storage, registry, shopEntities, config); this.eggFactory = new SpawnEggFactory(this, messages); this.dialogService = new DialogService(this); this.iconConfig = new IconConfig(messages, config); @@ -128,7 +131,7 @@ public void onEnable() { this.slotEditFlow = new SlotEditFlow(dialogService, messages, economyService, editService, config); this.playerCacheService = new PlayerCacheService(this); this.playerPickerUi = new PlayerPickerUi(playerCacheService, messages, dialogService, iconConfig); - this.coOwnerFlow = new CoOwnerFlow(dialogService, messages, storage, shopService, villagerManager, + this.coOwnerFlow = new CoOwnerFlow(dialogService, messages, storage, shopService, shopEntities, playerPickerUi, playerCacheService); this.restockUi = new ShopRestockUi(storage, messages, editService, iconConfig); this.actionMenu = new ShopActionMenu(this, dialogService, messages, editService, restockUi, coOwnerFlow); @@ -145,8 +148,8 @@ public void onEnable() { var pm = getServer().getPluginManager(); pm.registerEvents(new ShopEggListener(this, eggFactory, shopService, messages), this); - pm.registerEvents(new ShopVillagerListener(registry, shopService, villagerManager, openService, - actionMenu, config, soundService, villagerTeleportGuard), this); + pm.registerEvents(new ShopVillagerListener(registry, shopService, villagerBackend, openService, + actionMenu, soundService, villagerTeleportGuard), this); pm.registerEvents(new VillagerLookListener(registry, config, villagerTeleportGuard), this); pm.registerEvents(new ShopBrowseListener(this, registry, browseUi, storage, tradeFlow, messages), this); pm.registerEvents(new NotificationFlushListener(this, tradeNotifier), this); @@ -215,7 +218,8 @@ public void reloadConfigInternal() { public ShopRegistry registry() { return registry; } public ShopService shopService() { return shopService; } public SpawnEggFactory eggFactory() { return eggFactory; } - public ShopVillagerManager villagerManager() { return villagerManager; } + public VillagerBackend villagerBackend() { return villagerBackend; } + public ShopEntityService shopEntities() { return shopEntities; } public DialogService dialogService() { return dialogService; } public ShopBrowseUi browseUi() { return browseUi; } public ShopOpenService openService() { return openService; } diff --git a/src/main/java/me/f0reach/vshop/shop/ShopService.java b/src/main/java/me/f0reach/vshop/shop/ShopService.java index e8fca30..3a1f884 100644 --- a/src/main/java/me/f0reach/vshop/shop/ShopService.java +++ b/src/main/java/me/f0reach/vshop/shop/ShopService.java @@ -10,6 +10,7 @@ import me.f0reach.vshop.model.ShopLocation; import me.f0reach.vshop.model.ShopType; import me.f0reach.vshop.shop.egg.SpawnEggMeta; +import me.f0reach.vshop.shop.entity.ShopEntityService; import me.f0reach.vshop.storage.StorageManager; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -31,14 +32,14 @@ public final class ShopService { private final StorageManager storage; private final ShopRegistry registry; - private final ShopVillagerManager villagers; + private final ShopEntityService entities; private final PluginConfig config; - public ShopService(StorageManager storage, ShopRegistry registry, ShopVillagerManager villagers, + public ShopService(StorageManager storage, ShopRegistry registry, ShopEntityService entities, PluginConfig config) { this.storage = storage; this.registry = registry; - this.villagers = villagers; + this.entities = entities; this.config = config; } @@ -101,7 +102,7 @@ public Shop createFromEgg(Player creator, Location at, SpawnEggMeta egg) throws now ); - UUID villagerId = villagers.spawn(shop, at, config); + UUID villagerId = entities.spawn(shop, at); shop.setVillagerEntityId(villagerId); storage.shops().insert(shop); @@ -131,7 +132,7 @@ public DeleteResult delete(Shop shop) throws SQLException { case DISCARD -> { /* fall through — storage cascade will drop the rows */ } } } - villagers.remove(shop); + entities.remove(shop); storage.shops().delete(shop.id()); registry.remove(shop.id()); Bukkit.getPluginManager().callEvent(new ShopDeleteEvent(shop)); @@ -169,8 +170,8 @@ public ShopRegistry registry() { return registry; } - public ShopVillagerManager villagers() { - return villagers; + public ShopEntityService entities() { + return entities; } /** Outcome of {@link #delete(Shop)}. */ diff --git a/src/main/java/me/f0reach/vshop/shop/coowner/CoOwnerFlow.java b/src/main/java/me/f0reach/vshop/shop/coowner/CoOwnerFlow.java index 9b6531d..38ca864 100644 --- a/src/main/java/me/f0reach/vshop/shop/coowner/CoOwnerFlow.java +++ b/src/main/java/me/f0reach/vshop/shop/coowner/CoOwnerFlow.java @@ -7,8 +7,8 @@ import me.f0reach.vshop.model.PlayerCacheEntry; import me.f0reach.vshop.model.Shop; import me.f0reach.vshop.shop.ShopService; -import me.f0reach.vshop.shop.ShopVillagerManager; import me.f0reach.vshop.shop.cache.PlayerCacheService; +import me.f0reach.vshop.shop.entity.ShopEntityService; import me.f0reach.vshop.storage.StorageManager; import me.f0reach.vshop.ui.chest.PlayerPickerUi; import me.f0reach.vshop.ui.dialog.DialogService; @@ -47,19 +47,19 @@ public final class CoOwnerFlow { private final MessageManager messages; private final StorageManager storage; private final ShopService shopService; - private final ShopVillagerManager villagerManager; + private final ShopEntityService shopEntities; private final PlayerPickerUi playerPicker; private final PlayerCacheService playerCache; private final EnumLabels enumLabels; public CoOwnerFlow(DialogService dialogs, MessageManager messages, StorageManager storage, - ShopService shopService, ShopVillagerManager villagerManager, + ShopService shopService, ShopEntityService shopEntities, PlayerPickerUi playerPicker, PlayerCacheService playerCache) { this.dialogs = dialogs; this.messages = messages; this.storage = storage; this.shopService = shopService; - this.villagerManager = villagerManager; + this.shopEntities = shopEntities; this.playerPicker = playerPicker; this.playerCache = playerCache; this.enumLabels = new EnumLabels(messages); @@ -375,7 +375,7 @@ private void performTransfer(Player oldPrimary, Shop shop, OfflinePlayer newPrim // Shop owner_uuid is the PRIMARY's cache → keep in sync. shop.setOwnerUuid(newId); shopService.update(shop); - villagerManager.refreshDisplayName(shop); + shopEntities.refreshDisplayName(shop); oldPrimary.sendMessage(messages.get("coowner.transfer.done", Placeholder.parsed("player", diff --git a/src/main/java/me/f0reach/vshop/shop/edit/ShopActionMenu.java b/src/main/java/me/f0reach/vshop/shop/edit/ShopActionMenu.java index 794e1fc..3b898aa 100644 --- a/src/main/java/me/f0reach/vshop/shop/edit/ShopActionMenu.java +++ b/src/main/java/me/f0reach/vshop/shop/edit/ShopActionMenu.java @@ -281,7 +281,7 @@ private void openRename(Player viewer, Shop shop) { shop.setName(next); try { plugin.shopService().update(shop); - plugin.villagerManager().refreshDisplayName(shop); + plugin.shopEntities().refreshDisplayName(shop); viewer.sendMessage(messages.get("action.rename.done", Placeholder.parsed("name", next))); } catch (SQLException ex) { @@ -325,8 +325,7 @@ private void openProfession(Player viewer, Shop shop) { shop.setProfession(chosen); try { plugin.shopService().update(shop); - Villager v = plugin.villagerManager().findEntity(shop); - if (v != null) plugin.villagerManager().refresh(v, shop, plugin.pluginConfig()); + plugin.shopEntities().refresh(shop); viewer.sendMessage(messages.get("action.profession.done", Placeholder.parsed("profession", professionLabel(chosen)))); } catch (SQLException ex) { diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java new file mode 100644 index 0000000..45003cf --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java @@ -0,0 +1,35 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.model.Shop; +import org.bukkit.Location; + +import java.util.UUID; + +/** + * Strategy for the in-world representation of a shop — the thing a player walks + * up to and clicks. Today the only implementation is {@link VillagerBackend}; + * a FancyNpcs-backed one is planned, which is why callers should depend on + * {@link ShopEntityService} rather than on a concrete backend. + * + *

All methods run on the main thread and are best-effort: if the shop's + * representation is not currently live (unloaded chunk, missing entity), the + * refresh/remove calls are no-ops rather than errors. + */ +public interface ShopEntityBackend { + + /** + * Creates the in-world representation at {@code at} and returns the Bukkit + * entity id to persist on the shop, or {@code null} for backends that have + * no Bukkit entity. The caller owns writing the result to the {@link Shop}. + */ + UUID spawn(Shop shop, Location at); + + /** Re-applies every derived attribute (profession, name, flags) to a live representation. */ + void refresh(Shop shop); + + /** Cheap path for a name-only change: re-renders the custom name and nothing else. */ + void refreshDisplayName(Shop shop); + + /** Despawns the representation. Does not touch persistence. */ + void remove(Shop shop); +} diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java new file mode 100644 index 0000000..66174b5 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java @@ -0,0 +1,55 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.model.Shop; +import org.bukkit.Location; + +import java.util.UUID; + +/** + * Single entry point for manipulating a shop's in-world representation. + * Resolves which {@link ShopEntityBackend} owns a given shop and forwards to it. + * + *

Only {@link VillagerBackend} exists today, so every shop resolves to it. + * When a second backend lands, the resolution rule changes here and callers + * stay untouched. + */ +public final class ShopEntityService implements ShopEntityBackend { + + private final VillagerBackend villagers; + + public ShopEntityService(VillagerBackend villagers) { + this.villagers = villagers; + } + + /** + * The villager backend. Exposed for the genuinely villager-specific bits + * (the PDC key, entity lookup) that have no meaning for other backends. + */ + public VillagerBackend villagers() { + return villagers; + } + + private ShopEntityBackend backendFor(Shop shop) { + return villagers; + } + + @Override + public UUID spawn(Shop shop, Location at) { + return backendFor(shop).spawn(shop, at); + } + + @Override + public void refresh(Shop shop) { + backendFor(shop).refresh(shop); + } + + @Override + public void refreshDisplayName(Shop shop) { + backendFor(shop).refreshDisplayName(shop); + } + + @Override + public void remove(Shop shop) { + backendFor(shop).remove(shop); + } +} diff --git a/src/main/java/me/f0reach/vshop/shop/ShopVillagerManager.java b/src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java similarity index 63% rename from src/main/java/me/f0reach/vshop/shop/ShopVillagerManager.java rename to src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java index 6d61bc9..a2d4139 100644 --- a/src/main/java/me/f0reach/vshop/shop/ShopVillagerManager.java +++ b/src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java @@ -1,4 +1,4 @@ -package me.f0reach.vshop.shop; +package me.f0reach.vshop.shop.entity; import me.f0reach.vshop.config.PluginConfig; import me.f0reach.vshop.locale.MessageManager; @@ -9,6 +9,7 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.NamespacedKey; import org.bukkit.OfflinePlayer; import org.bukkit.attribute.Attribute; import org.bukkit.entity.Villager; @@ -19,48 +20,63 @@ import java.util.UUID; /** - * Spawns / refreshes / removes the Villager entity that represents a shop and - * keeps its appearance (custom name, AI-disabled, invulnerable) in sync with - * the shop record. + * Backs a shop with a real, AI-disabled, invulnerable {@link Villager} and keeps + * its appearance (custom name, profession, flags) in sync with the shop record. */ -public final class ShopVillagerManager { +public final class VillagerBackend implements ShopEntityBackend { public static final String VILLAGER_PDC_KEY = "shop_id"; private final Plugin plugin; private final MessageManager messages; private final CoOwnerRepository coOwnerRepo; - private final org.bukkit.NamespacedKey villagerKey; + private final PluginConfig config; + private final NamespacedKey villagerKey; - public ShopVillagerManager(Plugin plugin, MessageManager messages, CoOwnerRepository coOwnerRepo) { + public VillagerBackend(Plugin plugin, MessageManager messages, CoOwnerRepository coOwnerRepo, + PluginConfig config) { this.plugin = plugin; this.messages = messages; this.coOwnerRepo = coOwnerRepo; - this.villagerKey = new org.bukkit.NamespacedKey(plugin, VILLAGER_PDC_KEY); + this.config = config; + this.villagerKey = new NamespacedKey(plugin, VILLAGER_PDC_KEY); } - public org.bukkit.NamespacedKey villagerKey() { + /** PDC key stamped on every shop villager, for fast event-side identification. */ + public NamespacedKey villagerKey() { return villagerKey; } - /** - * Spawns the villager for a shop and returns its UUID. The caller is - * responsible for persisting the UUID on the {@link Shop}. - */ - public UUID spawn(Shop shop, Location at, PluginConfig config) { - Villager villager = at.getWorld().spawn(at, Villager.class, v -> applyAttributes(v, shop, config)); + @Override + public UUID spawn(Shop shop, Location at) { + Villager villager = at.getWorld().spawn(at, Villager.class, v -> applyAttributes(v, shop)); return villager.getUniqueId(); } - /** - * Re-applies attributes to an already-spawned villager. Useful after a - * profession or name change, or after the shop's PRIMARY changes. - */ - public void refresh(Villager villager, Shop shop, PluginConfig config) { - applyAttributes(villager, shop, config); + @Override + public void refresh(Shop shop) { + Villager v = findEntity(shop); + if (v == null) return; + applyAttributes(v, shop); + } + + @Override + public void refreshDisplayName(Shop shop) { + // Best-effort: if the villager isn't currently loaded, the next chunk-load + // will pick up the change via spawn(). + Villager v = findEntity(shop); + if (v == null) return; + v.customName(buildName(shop)); + v.setCustomNameVisible(true); + } + + @Override + public void remove(Shop shop) { + Villager v = findEntity(shop); + if (v != null) v.remove(); } - private void applyAttributes(Villager v, Shop shop, PluginConfig config) { + private void applyAttributes(Villager v, Shop shop) { v.setAI(false); v.setInvulnerable(true); v.setRemoveWhenFarAway(false); @@ -79,11 +95,11 @@ private void applyAttributes(Villager v, Shop shop, PluginConfig config) { // Mark this villager as belonging to a shop for fast event-side lookup. v.getPersistentDataContainer().set(villagerKey, PersistentDataType.STRING, shop.id().toString()); - v.customName(buildName(shop, config)); + v.customName(buildName(shop)); v.setCustomNameVisible(true); } - public Component buildName(Shop shop, PluginConfig config) { + public Component buildName(Shop shop) { String primaryName = shop.isAdminShop() ? "" : resolvePrimaryName(shop); String format = shop.isAdminShop() ? config.shop().villagerNameFormatAdmin() @@ -114,8 +130,8 @@ private String resolvePrimaryName(Shop shop) { } /** - * Returns the shop villager located at the same UUID as the one persisted - * on the shop record, or null if not loaded. + * Returns the shop villager matching the entity id persisted on the shop + * record, or null if it is not currently loaded. */ public Villager findEntity(Shop shop) { if (shop.villagerEntityId() == null) return null; @@ -124,32 +140,4 @@ public Villager findEntity(Shop shop) { if (entity instanceof Villager v) return v; return null; } - - public void remove(Shop shop) { - Villager v = findEntity(shop); - if (v != null) v.remove(); - } - - /** - * Re-renders the villager's custom name from the (now possibly updated) - * shop name / PRIMARY without touching other attributes. Best-effort: if - * the villager isn't currently loaded, the next chunk-load will pick up - * the change via spawn(). - */ - public void refreshDisplayName(Shop shop) { - Villager v = findEntity(shop); - if (v == null) return; - v.customName(buildName(shop, configSnapshot())); - v.setCustomNameVisible(true); - } - - private PluginConfig configSnapshot() { - // We don't have a config reference in this manager; pull from the running plugin. - Plugin p = plugin; - if (p instanceof me.f0reach.vshop.ModernVillagerShopPlugin mvs) { - return mvs.pluginConfig(); - } - // Fallback — should never be hit at runtime. - return new PluginConfig(plugin.getConfig()); - } } diff --git a/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java b/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java index ac7a077..37c93ba 100644 --- a/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java +++ b/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java @@ -1,13 +1,12 @@ package me.f0reach.vshop.shop.listener; -import me.f0reach.vshop.config.PluginConfig; import me.f0reach.vshop.model.Shop; import me.f0reach.vshop.shop.ShopOpenService; import me.f0reach.vshop.shop.ShopRegistry; import me.f0reach.vshop.shop.ShopService; -import me.f0reach.vshop.shop.ShopVillagerManager; import me.f0reach.vshop.shop.VillagerTeleportGuard; import me.f0reach.vshop.shop.edit.ShopActionMenu; +import me.f0reach.vshop.shop.entity.VillagerBackend; import me.f0reach.vshop.sound.SoundEvents; import me.f0reach.vshop.sound.SoundService; import org.bukkit.NamespacedKey; @@ -40,19 +39,17 @@ public final class ShopVillagerListener implements Listener { private final ShopOpenService openService; private final ShopActionMenu actionMenu; private final NamespacedKey villagerKey; - private final PluginConfig config; private final SoundService sounds; private final VillagerTeleportGuard teleportGuard; - public ShopVillagerListener(ShopRegistry registry, ShopService shops, ShopVillagerManager villagers, - ShopOpenService openService, ShopActionMenu actionMenu, PluginConfig config, + public ShopVillagerListener(ShopRegistry registry, ShopService shops, VillagerBackend villagers, + ShopOpenService openService, ShopActionMenu actionMenu, SoundService sounds, VillagerTeleportGuard teleportGuard) { this.registry = registry; this.shops = shops; this.openService = openService; this.actionMenu = actionMenu; this.villagerKey = villagers.villagerKey(); - this.config = config; this.sounds = sounds; this.teleportGuard = teleportGuard; } @@ -131,7 +128,7 @@ public void onChunkLoad(ChunkLoadEvent event) { if (entity == null) { var at = shop.location().toBukkit(); if (at == null) continue; - UUID newId = shops.villagers().spawn(shop, at, config); + UUID newId = shops.entities().spawn(shop, at); shop.setVillagerEntityId(newId); try { shops.update(shop); From 357cad3401257cb2f6f799196ab396c49be4f11c Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 20:00:23 +0900 Subject: [PATCH 3/9] feat(storage): persist per-shop appearance overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the storage half of the FancyNpcs integration: which backend renders a shop and the cosmetics that go with it (entity type, skin, glow, scale, turn-to-player, equipment, free-form attributes). Two new tables in both dialects. Absence of a shop_appearance row means "plain Villager", so find() returns an empty Optional rather than a default instance and nothing has to be backfilled for existing shops. Equipment lives in a child table and is replaced wholesale on upsert, inside the same transaction as the parent — clearing a slot has to actually remove the row, not leave the old item behind. Equipment slot names and attribute keys are stored as plain strings. Both namespaces belong to FancyNpcs and shift between its releases, so they are validated where they are applied rather than mirrored into an enum here that would silently drift. Decoding is lenient throughout: an entity type or colour that a Minecraft update removed reads back as null instead of breaking load. Also extends the shop-delete cascade and /vshop migrate to cover both tables, and adds them to SchemaInitializerContract so the dialects cannot diverge. Verified against SQLite and MySQL 8.4: 236 tests, 0 failures, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- .../f0reach/vshop/model/ShopAppearance.java | 83 +++++++ .../f0reach/vshop/model/ShopEntityKind.java | 13 ++ .../me/f0reach/vshop/model/SkinVariant.java | 11 + .../f0reach/vshop/storage/StorageManager.java | 7 + .../storage/migrate/MigrationService.java | 4 + .../storage/mysql/MysqlSchemaInitializer.java | 20 ++ .../mysql/MysqlShopAppearanceRepository.java | 157 +++++++++++++ .../storage/mysql/MysqlShopRepository.java | 2 + .../repo/ShopAppearanceRepository.java | 32 +++ .../vshop/storage/repo/ShopAppearanceSql.java | 144 ++++++++++++ .../sqlite/SqliteSchemaInitializer.java | 20 ++ .../SqliteShopAppearanceRepository.java | 157 +++++++++++++ .../storage/sqlite/SqliteShopRepository.java | 2 + .../storage/SchemaInitializerContract.java | 4 +- .../ShopAppearanceRepositoryContract.java | 206 ++++++++++++++++++ .../MysqlShopAppearanceRepositoryTest.java | 10 + .../SqliteShopAppearanceRepositoryTest.java | 7 + 18 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 src/main/java/me/f0reach/vshop/model/ShopAppearance.java create mode 100644 src/main/java/me/f0reach/vshop/model/ShopEntityKind.java create mode 100644 src/main/java/me/f0reach/vshop/model/SkinVariant.java create mode 100644 src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepository.java create mode 100644 src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceRepository.java create mode 100644 src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceSql.java create mode 100644 src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepository.java create mode 100644 src/test/java/me/f0reach/vshop/storage/ShopAppearanceRepositoryContract.java create mode 100644 src/test/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepositoryTest.java create mode 100644 src/test/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepositoryTest.java diff --git a/CLAUDE.md b/CLAUDE.md index f259f4a..f20ad98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ CI runs both backends — see [.github/workflows/ci.yml](.github/workflows/ci.ym - `config` — `PluginConfig` wraps the YAML `FileConfiguration`. Treat it as immutable; `/vshop reload` builds a fresh instance. - `locale` — `MessageManager` loads `lang/messages_.yml`, parses MiniMessage, and is the only place that emits player-facing text. -- `storage` — `StorageManager` owns the `DataSourceProvider` (Hikari) and exposes one repository per concern (`shops()`, `slots()`, `inventory()`, `transactions()`, `notifications()`, `limits()`, `coOwners()`, `playerCache()`, `playerPreferences()`). Repository implementations live under `storage/sqlite` and `storage/mysql`; the SQL-agnostic schema bootstrap is in `storage/repo/SchemaInitializer`. Cross-backend data movement is in `storage/migrate/MigrationService` (invoked by `/vshop migrate`). +- `storage` — `StorageManager` owns the `DataSourceProvider` (Hikari) and exposes one repository per concern (`shops()`, `slots()`, `inventory()`, `appearance()`, `transactions()`, `notifications()`, `limits()`, `coOwners()`, `playerCache()`, `playerPreferences()`). Repository implementations live under `storage/sqlite` and `storage/mysql`; the SQL-agnostic schema bootstrap is in `storage/repo/SchemaInitializer`. Cross-backend data movement is in `storage/migrate/MigrationService` (invoked by `/vshop migrate`). - `economy` — `EconomyService` is the only caller of Vault. Fee/share math is centralized here; never call `Economy` directly elsewhere. - `shop` — domain. `ShopRegistry` is the in-memory authoritative map of `UUID -> Shop`. `ShopService` is the lifecycle coordinator (create/load/delete, persistence + registry + villager state in lockstep). Subpackages mirror flows: `entity` (the shop's in-world representation), `trade` (purchase/sell), `edit` (slot/menu editing), `coowner` (PRIMARY/MANAGER/STAFF), `egg` (spawn-egg crafting), `listener` (Bukkit events that fan into the services), `cache` (player-head/online cache). - `shop/entity` — how a shop shows up in the world. `ShopEntityBackend` is the strategy interface (spawn / refresh / refreshDisplayName / remove); `VillagerBackend` is the only implementation today and owns the live Villager (AI lock, invulnerability, custom name regen, the `shop_id` PDC key). `ShopEntityService` is the facade that resolves a shop to its backend — **depend on the facade, not on a concrete backend**, so a second backend can be added without touching callers. The villager-specific escape hatches (`villagerKey()`, `findEntity()`) live on `VillagerBackend` and are reached via `ShopEntityService#villagers()`. diff --git a/src/main/java/me/f0reach/vshop/model/ShopAppearance.java b/src/main/java/me/f0reach/vshop/model/ShopAppearance.java new file mode 100644 index 0000000..614817e --- /dev/null +++ b/src/main/java/me/f0reach/vshop/model/ShopAppearance.java @@ -0,0 +1,83 @@ +package me.f0reach.vshop.model; + +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.EntityType; +import org.bukkit.inventory.ItemStack; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * How a shop looks in the world, over and above the shop record itself. + * + *

A shop without a row here renders as a plain Villager, so this type only + * ever describes deviations from that default. Every field except + * {@link #backend()} is nullable/optional and means "leave it to the backend + * default" when unset. + * + *

Equipment slots and attribute names are plain strings on purpose: both + * namespaces belong to FancyNpcs and change between its releases, so they are + * validated where they are applied ({@code integration/fancynpcs}) rather than + * mirrored into an enum here that would silently drift. + */ +public final class ShopAppearance { + + private final UUID shopId; + private ShopEntityKind backend; + private EntityType entityType; + private String skin; + private SkinVariant skinVariant; + private boolean glowing; + private NamedTextColor glowColor; + private Float scale; + private Boolean turnToPlayer; + private final Map attributes = new LinkedHashMap<>(); + private final Map equipment = new LinkedHashMap<>(); + private Instant updatedAt; + + public ShopAppearance(UUID shopId, ShopEntityKind backend) { + this.shopId = shopId; + this.backend = backend; + } + + /** The implicit appearance of a shop with no stored row. */ + public static ShopAppearance defaultFor(UUID shopId) { + return new ShopAppearance(shopId, ShopEntityKind.VILLAGER); + } + + public UUID shopId() { return shopId; } + public ShopEntityKind backend() { return backend; } + public EntityType entityType() { return entityType; } + public String skin() { return skin; } + public SkinVariant skinVariant() { return skinVariant; } + public boolean glowing() { return glowing; } + public NamedTextColor glowColor() { return glowColor; } + public Float scale() { return scale; } + public Boolean turnToPlayer() { return turnToPlayer; } + public Instant updatedAt() { return updatedAt; } + + /** Live view of the FancyNpcs attribute overrides, keyed by attribute name. */ + public Map attributes() { return attributes; } + + /** Live view of the equipment, keyed by FancyNpcs equipment-slot name. */ + public Map equipment() { return equipment; } + + public boolean isDefault() { + return backend == ShopEntityKind.VILLAGER + && entityType == null && skin == null && skinVariant == null + && !glowing && glowColor == null && scale == null && turnToPlayer == null + && attributes.isEmpty() && equipment.isEmpty(); + } + + public void setBackend(ShopEntityKind backend) { this.backend = backend; } + public void setEntityType(EntityType entityType) { this.entityType = entityType; } + public void setSkin(String skin) { this.skin = skin; } + public void setSkinVariant(SkinVariant skinVariant) { this.skinVariant = skinVariant; } + public void setGlowing(boolean glowing) { this.glowing = glowing; } + public void setGlowColor(NamedTextColor glowColor) { this.glowColor = glowColor; } + public void setScale(Float scale) { this.scale = scale; } + public void setTurnToPlayer(Boolean turnToPlayer) { this.turnToPlayer = turnToPlayer; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/me/f0reach/vshop/model/ShopEntityKind.java b/src/main/java/me/f0reach/vshop/model/ShopEntityKind.java new file mode 100644 index 0000000..7fbfbe2 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/model/ShopEntityKind.java @@ -0,0 +1,13 @@ +package me.f0reach.vshop.model; + +/** + * Which in-world representation backs a shop. Persisted by name in + * {@code shop_appearance.backend} and resolved by + * {@code shop.entity.ShopEntityService}. + */ +public enum ShopEntityKind { + /** A real, AI-disabled Bukkit Villager. The default and the only fallback. */ + VILLAGER, + /** A packet-based FancyNpcs NPC. Requires the FancyNpcs plugin to be present. */ + FANCY_NPC +} diff --git a/src/main/java/me/f0reach/vshop/model/SkinVariant.java b/src/main/java/me/f0reach/vshop/model/SkinVariant.java new file mode 100644 index 0000000..a9009e4 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/model/SkinVariant.java @@ -0,0 +1,11 @@ +package me.f0reach.vshop.model; + +/** + * Arm model of a player skin. Mirrors the two values FancyNpcs accepts — + * there is no explicit CLASSIC: {@link #AUTO} lets the skin's own metadata + * decide, which is classic for most skins. + */ +public enum SkinVariant { + AUTO, + SLIM +} diff --git a/src/main/java/me/f0reach/vshop/storage/StorageManager.java b/src/main/java/me/f0reach/vshop/storage/StorageManager.java index 03604d9..2d41b05 100644 --- a/src/main/java/me/f0reach/vshop/storage/StorageManager.java +++ b/src/main/java/me/f0reach/vshop/storage/StorageManager.java @@ -6,6 +6,7 @@ import me.f0reach.vshop.storage.mysql.MysqlPlayerCacheRepository; import me.f0reach.vshop.storage.mysql.MysqlPlayerPreferenceRepository; import me.f0reach.vshop.storage.mysql.MysqlSchemaInitializer; +import me.f0reach.vshop.storage.mysql.MysqlShopAppearanceRepository; import me.f0reach.vshop.storage.mysql.MysqlShopInventoryRepository; import me.f0reach.vshop.storage.mysql.MysqlShopLimitRepository; import me.f0reach.vshop.storage.mysql.MysqlShopNotificationRepository; @@ -16,6 +17,7 @@ import me.f0reach.vshop.storage.repo.PlayerCacheRepository; import me.f0reach.vshop.storage.repo.PlayerPreferenceRepository; import me.f0reach.vshop.storage.repo.SchemaInitializer; +import me.f0reach.vshop.storage.repo.ShopAppearanceRepository; import me.f0reach.vshop.storage.repo.ShopInventoryRepository; import me.f0reach.vshop.storage.repo.ShopLimitRepository; import me.f0reach.vshop.storage.repo.ShopNotificationRepository; @@ -26,6 +28,7 @@ import me.f0reach.vshop.storage.sqlite.SqlitePlayerCacheRepository; import me.f0reach.vshop.storage.sqlite.SqlitePlayerPreferenceRepository; import me.f0reach.vshop.storage.sqlite.SqliteSchemaInitializer; +import me.f0reach.vshop.storage.sqlite.SqliteShopAppearanceRepository; import me.f0reach.vshop.storage.sqlite.SqliteShopInventoryRepository; import me.f0reach.vshop.storage.sqlite.SqliteShopLimitRepository; import me.f0reach.vshop.storage.sqlite.SqliteShopNotificationRepository; @@ -49,6 +52,7 @@ public final class StorageManager implements AutoCloseable { private final ShopSlotRepository slots; private final CoOwnerRepository coOwners; private final ShopInventoryRepository inventory; + private final ShopAppearanceRepository appearance; private final ShopLimitRepository limits; private final ShopTransactionRepository transactions; private final ShopNotificationRepository notifications; @@ -66,6 +70,7 @@ public StorageManager(Plugin plugin, PluginConfig config) { this.slots = new SqliteShopSlotRepository(dataSource); this.coOwners = new SqliteCoOwnerRepository(dataSource); this.inventory = new SqliteShopInventoryRepository(dataSource); + this.appearance = new SqliteShopAppearanceRepository(dataSource); this.limits = new SqliteShopLimitRepository(dataSource); this.transactions = new SqliteShopTransactionRepository(dataSource); this.notifications = new SqliteShopNotificationRepository(dataSource); @@ -78,6 +83,7 @@ public StorageManager(Plugin plugin, PluginConfig config) { this.slots = new MysqlShopSlotRepository(dataSource); this.coOwners = new MysqlCoOwnerRepository(dataSource); this.inventory = new MysqlShopInventoryRepository(dataSource); + this.appearance = new MysqlShopAppearanceRepository(dataSource); this.limits = new MysqlShopLimitRepository(dataSource); this.transactions = new MysqlShopTransactionRepository(dataSource); this.notifications = new MysqlShopNotificationRepository(dataSource); @@ -97,6 +103,7 @@ public void initSchema() throws SQLException { public ShopSlotRepository slots() { return slots; } public CoOwnerRepository coOwners() { return coOwners; } public ShopInventoryRepository inventory() { return inventory; } + public ShopAppearanceRepository appearance() { return appearance; } public ShopLimitRepository limits() { return limits; } public ShopTransactionRepository transactions() { return transactions; } public ShopNotificationRepository notifications() { return notifications; } diff --git a/src/main/java/me/f0reach/vshop/storage/migrate/MigrationService.java b/src/main/java/me/f0reach/vshop/storage/migrate/MigrationService.java index ca2e4ab..1ca7dc8 100644 --- a/src/main/java/me/f0reach/vshop/storage/migrate/MigrationService.java +++ b/src/main/java/me/f0reach/vshop/storage/migrate/MigrationService.java @@ -55,6 +55,10 @@ public static int run(ModernVillagerShopPlugin plugin, String fromType, String t for (var inv : source.inventory().findByShop(shop.id())) { dest.inventory().upsert(inv); } + var appearance = source.appearance().find(shop.id()); + if (appearance.isPresent()) { + dest.appearance().upsert(appearance.get()); + } shopsCopied++; } LOG.info("Migration copied " + shopsCopied + " shops from " + from + " to " + to); diff --git a/src/main/java/me/f0reach/vshop/storage/mysql/MysqlSchemaInitializer.java b/src/main/java/me/f0reach/vshop/storage/mysql/MysqlSchemaInitializer.java index deac24e..92421ba 100644 --- a/src/main/java/me/f0reach/vshop/storage/mysql/MysqlSchemaInitializer.java +++ b/src/main/java/me/f0reach/vshop/storage/mysql/MysqlSchemaInitializer.java @@ -80,6 +80,26 @@ private static boolean isDuplicateColumn(SQLException ex) { "created_at BIGINT NOT NULL," + "updated_at BIGINT NOT NULL" + ")" + SUFFIX, + // Cosmetic overrides. A shop with no row here renders as a plain Villager. + "CREATE TABLE IF NOT EXISTS shop_appearance (" + + "shop_id VARCHAR(36) PRIMARY KEY," + + "backend VARCHAR(32) NOT NULL," + + "entity_type VARCHAR(128)," + + "skin VARCHAR(512)," + + "skin_variant VARCHAR(16)," + + "glowing TINYINT(1) NOT NULL DEFAULT 0," + + "glow_color VARCHAR(32)," + + "scale DOUBLE," + + "turn_to_player TINYINT(1)," + + "attributes TEXT," + + "updated_at BIGINT NOT NULL" + + ")" + SUFFIX, + "CREATE TABLE IF NOT EXISTS shop_appearance_equipment (" + + "shop_id VARCHAR(36) NOT NULL," + + "slot VARCHAR(32) NOT NULL," + + "item_data LONGBLOB NOT NULL," + + "PRIMARY KEY (shop_id, slot)" + + ")" + SUFFIX, "CREATE TABLE IF NOT EXISTS shop_co_owners (" + "shop_id VARCHAR(36) NOT NULL," + "player_uuid VARCHAR(36) NOT NULL," + diff --git a/src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepository.java b/src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepository.java new file mode 100644 index 0000000..9d7ed54 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepository.java @@ -0,0 +1,157 @@ +package me.f0reach.vshop.storage.mysql; + +import me.f0reach.vshop.item.ItemStackCodec; +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.storage.repo.ShopAppearanceRepository; +import me.f0reach.vshop.storage.repo.ShopAppearanceSql; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class MysqlShopAppearanceRepository implements ShopAppearanceRepository { + + private final DataSource dataSource; + + public MysqlShopAppearanceRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Optional find(UUID shopId) throws SQLException { + try (Connection c = dataSource.getConnection()) { + ShopAppearance appearance = null; + try (PreparedStatement ps = c.prepareStatement( + "SELECT " + ShopAppearanceSql.COLUMNS + " FROM shop_appearance WHERE shop_id = ?")) { + ps.setString(1, shopId.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) appearance = ShopAppearanceSql.mapAppearance(rs); + } + } + if (appearance == null) return Optional.empty(); + loadEquipment(c, shopId, appearance); + return Optional.of(appearance); + } + } + + @Override + public List findAll() throws SQLException { + Map byShop = new LinkedHashMap<>(); + try (Connection c = dataSource.getConnection()) { + try (PreparedStatement ps = c.prepareStatement( + "SELECT " + ShopAppearanceSql.COLUMNS + " FROM shop_appearance"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + ShopAppearance a = ShopAppearanceSql.mapAppearance(rs); + byShop.put(a.shopId(), a); + } + } + if (byShop.isEmpty()) return List.of(); + // One sweep over the child table beats a query per shop on enable. + try (PreparedStatement ps = c.prepareStatement( + "SELECT shop_id, slot, item_data FROM shop_appearance_equipment"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + ShopAppearance a = byShop.get(UUID.fromString(rs.getString("shop_id"))); + if (a == null) continue; // orphan row; ignored, delete() keeps these from accruing + a.equipment().put(rs.getString("slot"), ItemStackCodec.decode(rs.getBytes("item_data"))); + } + } + } + return new ArrayList<>(byShop.values()); + } + + @Override + public void upsert(ShopAppearance appearance) throws SQLException { + try (Connection c = dataSource.getConnection()) { + c.setAutoCommit(false); + try { + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO shop_appearance (" + ShopAppearanceSql.COLUMNS + ") " + + "VALUES (" + ShopAppearanceSql.PLACEHOLDERS + ") " + + "ON DUPLICATE KEY UPDATE " + + "backend=VALUES(backend), entity_type=VALUES(entity_type), " + + "skin=VALUES(skin), skin_variant=VALUES(skin_variant), " + + "glowing=VALUES(glowing), glow_color=VALUES(glow_color), " + + "scale=VALUES(scale), turn_to_player=VALUES(turn_to_player), " + + "attributes=VALUES(attributes), updated_at=VALUES(updated_at)")) { + ShopAppearanceSql.bindAppearance(ps, appearance); + ps.executeUpdate(); + } + replaceEquipment(c, appearance); + c.commit(); + } catch (SQLException ex) { + c.rollback(); + throw ex; + } finally { + c.setAutoCommit(true); + } + } + } + + @Override + public void delete(UUID shopId) throws SQLException { + try (Connection c = dataSource.getConnection()) { + c.setAutoCommit(false); + try { + deleteEquipment(c, shopId); + try (PreparedStatement ps = c.prepareStatement( + "DELETE FROM shop_appearance WHERE shop_id = ?")) { + ps.setString(1, shopId.toString()); + ps.executeUpdate(); + } + c.commit(); + } catch (SQLException ex) { + c.rollback(); + throw ex; + } finally { + c.setAutoCommit(true); + } + } + } + + private static void loadEquipment(Connection c, UUID shopId, ShopAppearance into) throws SQLException { + try (PreparedStatement ps = c.prepareStatement( + "SELECT slot, item_data FROM shop_appearance_equipment WHERE shop_id = ? ORDER BY slot")) { + ps.setString(1, shopId.toString()); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + into.equipment().put(rs.getString("slot"), ItemStackCodec.decode(rs.getBytes("item_data"))); + } + } + } + } + + /** The equipment set is replaced wholesale so a removed slot actually disappears. */ + private static void replaceEquipment(Connection c, ShopAppearance appearance) throws SQLException { + deleteEquipment(c, appearance.shopId()); + if (appearance.equipment().isEmpty()) return; + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO shop_appearance_equipment (shop_id, slot, item_data) VALUES (?,?,?)")) { + for (var entry : appearance.equipment().entrySet()) { + if (entry.getValue() == null) continue; + ps.setString(1, appearance.shopId().toString()); + ps.setString(2, entry.getKey()); + ps.setBytes(3, ItemStackCodec.encode(entry.getValue())); + ps.addBatch(); + } + ps.executeBatch(); + } + } + + private static void deleteEquipment(Connection c, UUID shopId) throws SQLException { + try (PreparedStatement ps = c.prepareStatement( + "DELETE FROM shop_appearance_equipment WHERE shop_id = ?")) { + ps.setString(1, shopId.toString()); + ps.executeUpdate(); + } + } +} diff --git a/src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopRepository.java b/src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopRepository.java index c6491c6..7b59e56 100644 --- a/src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopRepository.java +++ b/src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopRepository.java @@ -132,6 +132,8 @@ public void delete(UUID shopId) throws SQLException { try (Connection c = dataSource.getConnection()) { c.setAutoCommit(false); try { + deleteOn(c, "shop_appearance_equipment", "shop_id", shopId); + deleteOn(c, "shop_appearance", "shop_id", shopId); deleteOn(c, "shop_co_owners", "shop_id", shopId); deleteOn(c, "shop_slots", "shop_id", shopId); deleteOn(c, "shop_inventory", "shop_id", shopId); diff --git a/src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceRepository.java b/src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceRepository.java new file mode 100644 index 0000000..2d9f31b --- /dev/null +++ b/src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceRepository.java @@ -0,0 +1,32 @@ +package me.f0reach.vshop.storage.repo; + +import me.f0reach.vshop.model.ShopAppearance; + +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for {@code shop_appearance} and its child {@code + * shop_appearance_equipment}. The two are always read and written together — + * an appearance without its equipment is not a meaningful half. + * + *

Absence of a row is meaningful: it means "plain Villager", which is why + * {@link #find(UUID)} returns an empty Optional rather than a default instance. + * Use {@link ShopAppearance#defaultFor(UUID)} at the call site when a concrete + * value is needed. + */ +public interface ShopAppearanceRepository { + + Optional find(UUID shopId) throws SQLException; + + /** Every stored appearance, for bulk load on plugin enable. */ + List findAll() throws SQLException; + + /** Inserts or replaces the appearance and its complete equipment set. */ + void upsert(ShopAppearance appearance) throws SQLException; + + /** Drops the appearance and its equipment, returning the shop to a plain Villager. */ + void delete(UUID shopId) throws SQLException; +} diff --git a/src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceSql.java b/src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceSql.java new file mode 100644 index 0000000..8801462 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceSql.java @@ -0,0 +1,144 @@ +package me.f0reach.vshop.storage.repo; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.model.ShopEntityKind; +import me.f0reach.vshop.model.SkinVariant; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Registry; +import org.bukkit.entity.EntityType; + +import java.lang.reflect.Type; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Column list, row mapping and value codecs shared by the SQLite and MySQL + * {@link ShopAppearanceRepository} implementations. Only the upsert syntax + * differs between the two dialects, so everything else lives here. + * + *

Unrecognised stored values (an entity type or colour removed by a + * Minecraft update, a malformed attribute blob) decode to null/empty rather + * than throwing: a shop must still load if part of its cosmetics went stale. + */ +public final class ShopAppearanceSql { + + public static final String COLUMNS = + "shop_id, backend, entity_type, skin, skin_variant, glowing, glow_color, " + + "scale, turn_to_player, attributes, updated_at"; + + /** Placeholder list matching {@link #COLUMNS}, for the INSERT clause. */ + public static final String PLACEHOLDERS = "?,?,?,?,?,?,?,?,?,?,?"; + + private static final Gson GSON = new Gson(); + private static final Type STRING_MAP = new TypeToken>() {}.getType(); + + private ShopAppearanceSql() {} + + /** Binds the {@link #COLUMNS} values in order, starting at parameter 1. */ + public static void bindAppearance(PreparedStatement ps, ShopAppearance a) throws SQLException { + ps.setString(1, a.shopId().toString()); + ps.setString(2, a.backend().name()); + setNullableString(ps, 3, a.entityType() == null ? null : a.entityType().getKey().toString()); + setNullableString(ps, 4, a.skin()); + setNullableString(ps, 5, a.skinVariant() == null ? null : a.skinVariant().name()); + ps.setInt(6, a.glowing() ? 1 : 0); + setNullableString(ps, 7, encodeColor(a.glowColor())); + if (a.scale() == null) { + ps.setNull(8, Types.DOUBLE); + } else { + ps.setDouble(8, a.scale()); + } + if (a.turnToPlayer() == null) { + ps.setNull(9, Types.INTEGER); + } else { + ps.setInt(9, a.turnToPlayer() ? 1 : 0); + } + setNullableString(ps, 10, encodeAttributes(a.attributes())); + Instant updated = a.updatedAt() == null ? Instant.now() : a.updatedAt(); + ps.setLong(11, updated.toEpochMilli()); + } + + /** Reads one {@code shop_appearance} row. Equipment is loaded separately. */ + public static ShopAppearance mapAppearance(ResultSet rs) throws SQLException { + UUID shopId = UUID.fromString(rs.getString("shop_id")); + ShopAppearance a = new ShopAppearance(shopId, decodeBackend(rs.getString("backend"))); + a.setEntityType(decodeEntityType(rs.getString("entity_type"))); + a.setSkin(rs.getString("skin")); + a.setSkinVariant(decodeSkinVariant(rs.getString("skin_variant"))); + a.setGlowing(rs.getInt("glowing") != 0); + a.setGlowColor(decodeColor(rs.getString("glow_color"))); + + double scale = rs.getDouble("scale"); + a.setScale(rs.wasNull() ? null : (float) scale); + + int turn = rs.getInt("turn_to_player"); + a.setTurnToPlayer(rs.wasNull() ? null : turn != 0); + + a.attributes().putAll(decodeAttributes(rs.getString("attributes"))); + a.setUpdatedAt(Instant.ofEpochMilli(rs.getLong("updated_at"))); + return a; + } + + public static String encodeAttributes(Map attributes) { + return attributes.isEmpty() ? null : GSON.toJson(attributes); + } + + public static Map decodeAttributes(String json) { + if (json == null || json.isBlank()) return Map.of(); + try { + Map parsed = GSON.fromJson(json, STRING_MAP); + return parsed == null ? Map.of() : parsed; + } catch (JsonSyntaxException ex) { + return Map.of(); + } + } + + private static ShopEntityKind decodeBackend(String raw) { + if (raw == null) return ShopEntityKind.VILLAGER; + try { + return ShopEntityKind.valueOf(raw); + } catch (IllegalArgumentException ex) { + return ShopEntityKind.VILLAGER; + } + } + + private static SkinVariant decodeSkinVariant(String raw) { + if (raw == null) return null; + try { + return SkinVariant.valueOf(raw); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static EntityType decodeEntityType(String raw) { + if (raw == null) return null; + org.bukkit.NamespacedKey key = org.bukkit.NamespacedKey.fromString(raw); + return key == null ? null : Registry.ENTITY_TYPE.get(key); + } + + private static String encodeColor(NamedTextColor color) { + return color == null ? null : NamedTextColor.NAMES.key(color); + } + + private static NamedTextColor decodeColor(String raw) { + return raw == null ? null : NamedTextColor.NAMES.value(raw); + } + + private static void setNullableString(PreparedStatement ps, int index, String value) throws SQLException { + if (value == null) { + ps.setNull(index, Types.VARCHAR); + } else { + ps.setString(index, value); + } + } +} diff --git a/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteSchemaInitializer.java b/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteSchemaInitializer.java index 9f315cd..fd8205b 100644 --- a/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteSchemaInitializer.java +++ b/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteSchemaInitializer.java @@ -62,6 +62,26 @@ private static boolean isDuplicateColumn(SQLException ex) { "created_at INTEGER NOT NULL," + "updated_at INTEGER NOT NULL" + ")", + // Cosmetic overrides. A shop with no row here renders as a plain Villager. + "CREATE TABLE IF NOT EXISTS shop_appearance (" + + "shop_id TEXT PRIMARY KEY," + + "backend TEXT NOT NULL," + + "entity_type TEXT," + + "skin TEXT," + + "skin_variant TEXT," + + "glowing INTEGER NOT NULL DEFAULT 0," + + "glow_color TEXT," + + "scale DOUBLE," + + "turn_to_player INTEGER," + + "attributes TEXT," + + "updated_at INTEGER NOT NULL" + + ")", + "CREATE TABLE IF NOT EXISTS shop_appearance_equipment (" + + "shop_id TEXT NOT NULL," + + "slot TEXT NOT NULL," + + "item_data BLOB NOT NULL," + + "PRIMARY KEY (shop_id, slot)" + + ")", "CREATE TABLE IF NOT EXISTS shop_co_owners (" + "shop_id TEXT NOT NULL," + "player_uuid TEXT NOT NULL," + diff --git a/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepository.java b/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepository.java new file mode 100644 index 0000000..1206284 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepository.java @@ -0,0 +1,157 @@ +package me.f0reach.vshop.storage.sqlite; + +import me.f0reach.vshop.item.ItemStackCodec; +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.storage.repo.ShopAppearanceRepository; +import me.f0reach.vshop.storage.repo.ShopAppearanceSql; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class SqliteShopAppearanceRepository implements ShopAppearanceRepository { + + private final DataSource dataSource; + + public SqliteShopAppearanceRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Optional find(UUID shopId) throws SQLException { + try (Connection c = dataSource.getConnection()) { + ShopAppearance appearance = null; + try (PreparedStatement ps = c.prepareStatement( + "SELECT " + ShopAppearanceSql.COLUMNS + " FROM shop_appearance WHERE shop_id = ?")) { + ps.setString(1, shopId.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) appearance = ShopAppearanceSql.mapAppearance(rs); + } + } + if (appearance == null) return Optional.empty(); + loadEquipment(c, shopId, appearance); + return Optional.of(appearance); + } + } + + @Override + public List findAll() throws SQLException { + Map byShop = new LinkedHashMap<>(); + try (Connection c = dataSource.getConnection()) { + try (PreparedStatement ps = c.prepareStatement( + "SELECT " + ShopAppearanceSql.COLUMNS + " FROM shop_appearance"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + ShopAppearance a = ShopAppearanceSql.mapAppearance(rs); + byShop.put(a.shopId(), a); + } + } + if (byShop.isEmpty()) return List.of(); + // One sweep over the child table beats a query per shop on enable. + try (PreparedStatement ps = c.prepareStatement( + "SELECT shop_id, slot, item_data FROM shop_appearance_equipment"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + ShopAppearance a = byShop.get(UUID.fromString(rs.getString("shop_id"))); + if (a == null) continue; // orphan row; ignored, delete() keeps these from accruing + a.equipment().put(rs.getString("slot"), ItemStackCodec.decode(rs.getBytes("item_data"))); + } + } + } + return new ArrayList<>(byShop.values()); + } + + @Override + public void upsert(ShopAppearance appearance) throws SQLException { + try (Connection c = dataSource.getConnection()) { + c.setAutoCommit(false); + try { + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO shop_appearance (" + ShopAppearanceSql.COLUMNS + ") " + + "VALUES (" + ShopAppearanceSql.PLACEHOLDERS + ") " + + "ON CONFLICT(shop_id) DO UPDATE SET " + + "backend=excluded.backend, entity_type=excluded.entity_type, " + + "skin=excluded.skin, skin_variant=excluded.skin_variant, " + + "glowing=excluded.glowing, glow_color=excluded.glow_color, " + + "scale=excluded.scale, turn_to_player=excluded.turn_to_player, " + + "attributes=excluded.attributes, updated_at=excluded.updated_at")) { + ShopAppearanceSql.bindAppearance(ps, appearance); + ps.executeUpdate(); + } + replaceEquipment(c, appearance); + c.commit(); + } catch (SQLException ex) { + c.rollback(); + throw ex; + } finally { + c.setAutoCommit(true); + } + } + } + + @Override + public void delete(UUID shopId) throws SQLException { + try (Connection c = dataSource.getConnection()) { + c.setAutoCommit(false); + try { + deleteEquipment(c, shopId); + try (PreparedStatement ps = c.prepareStatement( + "DELETE FROM shop_appearance WHERE shop_id = ?")) { + ps.setString(1, shopId.toString()); + ps.executeUpdate(); + } + c.commit(); + } catch (SQLException ex) { + c.rollback(); + throw ex; + } finally { + c.setAutoCommit(true); + } + } + } + + private static void loadEquipment(Connection c, UUID shopId, ShopAppearance into) throws SQLException { + try (PreparedStatement ps = c.prepareStatement( + "SELECT slot, item_data FROM shop_appearance_equipment WHERE shop_id = ? ORDER BY slot")) { + ps.setString(1, shopId.toString()); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + into.equipment().put(rs.getString("slot"), ItemStackCodec.decode(rs.getBytes("item_data"))); + } + } + } + } + + /** The equipment set is replaced wholesale so a removed slot actually disappears. */ + private static void replaceEquipment(Connection c, ShopAppearance appearance) throws SQLException { + deleteEquipment(c, appearance.shopId()); + if (appearance.equipment().isEmpty()) return; + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO shop_appearance_equipment (shop_id, slot, item_data) VALUES (?,?,?)")) { + for (var entry : appearance.equipment().entrySet()) { + if (entry.getValue() == null) continue; + ps.setString(1, appearance.shopId().toString()); + ps.setString(2, entry.getKey()); + ps.setBytes(3, ItemStackCodec.encode(entry.getValue())); + ps.addBatch(); + } + ps.executeBatch(); + } + } + + private static void deleteEquipment(Connection c, UUID shopId) throws SQLException { + try (PreparedStatement ps = c.prepareStatement( + "DELETE FROM shop_appearance_equipment WHERE shop_id = ?")) { + ps.setString(1, shopId.toString()); + ps.executeUpdate(); + } + } +} diff --git a/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopRepository.java b/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopRepository.java index 6f50b4e..a5bbbd8 100644 --- a/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopRepository.java +++ b/src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopRepository.java @@ -132,6 +132,8 @@ public void delete(UUID shopId) throws SQLException { try (Connection c = dataSource.getConnection()) { c.setAutoCommit(false); try { + deleteOn(c, "shop_appearance_equipment", "shop_id", shopId); + deleteOn(c, "shop_appearance", "shop_id", shopId); deleteOn(c, "shop_co_owners", "shop_id", shopId); deleteOn(c, "shop_slots", "shop_id", shopId); deleteOn(c, "shop_inventory", "shop_id", shopId); diff --git a/src/test/java/me/f0reach/vshop/storage/SchemaInitializerContract.java b/src/test/java/me/f0reach/vshop/storage/SchemaInitializerContract.java index 063d3d9..2b22af6 100644 --- a/src/test/java/me/f0reach/vshop/storage/SchemaInitializerContract.java +++ b/src/test/java/me/f0reach/vshop/storage/SchemaInitializerContract.java @@ -14,13 +14,15 @@ /** * Catches "added a table to one dialect but not the other" drift. Both - * dialects must end up exposing the same nine logical tables; verifying it + * dialects must end up exposing the same set of logical tables; verifying it * here means a future schema change has to update both sides. */ public abstract class SchemaInitializerContract extends AbstractRepositoryContract { private static final Set EXPECTED = Set.of( "shops", + "shop_appearance", + "shop_appearance_equipment", "shop_co_owners", "shop_slots", "shop_inventory", diff --git a/src/test/java/me/f0reach/vshop/storage/ShopAppearanceRepositoryContract.java b/src/test/java/me/f0reach/vshop/storage/ShopAppearanceRepositoryContract.java new file mode 100644 index 0000000..036652b --- /dev/null +++ b/src/test/java/me/f0reach/vshop/storage/ShopAppearanceRepositoryContract.java @@ -0,0 +1,206 @@ +package me.f0reach.vshop.storage; + +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.model.ShopEntityKind; +import me.f0reach.vshop.model.SkinVariant; +import me.f0reach.vshop.storage.mysql.MysqlShopAppearanceRepository; +import me.f0reach.vshop.storage.repo.ShopAppearanceRepository; +import me.f0reach.vshop.storage.sqlite.SqliteShopAppearanceRepository; +import me.f0reach.vshop.testsupport.AbstractRepositoryContract; +import me.f0reach.vshop.testsupport.BukkitTestSupport; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.entity.EntityType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public abstract class ShopAppearanceRepositoryContract extends AbstractRepositoryContract { + + @BeforeAll + static void bootBukkit() { + BukkitTestSupport.ensureStarted(); + } + + private ShopAppearanceRepository repository() { + return backend() == Backend.SQLITE + ? new SqliteShopAppearanceRepository(dataSource()) + : new MysqlShopAppearanceRepository(dataSource()); + } + + private static ShopAppearance playerNpc(UUID shopId) { + ShopAppearance a = new ShopAppearance(shopId, ShopEntityKind.FANCY_NPC); + a.setEntityType(EntityType.PLAYER); + a.setSkin("Notch"); + a.setSkinVariant(SkinVariant.SLIM); + a.setGlowing(true); + a.setGlowColor(NamedTextColor.AQUA); + a.setScale(1.5f); + a.setTurnToPlayer(true); + a.setUpdatedAt(Instant.now().truncatedTo(ChronoUnit.MILLIS)); + return a; + } + + @Test + void missingShopHasNoAppearance() throws SQLException { + assertTrue(repository().find(UUID.randomUUID()).isEmpty()); + } + + @Test + void roundTripsEveryField() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID shop = UUID.randomUUID(); + ShopAppearance written = playerNpc(shop); + repo.upsert(written); + + ShopAppearance got = repo.find(shop).orElseThrow(); + assertEquals(ShopEntityKind.FANCY_NPC, got.backend()); + assertEquals(EntityType.PLAYER, got.entityType()); + assertEquals("Notch", got.skin()); + assertEquals(SkinVariant.SLIM, got.skinVariant()); + assertTrue(got.glowing()); + assertSame(NamedTextColor.AQUA, got.glowColor()); + assertEquals(1.5f, got.scale()); + assertEquals(Boolean.TRUE, got.turnToPlayer()); + assertEquals(written.updatedAt(), got.updatedAt()); + } + + @Test + void unsetOptionalFieldsStayNull() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID shop = UUID.randomUUID(); + repo.upsert(new ShopAppearance(shop, ShopEntityKind.VILLAGER)); + + ShopAppearance got = repo.find(shop).orElseThrow(); + assertEquals(ShopEntityKind.VILLAGER, got.backend()); + assertNull(got.entityType()); + assertNull(got.skin()); + assertNull(got.skinVariant()); + assertNull(got.glowColor()); + // Nullable-boolean and nullable-float must survive as null, not 0/false. + assertNull(got.scale()); + assertNull(got.turnToPlayer()); + assertTrue(got.attributes().isEmpty()); + assertTrue(got.equipment().isEmpty()); + } + + @Test + void upsertReplacesExistingRow() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID shop = UUID.randomUUID(); + repo.upsert(playerNpc(shop)); + + ShopAppearance second = new ShopAppearance(shop, ShopEntityKind.VILLAGER); + second.setUpdatedAt(Instant.now().truncatedTo(ChronoUnit.MILLIS)); + repo.upsert(second); + + ShopAppearance got = repo.find(shop).orElseThrow(); + assertEquals(ShopEntityKind.VILLAGER, got.backend()); + assertNull(got.skin()); + assertNull(got.scale()); + } + + @Test + void roundTripsEquipment() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID shop = UUID.randomUUID(); + ShopAppearance a = playerNpc(shop); + a.equipment().put("MAINHAND", BukkitTestSupport.item(Material.DIAMOND_SWORD)); + a.equipment().put("HEAD", BukkitTestSupport.item(Material.DIAMOND_HELMET)); + repo.upsert(a); + + ShopAppearance got = repo.find(shop).orElseThrow(); + assertEquals(2, got.equipment().size()); + assertEquals(Material.DIAMOND_SWORD, got.equipment().get("MAINHAND").getType()); + assertEquals(Material.DIAMOND_HELMET, got.equipment().get("HEAD").getType()); + } + + @Test + void upsertReplacesTheWholeEquipmentSet() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID shop = UUID.randomUUID(); + ShopAppearance a = playerNpc(shop); + a.equipment().put("MAINHAND", BukkitTestSupport.item(Material.DIAMOND_SWORD)); + a.equipment().put("HEAD", BukkitTestSupport.item(Material.DIAMOND_HELMET)); + repo.upsert(a); + + // Clearing a slot must actually remove it, not leave the old row behind. + ShopAppearance b = playerNpc(shop); + b.equipment().put("HEAD", BukkitTestSupport.item(Material.GOLDEN_HELMET)); + repo.upsert(b); + + ShopAppearance got = repo.find(shop).orElseThrow(); + assertEquals(1, got.equipment().size()); + assertEquals(Material.GOLDEN_HELMET, got.equipment().get("HEAD").getType()); + } + + @Test + void roundTripsAttributes() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID shop = UUID.randomUUID(); + ShopAppearance a = playerNpc(shop); + a.attributes().put("pose", "sitting"); + a.attributes().put("variant", "warm"); + repo.upsert(a); + + ShopAppearance got = repo.find(shop).orElseThrow(); + assertEquals(2, got.attributes().size()); + assertEquals("sitting", got.attributes().get("pose")); + assertEquals("warm", got.attributes().get("variant")); + } + + @Test + void deleteRemovesAppearanceAndEquipment() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID shop = UUID.randomUUID(); + ShopAppearance a = playerNpc(shop); + a.equipment().put("MAINHAND", BukkitTestSupport.item(Material.DIAMOND_SWORD)); + repo.upsert(a); + + repo.delete(shop); + assertTrue(repo.find(shop).isEmpty()); + + // Re-inserting must not resurrect the old equipment row. + repo.upsert(new ShopAppearance(shop, ShopEntityKind.VILLAGER)); + assertTrue(repo.find(shop).orElseThrow().equipment().isEmpty()); + } + + @Test + void findAllReturnsEveryShopWithItsOwnEquipment() throws SQLException { + ShopAppearanceRepository repo = repository(); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + + ShopAppearance a = playerNpc(first); + a.equipment().put("MAINHAND", BukkitTestSupport.item(Material.DIAMOND_SWORD)); + repo.upsert(a); + + ShopAppearance b = playerNpc(second); + b.equipment().put("HEAD", BukkitTestSupport.item(Material.GOLDEN_HELMET)); + repo.upsert(b); + + List all = repo.findAll(); + assertEquals(2, all.size()); + ShopAppearance loadedFirst = all.stream() + .filter(x -> x.shopId().equals(first)).findFirst().orElseThrow(); + ShopAppearance loadedSecond = all.stream() + .filter(x -> x.shopId().equals(second)).findFirst().orElseThrow(); + assertEquals(List.of("MAINHAND"), List.copyOf(loadedFirst.equipment().keySet())); + assertEquals(List.of("HEAD"), List.copyOf(loadedSecond.equipment().keySet())); + } + + @Test + void findAllOnEmptyTableReturnsEmptyList() throws SQLException { + assertTrue(repository().findAll().isEmpty()); + } +} diff --git a/src/test/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepositoryTest.java b/src/test/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepositoryTest.java new file mode 100644 index 0000000..f1aa791 --- /dev/null +++ b/src/test/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepositoryTest.java @@ -0,0 +1,10 @@ +package me.f0reach.vshop.storage.mysql; + +import me.f0reach.vshop.storage.ShopAppearanceRepositoryContract; +import me.f0reach.vshop.testsupport.TestDatabases; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = TestDatabases.MYSQL_URL_ENV, matches = ".+") +class MysqlShopAppearanceRepositoryTest extends ShopAppearanceRepositoryContract { + @Override protected Backend backend() { return Backend.MYSQL; } +} diff --git a/src/test/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepositoryTest.java b/src/test/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepositoryTest.java new file mode 100644 index 0000000..bf9e663 --- /dev/null +++ b/src/test/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepositoryTest.java @@ -0,0 +1,7 @@ +package me.f0reach.vshop.storage.sqlite; + +import me.f0reach.vshop.storage.ShopAppearanceRepositoryContract; + +class SqliteShopAppearanceRepositoryTest extends ShopAppearanceRepositoryContract { + @Override protected Backend backend() { return Backend.SQLITE; } +} From e2eb9b8fdcdfc072896e5735d384536beaf4b5fb Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 20:53:56 +0900 Subject: [PATCH 4/9] feat(fancynpcs): render shops as NPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the FancyNpcs backend, so a shop whose stored appearance says FANCY_NPC comes up as a packet NPC — a PLAYER skin by default — instead of a Villager. Shape follows what Phase 0 measured against FancyNpcs 2.10.1: - NPCs are invisible to the Bukkit entity API, so spawn() returns no entity id and chunk-load respawn skips NPC-backed shops entirely. - NpcInteractEvent is a synchronous Bukkit event (it comes from a handler for Paper's PlayerUseUnknownEntityEvent), so interaction needs no scheduler hop. - NpcData#setSkin blocks the caller for ~0.7s on a cache miss, so boot warms every distinct skin off-thread and only then builds NPCs on the main thread, where the same lookups are cache hits. - FancyNpcs is not ready during our onEnable, so spawning waits for NpcsLoadedEvent (or runs immediately if we were loaded after boot). NPCs use saveToFile(false) and are rebuilt from shop_appearance every boot, matching the existing "the entity is derived state" invariant: FancyNpcs never persists them, restarts leave no ghosts, and cosmetics stay inside /vshop migrate. They are torn down in onDisable, since nothing else would. Interaction routing moves into ShopInteractionRouter so a clicked villager and a clicked NPC cannot drift apart. Name rendering moves into ShopDisplayName, which serves a Component to Bukkit and a MiniMessage string to FancyNpcs. Everything touching de.oliver.fancynpcs.* is confined to integration/fancynpcs, reached only through ShopEntityIntegration behind an isPluginEnabled guard. If the plugin is missing or fancynpcs.enabled is false, NPC-backed shops fall back to villagers with one warning — cosmetics must never take a shop offline. Verified on Paper 1.21.11 + FancyNpcs 2.10.1-java21+2: NPC created after NpcsLoadedEvent, npcs.yml stays empty, no ghosts across restarts, and the disabled-integration path logs the fallback and loads shops normally. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 +- .../vshop/ModernVillagerShopPlugin.java | 50 ++++- .../me/f0reach/vshop/config/PluginConfig.java | 15 ++ .../fancynpcs/FancyNpcBackend.java | 199 ++++++++++++++++++ .../fancynpcs/FancyNpcListener.java | 43 ++++ .../fancynpcs/FancyNpcsIntegration.java | 161 ++++++++++++++ .../vshop/shop/ShopInteractionRouter.java | 35 +++ .../me/f0reach/vshop/shop/ShopService.java | 2 +- .../shop/entity/ShopAppearanceRegistry.java | 62 ++++++ .../vshop/shop/entity/ShopDisplayName.java | 82 ++++++++ .../shop/entity/ShopEntityIntegration.java | 32 +++ .../vshop/shop/entity/ShopEntityService.java | 53 ++++- .../vshop/shop/entity/VillagerBackend.java | 55 +---- .../shop/listener/ShopVillagerListener.java | 28 +-- src/main/resources/config.yml | 9 + 15 files changed, 750 insertions(+), 80 deletions(-) create mode 100644 src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java create mode 100644 src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcListener.java create mode 100644 src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java create mode 100644 src/main/java/me/f0reach/vshop/shop/ShopInteractionRouter.java create mode 100644 src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceRegistry.java create mode 100644 src/main/java/me/f0reach/vshop/shop/entity/ShopDisplayName.java create mode 100644 src/main/java/me/f0reach/vshop/shop/entity/ShopEntityIntegration.java diff --git a/CLAUDE.md b/CLAUDE.md index f20ad98..57c9327 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,10 +42,10 @@ CI runs both backends — see [.github/workflows/ci.yml](.github/workflows/ci.ym - `storage` — `StorageManager` owns the `DataSourceProvider` (Hikari) and exposes one repository per concern (`shops()`, `slots()`, `inventory()`, `appearance()`, `transactions()`, `notifications()`, `limits()`, `coOwners()`, `playerCache()`, `playerPreferences()`). Repository implementations live under `storage/sqlite` and `storage/mysql`; the SQL-agnostic schema bootstrap is in `storage/repo/SchemaInitializer`. Cross-backend data movement is in `storage/migrate/MigrationService` (invoked by `/vshop migrate`). - `economy` — `EconomyService` is the only caller of Vault. Fee/share math is centralized here; never call `Economy` directly elsewhere. - `shop` — domain. `ShopRegistry` is the in-memory authoritative map of `UUID -> Shop`. `ShopService` is the lifecycle coordinator (create/load/delete, persistence + registry + villager state in lockstep). Subpackages mirror flows: `entity` (the shop's in-world representation), `trade` (purchase/sell), `edit` (slot/menu editing), `coowner` (PRIMARY/MANAGER/STAFF), `egg` (spawn-egg crafting), `listener` (Bukkit events that fan into the services), `cache` (player-head/online cache). -- `shop/entity` — how a shop shows up in the world. `ShopEntityBackend` is the strategy interface (spawn / refresh / refreshDisplayName / remove); `VillagerBackend` is the only implementation today and owns the live Villager (AI lock, invulnerability, custom name regen, the `shop_id` PDC key). `ShopEntityService` is the facade that resolves a shop to its backend — **depend on the facade, not on a concrete backend**, so a second backend can be added without touching callers. The villager-specific escape hatches (`villagerKey()`, `findEntity()`) live on `VillagerBackend` and are reached via `ShopEntityService#villagers()`. +- `shop/entity` — how a shop shows up in the world. `ShopEntityBackend` is the strategy interface (spawn / refresh / refreshDisplayName / remove); `VillagerBackend` owns the live Villager (AI lock, invulnerability, custom name regen, the `shop_id` PDC key) and `integration/fancynpcs/FancyNpcBackend` the packet NPC. `ShopEntityService` is the facade that resolves a shop to its backend from `ShopAppearanceRegistry` — **depend on the facade, not on a concrete backend**. The villager-specific escape hatches (`villagerKey()`, `findEntity()`) live on `VillagerBackend` and are reached via `ShopEntityService#villagers()`. `ShopDisplayName` renders the floating name for both backends (Bukkit wants a `Component`, FancyNpcs a MiniMessage `String`). `ShopEntityIntegration` is the lifecycle contract an optional rendering plugin implements. - `ui` — `ui/dialog` is the BedrockDialog adapter (`DialogService`); `ui/chest` builds the inventory-based browse/edit/restock/player-picker UIs; `ui/text` renders chat output (history, search, list). - `command` — `VShopCommand` builds the Brigadier tree and delegates per-subcommand classes in `command/sub`. -- `integration` — `MvshopPlaceholders` is an optional PAPI expansion, registered only when both the plugin is present and `placeholderapi.enabled` is true. +- `integration` — `MvshopPlaceholders` is an optional PAPI expansion, registered only when both the plugin is present and `placeholderapi.enabled` is true. `integration/fancynpcs` renders shops as FancyNpcs NPCs; it is the **only** place allowed to reference `de.oliver.fancynpcs.*`, and the composition root must never name a class in it outside the `isPluginEnabled("FancyNpcs")` guard (use `ShopEntityIntegration` for the held reference). Three facts drive its shape, all measured: NPCs are invisible to the Bukkit entity API, so nothing chunk- or entity-based applies; `NpcInteractEvent` is a synchronous Bukkit event, so no scheduler hop is needed; and `NpcData#setSkin` blocks the calling thread on a cache miss, so skins are warmed off-thread before spawning. NPCs are created with `saveToFile(false)` and rebuilt from `shop_appearance` at boot — our DB stays authoritative and `/vshop migrate` keeps carrying cosmetics. - `api` — public surface registered to `ServicesManager` (`ModernVillagerShopAPI`); `api/price/PriceRegistry` is the extension point external plugins use to influence prices (read via `shop.trade.PriceResolver`). - `item`, `model` — data carriers (item snapshots, enums, value objects). diff --git a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java index 2f36352..8e6ba8c 100644 --- a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java +++ b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java @@ -8,10 +8,15 @@ import me.f0reach.vshop.economy.EconomyService; import me.f0reach.vshop.integration.MvshopPlaceholders; import me.f0reach.vshop.locale.MessageManager; +import me.f0reach.vshop.shop.ShopInteractionRouter; import me.f0reach.vshop.shop.ShopOpenService; import me.f0reach.vshop.shop.ShopRegistry; import me.f0reach.vshop.shop.ShopService; import me.f0reach.vshop.shop.VillagerTeleportGuard; +import me.f0reach.vshop.shop.entity.ShopAppearanceRegistry; +import me.f0reach.vshop.shop.entity.ShopDisplayName; +import me.f0reach.vshop.shop.entity.ShopEntityBackend; +import me.f0reach.vshop.shop.entity.ShopEntityIntegration; import me.f0reach.vshop.shop.entity.ShopEntityService; import me.f0reach.vshop.shop.entity.VillagerBackend; import me.f0reach.vshop.shop.admin.AdminShopSlotIO; @@ -56,8 +61,12 @@ public final class ModernVillagerShopPlugin extends JavaPlugin { private ShopRegistry registry; private ShopService shopService; private SpawnEggFactory eggFactory; + private ShopDisplayName shopDisplayName; + private ShopAppearanceRegistry shopAppearances; private VillagerBackend villagerBackend; private ShopEntityService shopEntities; + private ShopInteractionRouter interactionRouter; + private ShopEntityIntegration npcIntegration; private DialogService dialogService; private IconConfig iconConfig; private ShopBrowseUi browseUi; @@ -109,8 +118,18 @@ public void onEnable() { this.registry = new ShopRegistry(); this.villagerTeleportGuard = new VillagerTeleportGuard(); - this.villagerBackend = new VillagerBackend(this, messages, storage.coOwners(), config); - this.shopEntities = new ShopEntityService(villagerBackend); + this.shopDisplayName = new ShopDisplayName(this, messages, storage.coOwners(), config); + this.shopAppearances = new ShopAppearanceRegistry(); + this.villagerBackend = new VillagerBackend(this, shopDisplayName); + // Naming FancyNpcsIntegration only inside this guard keeps the JVM from + // resolving FancyNpcs types on servers that do not have the plugin. + ShopEntityBackend npcBackend = null; + if (config.fancyNpcs().enabled() && getServer().getPluginManager().isPluginEnabled("FancyNpcs")) { + this.npcIntegration = new me.f0reach.vshop.integration.fancynpcs.FancyNpcsIntegration( + this, registry, shopAppearances, shopDisplayName, config); + npcBackend = npcIntegration.backend(); + } + this.shopEntities = new ShopEntityService(villagerBackend, shopAppearances, npcBackend); this.shopService = new ShopService(storage, registry, shopEntities, config); this.eggFactory = new SpawnEggFactory(this, messages); this.dialogService = new DialogService(this); @@ -142,14 +161,27 @@ public void onEnable() { try { shopService.loadAll(); + shopAppearances.loadAll(storage.appearance().findAll()); } catch (SQLException ex) { getLogger().severe("Failed to load existing shops: " + ex.getMessage()); } + this.interactionRouter = new ShopInteractionRouter(openService, actionMenu, soundService); + + if (npcIntegration != null) { + npcIntegration.start(interactionRouter); + } else { + int wanted = shopAppearances.countByBackend(me.f0reach.vshop.model.ShopEntityKind.FANCY_NPC); + if (wanted > 0) { + getLogger().warning(wanted + " shop(s) are configured as FancyNpcs NPCs but the " + + "integration is unavailable; they will render as villagers."); + } + } + var pm = getServer().getPluginManager(); pm.registerEvents(new ShopEggListener(this, eggFactory, shopService, messages), this); - pm.registerEvents(new ShopVillagerListener(registry, shopService, villagerBackend, openService, - actionMenu, soundService, villagerTeleportGuard), this); + pm.registerEvents(new ShopVillagerListener(registry, shopService, villagerBackend, + interactionRouter, villagerTeleportGuard), this); pm.registerEvents(new VillagerLookListener(registry, config, villagerTeleportGuard), this); pm.registerEvents(new ShopBrowseListener(this, registry, browseUi, storage, tradeFlow, messages), this); pm.registerEvents(new NotificationFlushListener(this, tradeNotifier), this); @@ -181,6 +213,14 @@ && getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) { @Override public void onDisable() { + if (npcIntegration != null) { + // NPCs are never persisted by FancyNpcs, so they must be taken down + // explicitly or they linger for the rest of the server's life. + try { npcIntegration.shutdown(); } catch (Throwable t) { + getLogger().warning("FancyNpcs shutdown failed: " + t); + } + npcIntegration = null; + } if (papiExpansion != null) { try { papiExpansion.unregister(); } catch (Throwable ignored) {} papiExpansion = null; @@ -220,6 +260,8 @@ public void reloadConfigInternal() { public SpawnEggFactory eggFactory() { return eggFactory; } public VillagerBackend villagerBackend() { return villagerBackend; } public ShopEntityService shopEntities() { return shopEntities; } + public ShopAppearanceRegistry shopAppearances() { return shopAppearances; } + public ShopDisplayName shopDisplayName() { return shopDisplayName; } public DialogService dialogService() { return dialogService; } public ShopBrowseUi browseUi() { return browseUi; } public ShopOpenService openService() { return openService; } diff --git a/src/main/java/me/f0reach/vshop/config/PluginConfig.java b/src/main/java/me/f0reach/vshop/config/PluginConfig.java index 589cb64..a84cead 100644 --- a/src/main/java/me/f0reach/vshop/config/PluginConfig.java +++ b/src/main/java/me/f0reach/vshop/config/PluginConfig.java @@ -34,6 +34,7 @@ public enum CloseWithInventoryMode { DISCARD, DROP, REFUSE } private volatile EconomyConfig economy; private volatile ShopConfig shop; private volatile PlayerCacheConfig playerCache; + private volatile FancyNpcsConfig fancyNpcs; private volatile Set blacklist; private volatile boolean placeholderApiEnabled; private volatile ConfigurationSection uiSection; @@ -93,6 +94,12 @@ public void reload(FileConfiguration cfg) { cfg.getDouble("shop.villagerLook.radius", 6.0)) ); + this.fancyNpcs = new FancyNpcsConfig( + cfg.getBoolean("fancynpcs.enabled", true), + cfg.getBoolean("fancynpcs.turnToPlayer", true), + (float) cfg.getDouble("fancynpcs.interactionCooldown", 0.0) + ); + this.playerCache = new PlayerCacheConfig( cfg.getInt("playerCache.maxEntries", 5000), PlayerCacheSort.valueOf(cfg.getString("playerCache.defaultSort", "LAST_SEEN_DESC").toUpperCase(Locale.ROOT)), @@ -146,6 +153,7 @@ private static Duration parseDuration(String input) { public EconomyConfig economy() { return economy; } public ShopConfig shop() { return shop; } public PlayerCacheConfig playerCache() { return playerCache; } + public FancyNpcsConfig fancyNpcs() { return fancyNpcs; } public Set blacklist() { return blacklist; } public boolean placeholderApiEnabled() { return placeholderApiEnabled; } public ConfigurationSection uiSection() { return uiSection; } @@ -184,5 +192,12 @@ public record ShopConfig( public record VillagerLookConfig(boolean enabled, double radius) {} + /** + * FancyNpcs integration. {@code enabled} is the operator kill-switch — the + * plugin still has to be installed for NPC-backed shops to render at all. + * {@code turnToPlayer} is the default for shops that have not overridden it. + */ + public record FancyNpcsConfig(boolean enabled, boolean turnToPlayer, float interactionCooldown) {} + public record PlayerCacheConfig(int maxEntries, PlayerCacheSort defaultSort, Duration textureTtl) {} } diff --git a/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java new file mode 100644 index 0000000..82c8c39 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java @@ -0,0 +1,199 @@ +package me.f0reach.vshop.integration.fancynpcs; + +import de.oliver.fancynpcs.api.FancyNpcsPlugin; +import de.oliver.fancynpcs.api.Npc; +import de.oliver.fancynpcs.api.NpcAttribute; +import de.oliver.fancynpcs.api.NpcData; +import de.oliver.fancynpcs.api.skins.SkinData; +import de.oliver.fancynpcs.api.skins.SkinLoadException; +import de.oliver.fancynpcs.api.utils.NpcEquipmentSlot; +import me.f0reach.vshop.config.PluginConfig; +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.shop.entity.ShopAppearanceRegistry; +import me.f0reach.vshop.shop.entity.ShopDisplayName; +import me.f0reach.vshop.shop.entity.ShopEntityBackend; +import org.bukkit.Location; +import org.bukkit.entity.EntityType; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; + +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Backs a shop with a FancyNpcs NPC. + * + *

NPCs are packet-based, so unlike {@link me.f0reach.vshop.shop.entity.VillagerBackend} + * there is no Bukkit entity: {@link #spawn} returns null, chunk loading is + * irrelevant, and nothing here is visible to {@code World#getEntities}. + * + *

Our database is authoritative and NPCs are pure derived state — they are + * created with {@code saveToFile(false)} so FancyNpcs never writes them to its + * own {@code npcs.yml}, and rebuilt from {@code shop_appearance} on every boot. + * That keeps a shop's cosmetics inside {@code /vshop migrate} and means a + * crashed server cannot leave orphaned NPCs behind. + * + *

Every FancyNpcs type referenced here is confined to this package, which is + * only loaded once the plugin has been confirmed present. + */ +public final class FancyNpcBackend implements ShopEntityBackend { + + /** Prefix identifying our NPCs inside FancyNpcs' global name space. */ + static final String NAME_PREFIX = "vshop-"; + + /** Stand-in creator for admin shops, which have no owner. */ + private static final UUID NO_CREATOR = new UUID(0L, 0L); + + private final Plugin plugin; + private final ShopDisplayName displayName; + private final ShopAppearanceRegistry appearances; + private final PluginConfig config; + + public FancyNpcBackend(Plugin plugin, ShopDisplayName displayName, + ShopAppearanceRegistry appearances, PluginConfig config) { + this.plugin = plugin; + this.displayName = displayName; + this.appearances = appearances; + this.config = config; + } + + static String npcName(UUID shopId) { + return NAME_PREFIX + shopId; + } + + /** Inverse of {@link #npcName}; null when the NPC is not one of ours. */ + static UUID shopIdOf(String npcName) { + if (npcName == null || !npcName.startsWith(NAME_PREFIX)) return null; + try { + return UUID.fromString(npcName.substring(NAME_PREFIX.length())); + } catch (IllegalArgumentException ex) { + return null; + } + } + + @Override + public UUID spawn(Shop shop, Location at) { + remove(shop); // idempotent: never leave a stale NPC under the same name + + UUID creator = shop.ownerUuid() == null ? NO_CREATOR : shop.ownerUuid(); + NpcData data = new NpcData(npcName(shop.id()), creator, at); + apply(data, shop); + + Npc npc = FancyNpcsPlugin.get().getNpcAdapter().apply(data); + npc.setSaveToFile(false); + FancyNpcsPlugin.get().getNpcManager().registerNpc(npc); + npc.create(); + npc.spawnForAll(); + // Packet entity — there is no Bukkit entity id to persist on the shop. + return null; + } + + @Override + public void refresh(Shop shop) { + Npc npc = find(shop.id()); + if (npc == null) return; + apply(npc.getData(), shop); + // Skin, entity type and equipment are baked into the spawn packets, so a + // plain update() would not show them. Re-send the whole NPC instead. + npc.removeForAll(); + npc.create(); + npc.spawnForAll(); + } + + @Override + public void refreshDisplayName(Shop shop) { + Npc npc = find(shop.id()); + if (npc == null) return; + npc.getData().setDisplayName(displayName.miniMessage(shop)); + npc.updateForAll(); + } + + @Override + public void remove(Shop shop) { + removeById(shop.id()); + } + + void removeById(UUID shopId) { + Npc npc = find(shopId); + if (npc == null) return; + FancyNpcsPlugin.get().getNpcManager().removeNpc(npc); + npc.removeForAll(); + } + + Npc find(UUID shopId) { + return FancyNpcsPlugin.get().getNpcManager().getNpc(npcName(shopId)); + } + + private void apply(NpcData data, Shop shop) { + ShopAppearance a = appearances.getOrDefault(shop.id()); + + data.setDisplayName(displayName.miniMessage(shop)); + data.setType(a.entityType() == null ? EntityType.PLAYER : a.entityType()); + data.setCollidable(false); + data.setShowInTab(false); + data.setTurnToPlayer(a.turnToPlayer() == null + ? config.fancyNpcs().turnToPlayer() + : a.turnToPlayer()); + data.setInteractionCooldown(config.fancyNpcs().interactionCooldown()); + data.setGlowing(a.glowing()); + if (a.glowColor() != null) data.setGlowingColor(a.glowColor()); + if (a.scale() != null) data.setScale(a.scale()); + + applySkin(data, a, shop); + applyEquipment(data, a, shop); + applyAttributes(data, a, shop); + } + + /** + * Skins only exist for PLAYER NPCs. Resolution can hit the network on a cold + * cache, so callers must not reach this from the main thread with an unseen + * skin — see {@code FancyNpcsIntegration}. A resolved SkinData may still + * report {@code hasTexture() == false}; FancyNpcs fills the texture in later + * from its own async queue. + */ + private void applySkin(NpcData data, ShopAppearance a, Shop shop) { + if (a.skin() == null || data.getType() != EntityType.PLAYER) { + data.setSkinData(null); + return; + } + SkinData.SkinVariant variant = a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM + ? SkinData.SkinVariant.SLIM + : SkinData.SkinVariant.AUTO; + try { + data.setSkin(a.skin(), variant); + } catch (SkinLoadException ex) { + plugin.getLogger().warning("Shop " + shop.id() + ": could not load skin '" + + a.skin() + "' (" + ex.getReason() + "); rendering without one"); + data.setSkinData(null); + } + } + + private void applyEquipment(NpcData data, ShopAppearance a, Shop shop) { + data.setEquipment(new java.util.HashMap<>()); + for (Map.Entry entry : a.equipment().entrySet()) { + NpcEquipmentSlot slot = NpcEquipmentSlot.parse(entry.getKey()); + if (slot == null) { + plugin.getLogger().warning("Shop " + shop.id() + ": unknown equipment slot '" + + entry.getKey() + "'; skipped"); + continue; + } + data.addEquipment(slot, entry.getValue()); + } + } + + private void applyAttributes(NpcData data, ShopAppearance a, Shop shop) { + if (a.attributes().isEmpty()) return; + var manager = FancyNpcsPlugin.get().getAttributeManager(); + for (Map.Entry entry : a.attributes().entrySet()) { + NpcAttribute attribute = manager.getAttributeByName(data.getType(), entry.getKey()); + if (attribute == null || !attribute.isValidValue(entry.getValue())) { + plugin.getLogger().log(Level.WARNING, "Shop {0}: attribute {1}={2} is not valid for {3}; skipped", + new Object[]{shop.id(), entry.getKey(), entry.getValue(), data.getType()}); + continue; + } + data.addAttribute(attribute, entry.getValue()); + } + } +} diff --git a/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcListener.java b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcListener.java new file mode 100644 index 0000000..a0b2a72 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcListener.java @@ -0,0 +1,43 @@ +package me.f0reach.vshop.integration.fancynpcs; + +import de.oliver.fancynpcs.api.actions.ActionTrigger; +import de.oliver.fancynpcs.api.events.NpcInteractEvent; +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.shop.ShopInteractionRouter; +import me.f0reach.vshop.shop.ShopRegistry; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +import java.util.UUID; + +/** + * Turns a right-click on a shop NPC into the same flow a right-clicked shop + * villager produces. + * + *

FancyNpcs fires {@code NpcInteractEvent} from its handler for Paper's + * {@code PlayerUseUnknownEntityEvent}. Both are synchronous Bukkit events, so + * this runs on the main thread and needs no scheduler hop. + */ +public final class FancyNpcListener implements Listener { + + private final ShopRegistry registry; + private final ShopInteractionRouter router; + + public FancyNpcListener(ShopRegistry registry, ShopInteractionRouter router) { + this.registry = registry; + this.router = router; + } + + @EventHandler(ignoreCancelled = true) + public void onInteract(NpcInteractEvent event) { + // Left-click stays inert, matching the villager: punching a shop does nothing. + if (event.getInteractionType() != ActionTrigger.RIGHT_CLICK) return; + + UUID shopId = FancyNpcBackend.shopIdOf(event.getNpc().getData().getName()); + if (shopId == null) return; // somebody else's NPC + + Shop shop = registry.byId(shopId).orElse(null); + if (shop == null) return; + router.onRightClick(event.getPlayer(), shop); + } +} diff --git a/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java new file mode 100644 index 0000000..38bbc12 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java @@ -0,0 +1,161 @@ +package me.f0reach.vshop.integration.fancynpcs; + +import de.oliver.fancynpcs.api.FancyNpcsPlugin; +import de.oliver.fancynpcs.api.events.NpcsLoadedEvent; +import de.oliver.fancynpcs.api.skins.SkinData; +import de.oliver.fancynpcs.api.skins.SkinLoadException; +import me.f0reach.vshop.config.PluginConfig; +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.model.ShopEntityKind; +import me.f0reach.vshop.shop.ShopInteractionRouter; +import me.f0reach.vshop.shop.ShopRegistry; +import me.f0reach.vshop.shop.entity.ShopAppearanceRegistry; +import me.f0reach.vshop.shop.entity.ShopDisplayName; +import me.f0reach.vshop.shop.entity.ShopEntityBackend; +import me.f0reach.vshop.shop.entity.ShopEntityIntegration; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.EntityType; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Owns the FancyNpcs side of the plugin: the backend, the interaction listener, + * and the boot/shutdown of every NPC-backed shop. + * + *

NPCs cannot be created during our {@code onEnable} — FancyNpcs has not + * finished loading its own at that point ({@code npcManager.isLoaded()} is + * false, and it fires {@link NpcsLoadedEvent} a few seconds later). So + * {@code start} either spawns immediately or waits for that event. + */ +public final class FancyNpcsIntegration implements ShopEntityIntegration, Listener { + + private final Plugin plugin; + private final ShopRegistry shops; + private final ShopAppearanceRegistry appearances; + private final FancyNpcBackend backend; + + private boolean spawned; + + public FancyNpcsIntegration(Plugin plugin, ShopRegistry shops, ShopAppearanceRegistry appearances, + ShopDisplayName displayName, PluginConfig config) { + this.plugin = plugin; + this.shops = shops; + this.appearances = appearances; + this.backend = new FancyNpcBackend(plugin, displayName, appearances, config); + } + + @Override + public ShopEntityBackend backend() { + return backend; + } + + @Override + public void start(ShopInteractionRouter router) { + Bukkit.getPluginManager().registerEvents(new FancyNpcListener(shops, router), plugin); + if (FancyNpcsPlugin.get().getNpcManager().isLoaded()) { + // Happens when we are (re)loaded by a plugin manager after boot. + spawnAll(); + return; + } + Bukkit.getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onNpcsLoaded(NpcsLoadedEvent event) { + spawnAll(); + } + + @Override + public void shutdown() { + for (Shop shop : npcBackedShops()) { + try { + backend.remove(shop); + } catch (RuntimeException ex) { + plugin.getLogger().warning("Failed to remove NPC for shop " + shop.id() + ": " + ex); + } + } + spawned = false; + } + + private synchronized void spawnAll() { + if (spawned) return; // isLoaded() raced with the event + spawned = true; + + List targets = npcBackedShops(); + if (targets.isEmpty()) return; + + // Resolving a skin blocks the calling thread on a cache miss (~0.7s per + // unseen name against Mojang), so warm the cache off-thread first and + // only then build the NPCs, where the same lookups are a cache hit. + Set skins = new LinkedHashSet<>(); + for (Shop shop : targets) { + ShopAppearance a = appearances.getOrDefault(shop.id()); + EntityType type = a.entityType() == null ? EntityType.PLAYER : a.entityType(); + if (a.skin() != null && type == EntityType.PLAYER) { + skins.add(new SkinRequest(a.skin(), + a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM + ? SkinData.SkinVariant.SLIM : SkinData.SkinVariant.AUTO)); + } + } + + if (skins.isEmpty()) { + spawnOnMain(targets); + return; + } + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + warmSkinCache(skins); + Bukkit.getScheduler().runTask(plugin, () -> spawnOnMain(targets)); + }); + } + + private void warmSkinCache(Set skins) { + var manager = FancyNpcsPlugin.get().getSkinManager(); + for (SkinRequest request : skins) { + try { + manager.getByIdentifier(request.identifier(), request.variant()); + } catch (SkinLoadException ex) { + // Reported again per shop by the backend, with the shop id attached. + plugin.getLogger().warning("Could not pre-load skin '" + request.identifier() + + "': " + ex.getReason()); + } catch (RuntimeException ex) { + plugin.getLogger().warning("Skin pre-load failed for '" + request.identifier() + "': " + ex); + } + } + } + + private void spawnOnMain(List targets) { + int created = 0; + for (Shop shop : targets) { + Location at = shop.location().toBukkit(); + if (at == null) { + plugin.getLogger().warning("Shop " + shop.id() + " wants an NPC but its world is not loaded"); + continue; + } + try { + backend.spawn(shop, at); + created++; + } catch (RuntimeException ex) { + plugin.getLogger().warning("Failed to create NPC for shop " + shop.id() + ": " + ex); + } + } + plugin.getLogger().info("FancyNpcs: created " + created + "/" + targets.size() + " shop NPCs"); + } + + private List npcBackedShops() { + List out = new ArrayList<>(); + for (Shop shop : shops.all()) { + if (appearances.backendOf(shop.id()) == ShopEntityKind.FANCY_NPC) out.add(shop); + } + return out; + } + + private record SkinRequest(String identifier, SkinData.SkinVariant variant) {} +} diff --git a/src/main/java/me/f0reach/vshop/shop/ShopInteractionRouter.java b/src/main/java/me/f0reach/vshop/shop/ShopInteractionRouter.java new file mode 100644 index 0000000..5efa2cd --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/ShopInteractionRouter.java @@ -0,0 +1,35 @@ +package me.f0reach.vshop.shop; + +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.shop.edit.ShopActionMenu; +import me.f0reach.vshop.sound.SoundEvents; +import me.f0reach.vshop.sound.SoundService; +import org.bukkit.entity.Player; + +/** + * What happens when a player right-clicks a shop, independent of what they + * actually clicked. Villagers arrive here from {@code PlayerInteractEntityEvent} + * and FancyNpcs NPCs from {@code NpcInteractEvent}; both must behave the same. + */ +public final class ShopInteractionRouter { + + private final ShopOpenService openService; + private final ShopActionMenu actionMenu; + private final SoundService sounds; + + public ShopInteractionRouter(ShopOpenService openService, ShopActionMenu actionMenu, SoundService sounds) { + this.openService = openService; + this.actionMenu = actionMenu; + this.sounds = sounds; + } + + public void onRightClick(Player viewer, Shop shop) { + // Owners / privileged co-owners get the action menu directly; others see the customer view. + sounds.play(viewer, SoundEvents.UI_OPEN); + if (actionMenu.canShow(viewer, shop)) { + actionMenu.open(viewer, shop); + return; + } + openService.open(viewer, shop); + } +} diff --git a/src/main/java/me/f0reach/vshop/shop/ShopService.java b/src/main/java/me/f0reach/vshop/shop/ShopService.java index 3a1f884..1318b4a 100644 --- a/src/main/java/me/f0reach/vshop/shop/ShopService.java +++ b/src/main/java/me/f0reach/vshop/shop/ShopService.java @@ -132,7 +132,7 @@ public DeleteResult delete(Shop shop) throws SQLException { case DISCARD -> { /* fall through — storage cascade will drop the rows */ } } } - entities.remove(shop); + entities.onShopDeleted(shop); storage.shops().delete(shop.id()); registry.remove(shop.id()); Bukkit.getPluginManager().callEvent(new ShopDeleteEvent(shop)); diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceRegistry.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceRegistry.java new file mode 100644 index 0000000..d7a24d7 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceRegistry.java @@ -0,0 +1,62 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.model.ShopEntityKind; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory mirror of {@code shop_appearance}, so backend resolution on every + * interaction is a map lookup rather than a query. Mirrors {@link + * me.f0reach.vshop.shop.ShopRegistry}'s role for shops. + * + *

A missing entry is the common case and means "plain Villager". + */ +public final class ShopAppearanceRegistry { + + private final Map byShop = new ConcurrentHashMap<>(); + + public void loadAll(Collection appearances) { + byShop.clear(); + for (ShopAppearance a : appearances) byShop.put(a.shopId(), a); + } + + public Optional find(UUID shopId) { + return Optional.ofNullable(byShop.get(shopId)); + } + + /** The appearance for this shop, or the implicit Villager default. */ + public ShopAppearance getOrDefault(UUID shopId) { + ShopAppearance a = byShop.get(shopId); + return a != null ? a : ShopAppearance.defaultFor(shopId); + } + + public ShopEntityKind backendOf(UUID shopId) { + ShopAppearance a = byShop.get(shopId); + return a == null ? ShopEntityKind.VILLAGER : a.backend(); + } + + public void put(ShopAppearance appearance) { + byShop.put(appearance.shopId(), appearance); + } + + public void remove(UUID shopId) { + byShop.remove(shopId); + } + + public Collection all() { + return new java.util.ArrayList<>(byShop.values()); + } + + public int countByBackend(ShopEntityKind kind) { + int n = 0; + for (ShopAppearance a : byShop.values()) { + if (a.backend() == kind) n++; + } + return n; + } +} diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopDisplayName.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopDisplayName.java new file mode 100644 index 0000000..86d36b6 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopDisplayName.java @@ -0,0 +1,82 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.config.PluginConfig; +import me.f0reach.vshop.locale.MessageManager; +import me.f0reach.vshop.model.CoOwner; +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.storage.repo.CoOwnerRepository; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.plugin.Plugin; + +import java.sql.SQLException; +import java.util.UUID; + +/** + * Renders the floating name above a shop from {@code shop.villagerNameFormat}. + * + *

Two outputs because the two backends want different things: Bukkit takes a + * built {@link Component}, while FancyNpcs takes a MiniMessage string that it + * parses itself. + */ +public final class ShopDisplayName { + + private final Plugin plugin; + private final MessageManager messages; + private final CoOwnerRepository coOwnerRepo; + private final PluginConfig config; + + public ShopDisplayName(Plugin plugin, MessageManager messages, CoOwnerRepository coOwnerRepo, + PluginConfig config) { + this.plugin = plugin; + this.messages = messages; + this.coOwnerRepo = coOwnerRepo; + this.config = config; + } + + public Component component(Shop shop) { + return messages.miniMessage().deserialize(format(shop), + Placeholder.parsed("shop_name", shopName(shop)), + Placeholder.parsed("primary", primaryName(shop))); + } + + /** MiniMessage source with the placeholders substituted, left unparsed. */ + public String miniMessage(Shop shop) { + return format(shop) + .replace("", shopName(shop)) + .replace("", primaryName(shop)); + } + + private String format(Shop shop) { + return shop.isAdminShop() + ? config.shop().villagerNameFormatAdmin() + : config.shop().villagerNameFormat(); + } + + private static String shopName(Shop shop) { + return shop.name() == null ? "" : shop.name(); + } + + private String primaryName(Shop shop) { + if (shop.isAdminShop()) return ""; + UUID owner = shop.ownerUuid(); + if (owner == null) { + // Fall back to scanning the co-owner table (e.g. cache miss). + try { + for (CoOwner co : coOwnerRepo.findByShop(shop.id())) { + if (co.role().canDeleteShop()) { + owner = co.playerUuid(); + break; + } + } + } catch (SQLException ex) { + plugin.getLogger().warning("Failed to resolve PRIMARY for shop " + shop.id() + ": " + ex.getMessage()); + } + } + if (owner == null) return ""; + OfflinePlayer op = Bukkit.getOfflinePlayer(owner); + return op.getName() == null ? owner.toString().substring(0, 8) : op.getName(); + } +} diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityIntegration.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityIntegration.java new file mode 100644 index 0000000..a32e697 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityIntegration.java @@ -0,0 +1,32 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.shop.ShopInteractionRouter; + +/** + * An optional third-party plugin that can render shops. + * + *

The interface exists so the composition root can hold a reference to the + * integration without ever naming the concrete class outside a + * "is that plugin installed?" guard — naming it would risk the JVM resolving + * the third-party types on a server where they do not exist. + */ +public interface ShopEntityIntegration { + + /** The backend to register with {@link ShopEntityService}. */ + ShopEntityBackend backend(); + + /** + * Registers interaction handling and brings every shop assigned to this + * backend into the world. Called once shops and appearances are loaded; the + * implementation is responsible for waiting on whatever readiness signal its + * own plugin requires. + * + *

Takes the router as a parameter rather than a constructor argument + * because the router transitively needs the shop action menu, which needs + * services that in turn need the backend this integration provides. + */ + void start(ShopInteractionRouter router); + + /** Removes everything this integration created. Called from {@code onDisable}. */ + void shutdown(); +} diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java index 66174b5..44bb336 100644 --- a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java @@ -1,6 +1,7 @@ package me.f0reach.vshop.shop.entity; import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopEntityKind; import org.bukkit.Location; import java.util.UUID; @@ -9,16 +10,22 @@ * Single entry point for manipulating a shop's in-world representation. * Resolves which {@link ShopEntityBackend} owns a given shop and forwards to it. * - *

Only {@link VillagerBackend} exists today, so every shop resolves to it. - * When a second backend lands, the resolution rule changes here and callers - * stay untouched. + *

Resolution is driven by {@link ShopAppearanceRegistry}. When a shop asks + * for a backend that is not available — FancyNpcs not installed, or disabled in + * config — it silently falls back to the Villager backend: a missing cosmetic + * plugin must never take a shop offline. */ public final class ShopEntityService implements ShopEntityBackend { private final VillagerBackend villagers; + private final ShopAppearanceRegistry appearances; + private final ShopEntityBackend npc; - public ShopEntityService(VillagerBackend villagers) { + public ShopEntityService(VillagerBackend villagers, ShopAppearanceRegistry appearances, + ShopEntityBackend npc) { this.villagers = villagers; + this.appearances = appearances; + this.npc = npc; } /** @@ -29,7 +36,19 @@ public VillagerBackend villagers() { return villagers; } + public ShopAppearanceRegistry appearances() { + return appearances; + } + + /** True when the shop currently renders as something other than a Villager. */ + public boolean isNpcBacked(Shop shop) { + return backendFor(shop) == npc; + } + private ShopEntityBackend backendFor(Shop shop) { + if (npc != null && appearances.backendOf(shop.id()) == ShopEntityKind.FANCY_NPC) { + return npc; + } return villagers; } @@ -52,4 +71,30 @@ public void refreshDisplayName(Shop shop) { public void remove(Shop shop) { backendFor(shop).remove(shop); } + + /** + * Despawns the shop and forgets its appearance. Separate from {@link #remove} + * because a shop being deleted must also drop out of the appearance registry, + * whereas a temporary despawn must not. + */ + public void onShopDeleted(Shop shop) { + remove(shop); + appearances.remove(shop.id()); + } + + /** + * Rebuilds the representation from the current appearance, switching backend + * if it changed. Returns the Bukkit entity id to persist on the shop — null + * for backends without one — so the caller can write it back. + * + *

Removes through both backends because the shop may be mid-switch and + * the old representation is not the one {@link #backendFor} now resolves to. + */ + public UUID respawn(Shop shop) { + Location at = shop.location().toBukkit(); + if (at == null) return shop.villagerEntityId(); + villagers.remove(shop); + if (npc != null) npc.remove(shop); + return spawn(shop, at); + } } diff --git a/src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java b/src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java index a2d4139..513634e 100644 --- a/src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java +++ b/src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java @@ -1,22 +1,14 @@ package me.f0reach.vshop.shop.entity; -import me.f0reach.vshop.config.PluginConfig; -import me.f0reach.vshop.locale.MessageManager; -import me.f0reach.vshop.model.CoOwner; import me.f0reach.vshop.model.Shop; -import me.f0reach.vshop.storage.repo.CoOwnerRepository; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.NamespacedKey; -import org.bukkit.OfflinePlayer; import org.bukkit.attribute.Attribute; import org.bukkit.entity.Villager; import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.Plugin; -import java.sql.SQLException; import java.util.UUID; /** @@ -27,18 +19,11 @@ public final class VillagerBackend implements ShopEntityBackend { public static final String VILLAGER_PDC_KEY = "shop_id"; - private final Plugin plugin; - private final MessageManager messages; - private final CoOwnerRepository coOwnerRepo; - private final PluginConfig config; + private final ShopDisplayName displayName; private final NamespacedKey villagerKey; - public VillagerBackend(Plugin plugin, MessageManager messages, CoOwnerRepository coOwnerRepo, - PluginConfig config) { - this.plugin = plugin; - this.messages = messages; - this.coOwnerRepo = coOwnerRepo; - this.config = config; + public VillagerBackend(Plugin plugin, ShopDisplayName displayName) { + this.displayName = displayName; this.villagerKey = new NamespacedKey(plugin, VILLAGER_PDC_KEY); } @@ -66,7 +51,7 @@ public void refreshDisplayName(Shop shop) { // will pick up the change via spawn(). Villager v = findEntity(shop); if (v == null) return; - v.customName(buildName(shop)); + v.customName(displayName.component(shop)); v.setCustomNameVisible(true); } @@ -95,40 +80,10 @@ private void applyAttributes(Villager v, Shop shop) { // Mark this villager as belonging to a shop for fast event-side lookup. v.getPersistentDataContainer().set(villagerKey, PersistentDataType.STRING, shop.id().toString()); - v.customName(buildName(shop)); + v.customName(displayName.component(shop)); v.setCustomNameVisible(true); } - public Component buildName(Shop shop) { - String primaryName = shop.isAdminShop() ? "" : resolvePrimaryName(shop); - String format = shop.isAdminShop() - ? config.shop().villagerNameFormatAdmin() - : config.shop().villagerNameFormat(); - return messages.miniMessage().deserialize(format, - Placeholder.parsed("shop_name", shop.name() == null ? "" : shop.name()), - Placeholder.parsed("primary", primaryName)); - } - - private String resolvePrimaryName(Shop shop) { - UUID owner = shop.ownerUuid(); - if (owner == null) { - // Fall back to scanning the co-owner table (e.g. cache miss). - try { - for (CoOwner co : coOwnerRepo.findByShop(shop.id())) { - if (co.role().canDeleteShop()) { - owner = co.playerUuid(); - break; - } - } - } catch (SQLException ex) { - plugin.getLogger().warning("Failed to resolve PRIMARY for shop " + shop.id() + ": " + ex.getMessage()); - } - } - if (owner == null) return ""; - OfflinePlayer op = Bukkit.getOfflinePlayer(owner); - return op.getName() == null ? owner.toString().substring(0, 8) : op.getName(); - } - /** * Returns the shop villager matching the entity id persisted on the shop * record, or null if it is not currently loaded. diff --git a/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java b/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java index 37c93ba..658a882 100644 --- a/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java +++ b/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java @@ -1,14 +1,11 @@ package me.f0reach.vshop.shop.listener; import me.f0reach.vshop.model.Shop; -import me.f0reach.vshop.shop.ShopOpenService; +import me.f0reach.vshop.shop.ShopInteractionRouter; import me.f0reach.vshop.shop.ShopRegistry; import me.f0reach.vshop.shop.ShopService; import me.f0reach.vshop.shop.VillagerTeleportGuard; -import me.f0reach.vshop.shop.edit.ShopActionMenu; import me.f0reach.vshop.shop.entity.VillagerBackend; -import me.f0reach.vshop.sound.SoundEvents; -import me.f0reach.vshop.sound.SoundService; import org.bukkit.NamespacedKey; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -36,21 +33,16 @@ public final class ShopVillagerListener implements Listener { private final ShopRegistry registry; private final ShopService shops; - private final ShopOpenService openService; - private final ShopActionMenu actionMenu; private final NamespacedKey villagerKey; - private final SoundService sounds; + private final ShopInteractionRouter router; private final VillagerTeleportGuard teleportGuard; public ShopVillagerListener(ShopRegistry registry, ShopService shops, VillagerBackend villagers, - ShopOpenService openService, ShopActionMenu actionMenu, - SoundService sounds, VillagerTeleportGuard teleportGuard) { + ShopInteractionRouter router, VillagerTeleportGuard teleportGuard) { this.registry = registry; this.shops = shops; - this.openService = openService; - this.actionMenu = actionMenu; this.villagerKey = villagers.villagerKey(); - this.sounds = sounds; + this.router = router; this.teleportGuard = teleportGuard; } @@ -98,13 +90,7 @@ public void onInteract(PlayerInteractEntityEvent event) { Shop shop = registry.byVillager(entity.getUniqueId()).orElse(null); if (shop == null) return; if (!(event.getPlayer() instanceof Player viewer)) return; - // Owners / privileged co-owners get the action menu directly; others see the customer view. - sounds.play(viewer, SoundEvents.UI_OPEN); - if (actionMenu.canShow(viewer, shop)) { - actionMenu.open(viewer, shop); - return; - } - openService.open(viewer, shop); + router.onRightClick(viewer, shop); } @EventHandler @@ -120,6 +106,10 @@ public void onChunkLoad(ChunkLoadEvent event) { int sz = (int) Math.floor(shop.location().z()) >> 4; if (sx != cx || sz != cz) continue; + // NPC-backed shops are packet-based and have no entity id: they are + // spawned once at boot and are unaffected by chunk loading. + if (shops.entities().isNpcBacked(shop)) continue; + UUID villagerId = shop.villagerEntityId(); if (villagerId == null) continue; Entity entity = event.getWorld().getEntities().stream() diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 07841f8..3219433 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -49,6 +49,15 @@ shop: # 何ブロック以内のプレイヤーを追うか radius: 6.0 +# FancyNpcs 連携。ショップの見た目を Villager 以外(主に PLAYER NPC)にする。 +# FancyNpcs プラグインが導入されていない場合は、この設定に関わらず Villager で動作する。 +fancynpcs: + enabled: true + # ショップ側で個別指定がないときに、NPC がプレイヤーの方を向くか + turnToPlayer: true + # 同一プレイヤーの連続クリックを無視する秒数(0 = 無効) + interactionCooldown: 0.0 + playerCache: maxEntries: 5000 defaultSort: LAST_SEEN_DESC # LAST_SEEN_DESC | NAME_ASC From 1c80a9b93cc833eb7312e4f3a9637ebdb915bae2 Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 22:21:13 +0900 Subject: [PATCH 5/9] feat(shop): resolve looked-at shops for both backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /vshop admin export|import ask "which shop are you looking at?" via Player#getTargetEntity, which cannot see an NPC-backed shop: FancyNpcs NPCs are packet-only, so there is no server-side entity for the raycast to hit (measured — getNearbyEntities, World#getEntities and Bukkit#getEntity all come back empty for them). ShopTargeting keeps the entity raycast for villagers and adds a fallback that intersects the look vector with a box placed at the shop anchor. The ray is first clipped at the nearest solid block so a shop behind a wall is not a valid target, matching how getTargetEntity already behaves. The box is the vanilla 0.6x1.8 player hitbox scaled by the shop's scale, feet on the anchor. Bukkit owns the intersection; the tests cover the part that is ours — where the box sits, how scale grows it, and that a nonsensical stored scale cannot collapse it. Co-Authored-By: Claude Opus 5 (1M context) --- .../vshop/ModernVillagerShopPlugin.java | 4 + .../f0reach/vshop/command/CommandSupport.java | 11 +- .../vshop/shop/entity/ShopTargeting.java | 111 ++++++++++++++++++ .../vshop/shop/entity/ShopTargetingTest.java | 84 +++++++++++++ 4 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/main/java/me/f0reach/vshop/shop/entity/ShopTargeting.java create mode 100644 src/test/java/me/f0reach/vshop/shop/entity/ShopTargetingTest.java diff --git a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java index 8e6ba8c..ee6abe6 100644 --- a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java +++ b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java @@ -18,6 +18,7 @@ import me.f0reach.vshop.shop.entity.ShopEntityBackend; import me.f0reach.vshop.shop.entity.ShopEntityIntegration; import me.f0reach.vshop.shop.entity.ShopEntityService; +import me.f0reach.vshop.shop.entity.ShopTargeting; import me.f0reach.vshop.shop.entity.VillagerBackend; import me.f0reach.vshop.shop.admin.AdminShopSlotIO; import me.f0reach.vshop.shop.cache.PlayerCacheService; @@ -65,6 +66,7 @@ public final class ModernVillagerShopPlugin extends JavaPlugin { private ShopAppearanceRegistry shopAppearances; private VillagerBackend villagerBackend; private ShopEntityService shopEntities; + private ShopTargeting shopTargeting; private ShopInteractionRouter interactionRouter; private ShopEntityIntegration npcIntegration; private DialogService dialogService; @@ -130,6 +132,7 @@ public void onEnable() { npcBackend = npcIntegration.backend(); } this.shopEntities = new ShopEntityService(villagerBackend, shopAppearances, npcBackend); + this.shopTargeting = new ShopTargeting(registry, shopEntities); this.shopService = new ShopService(storage, registry, shopEntities, config); this.eggFactory = new SpawnEggFactory(this, messages); this.dialogService = new DialogService(this); @@ -261,6 +264,7 @@ public void reloadConfigInternal() { public VillagerBackend villagerBackend() { return villagerBackend; } public ShopEntityService shopEntities() { return shopEntities; } public ShopAppearanceRegistry shopAppearances() { return shopAppearances; } + public ShopTargeting shopTargeting() { return shopTargeting; } public ShopDisplayName shopDisplayName() { return shopDisplayName; } public DialogService dialogService() { return dialogService; } public ShopBrowseUi browseUi() { return browseUi; } diff --git a/src/main/java/me/f0reach/vshop/command/CommandSupport.java b/src/main/java/me/f0reach/vshop/command/CommandSupport.java index cfd77e9..8939e86 100644 --- a/src/main/java/me/f0reach/vshop/command/CommandSupport.java +++ b/src/main/java/me/f0reach/vshop/command/CommandSupport.java @@ -6,9 +6,7 @@ import me.f0reach.vshop.model.Shop; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; -import org.bukkit.entity.Entity; import org.bukkit.entity.Player; -import org.bukkit.entity.Villager; import java.util.Optional; import java.util.UUID; @@ -63,12 +61,11 @@ public void sendGenericError(CommandSender sender, Throwable ex) { /** * Resolves the admin/player shop the given player is currently looking at, - * by raycasting for the target entity (up to {@code maxDistance} blocks) and - * checking whether it is a Villager registered in {@link me.f0reach.vshop.shop.ShopRegistry}. + * within {@code maxDistance} blocks. Handles both backends — see + * {@link me.f0reach.vshop.shop.entity.ShopTargeting}, which NPC-backed shops + * need because they are invisible to the server-side entity raycast. */ public Optional findShopFromLineOfSight(Player player, int maxDistance) { - Entity target = player.getTargetEntity(maxDistance); - if (!(target instanceof Villager)) return Optional.empty(); - return plugin.registry().byVillager(target.getUniqueId()); + return plugin.shopTargeting().findFromLineOfSight(player, maxDistance); } } diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopTargeting.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopTargeting.java new file mode 100644 index 0000000..b07daf6 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopTargeting.java @@ -0,0 +1,111 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopLocation; +import me.f0reach.vshop.shop.ShopRegistry; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Villager; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; + +import java.util.Optional; +import java.util.UUID; + +/** + * Resolves "which shop is this player looking at?". + * + *

Villager-backed shops go through {@link Player#getTargetEntity(int)}. + * NPC-backed shops cannot: FancyNpcs NPCs are packet-only and invisible to the + * Bukkit entity API — {@code World#getEntities} does not list them and + * {@code Bukkit#getEntity} cannot find them — so there is nothing for the + * server-side raycast to hit. Those are resolved by intersecting the player's + * look vector with a box we place at the shop's anchor ourselves. + */ +public final class ShopTargeting { + + /** Vanilla player hitbox, the shape all but exotic NPC types approximate. */ + private static final double NPC_WIDTH = 0.6; + private static final double NPC_HEIGHT = 1.8; + + private final ShopRegistry registry; + private final ShopEntityService entities; + + public ShopTargeting(ShopRegistry registry, ShopEntityService entities) { + this.registry = registry; + this.entities = entities; + } + + /** + * The shop under the player's crosshair within {@code maxDistance} blocks, + * whichever backend renders it. Blocks occlude: you cannot target a shop + * through a wall. + */ + public Optional findFromLineOfSight(Player player, int maxDistance) { + Entity target = player.getTargetEntity(maxDistance); + if (target instanceof Villager) { + Optional byEntity = registry.byVillager(target.getUniqueId()); + if (byEntity.isPresent()) return byEntity; + } + return findNpcInLineOfSight(player, maxDistance); + } + + private Optional findNpcInLineOfSight(Player player, int maxDistance) { + Location eye = player.getEyeLocation(); + Vector direction = eye.getDirection(); + UUID world = player.getWorld().getUID(); + + // Stop the ray at the first solid block so a shop behind a wall is not + // a valid target, matching how getTargetEntity treats villagers. + double reach = maxDistance; + RayTraceResult blocked = player.getWorld().rayTraceBlocks(eye, direction, maxDistance); + if (blocked != null) { + reach = blocked.getHitPosition().distance(eye.toVector()); + } + + Shop closest = null; + double closestDistance = Double.MAX_VALUE; + for (Shop shop : registry.all()) { + if (!entities.isNpcBacked(shop)) continue; + ShopLocation at = shop.location(); + if (!world.equals(at.worldId())) continue; + + BoundingBox box = boxFor(shop); + RayTraceResult hit = box.rayTrace(eye.toVector(), direction, reach); + if (hit == null) continue; + double distance = hit.getHitPosition().distance(eye.toVector()); + if (distance < closestDistance) { + closestDistance = distance; + closest = shop; + } + } + return Optional.ofNullable(closest); + } + + private BoundingBox boxFor(Shop shop) { + return hitboxOf(shop.location(), scaleOf(shop)); + } + + /** + * The NPC's approximate hitbox: centred on the anchor horizontally, standing + * on it vertically, grown by the shop's scale. + */ + static BoundingBox hitboxOf(ShopLocation at, float scale) { + double halfWidth = NPC_WIDTH * scale / 2.0; + double height = NPC_HEIGHT * scale; + return new BoundingBox( + at.x() - halfWidth, at.y(), at.z() - halfWidth, + at.x() + halfWidth, at.y() + height, at.z() + halfWidth); + } + + /** Zero and negative scales would collapse the box, so they read as "default". */ + static float normaliseScale(Float scale) { + return scale == null || scale <= 0 ? 1.0f : scale; + } + + private float scaleOf(Shop shop) { + return normaliseScale(entities.appearances().getOrDefault(shop.id()).scale()); + } +} diff --git a/src/test/java/me/f0reach/vshop/shop/entity/ShopTargetingTest.java b/src/test/java/me/f0reach/vshop/shop/entity/ShopTargetingTest.java new file mode 100644 index 0000000..7681848 --- /dev/null +++ b/src/test/java/me/f0reach/vshop/shop/entity/ShopTargetingTest.java @@ -0,0 +1,84 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.model.ShopLocation; +import me.f0reach.vshop.testsupport.BukkitTestSupport; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Geometry of the NPC hitbox we raycast against. The intersection itself is + * Bukkit's; what has to be right here is where the box sits and how it scales. + */ +class ShopTargetingTest { + + @BeforeAll + static void bootBukkit() { + BukkitTestSupport.ensureBukkit(); + } + + private static ShopLocation at(double x, double y, double z) { + return new ShopLocation(UUID.randomUUID(), x, y, z, 0f, 0f); + } + + @Test + void hitboxStandsOnTheAnchorAndIsCentredHorizontally() { + BoundingBox box = ShopTargeting.hitboxOf(at(10, 64, -5), 1.0f); + assertEquals(10.0, box.getCenterX(), 1e-9); + assertEquals(-5.0, box.getCenterZ(), 1e-9); + // Feet on the anchor, not centred on it: the shop's y is ground level. + assertEquals(64.0, box.getMinY(), 1e-9); + assertEquals(64.0 + 1.8, box.getMaxY(), 1e-9); + assertEquals(0.6, box.getWidthX(), 1e-9); + assertEquals(0.6, box.getWidthZ(), 1e-9); + } + + @Test + void scaleGrowsTheBoxAroundTheSameAnchor() { + BoundingBox box = ShopTargeting.hitboxOf(at(0, 0, 0), 2.0f); + assertEquals(1.2, box.getWidthX(), 1e-9); + assertEquals(3.6, box.getHeight(), 1e-9); + assertEquals(0.0, box.getMinY(), 1e-9); + } + + @Test + void missingOrNonsensicalScaleFallsBackToOne() { + assertEquals(1.0f, ShopTargeting.normaliseScale(null)); + assertEquals(1.0f, ShopTargeting.normaliseScale(0.0f)); + assertEquals(1.0f, ShopTargeting.normaliseScale(-2.0f)); + assertEquals(1.5f, ShopTargeting.normaliseScale(1.5f)); + } + + @Test + void horizontalLookHitsTheBodyOfAnNpcAhead() { + BoundingBox box = ShopTargeting.hitboxOf(at(0, 64, 5), 1.0f); + // Eye height 1.62 above ground, looking straight down +Z. + Vector eye = new Vector(0, 65.62, 0); + assertNotNull(box.rayTrace(eye, new Vector(0, 0, 1), 10)); + } + + @Test + void lookingPastTheNpcMisses() { + BoundingBox box = ShopTargeting.hitboxOf(at(0, 64, 5), 1.0f); + Vector eye = new Vector(0, 65.62, 0); + // A metre to the side of a 0.6-wide box. + assertNull(box.rayTrace(eye, new Vector(1, 0, 1).normalize(), 10)); + // Correct direction but out of reach. + assertNull(box.rayTrace(eye, new Vector(0, 0, 1), 2)); + } + + @Test + void aTallNpcIsHitWhereADefaultOneWouldBeMissed() { + Vector eye = new Vector(0, 65.62, 0); + Vector up = new Vector(0, 2, 5).normalize(); + assertNull(ShopTargeting.hitboxOf(at(0, 64, 5), 1.0f).rayTrace(eye, up, 10)); + assertNotNull(ShopTargeting.hitboxOf(at(0, 64, 5), 3.0f).rayTrace(eye, up, 10)); + } +} From 3958ece5aa2f5e1bba77cb3ca0db423d3f009107 Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 22:29:53 +0900 Subject: [PATCH 6/9] feat(command): add /vshop appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command-only control over how a shop renders, as requested — no Dialog UI. `/vshop appearance npc [skin]` is the headline: one call switches a shop to a PLAYER NPC, defaulting to the owner's own skin. show | npc [skin] | villager | type | skin [slim] glow [color] | scale | equip [none] attribute | reset ShopAppearanceService is the write path: mutate, persist, re-render, keeping the row, the registry and the thing standing in the world in step. It renders through an async hop because skin resolution blocks its caller — the new ShopEntityBackend#prepare hook does that warm-up off-thread, and the integration now reuses it for boot instead of its own bespoke pass. Success messages fire from the render callback, so "updated" means visibly updated. An appearance mutated back to all-defaults deletes its row rather than storing a no-op, which keeps "no row means plain Villager" true. Guard rails: NPC-only knobs report that FancyNpcs is unavailable instead of silently storing settings nothing will apply; entity types can be restricted by fancynpcs.allowedTypes and scale by fancynpcs.maxScale, both bypassed by modernvillagershop.admin.appearance; and URL skins pull an arbitrary remote image through the server, so they sit behind their own permission. `show` is read-only and therefore console-friendly. Locale keys added to both messages_ja.yml and messages_en.yml, including enum.shop-entity-kind.* and enum.skin-variant.*, with both enums registered in EnumLabelsTest so a future constant cannot ship untranslated. Co-Authored-By: Claude Opus 5 (1M context) --- .../vshop/ModernVillagerShopPlugin.java | 7 + .../f0reach/vshop/command/VShopCommand.java | 4 + .../vshop/command/sub/AppearanceCommand.java | 462 ++++++++++++++++++ .../vshop/command/sub/HelpCommand.java | 2 +- .../me/f0reach/vshop/config/PluginConfig.java | 17 +- .../fancynpcs/FancyNpcBackend.java | 28 +- .../fancynpcs/FancyNpcsIntegration.java | 46 +- .../shop/entity/ShopAppearanceService.java | 114 +++++ .../vshop/shop/entity/ShopEntityBackend.java | 14 +- .../vshop/shop/entity/ShopEntityService.java | 19 +- .../f0reach/vshop/ui/text/AppearanceView.java | 85 ++++ src/main/resources/config.yml | 5 + src/main/resources/lang/messages_en.yml | 35 ++ src/main/resources/lang/messages_ja.yml | 35 ++ src/main/resources/paper-plugin.yml | 11 + .../f0reach/vshop/locale/EnumLabelsTest.java | 8 +- 16 files changed, 839 insertions(+), 53 deletions(-) create mode 100644 src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java create mode 100644 src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceService.java create mode 100644 src/main/java/me/f0reach/vshop/ui/text/AppearanceView.java diff --git a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java index ee6abe6..d46a229 100644 --- a/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java +++ b/src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java @@ -14,6 +14,7 @@ import me.f0reach.vshop.shop.ShopService; import me.f0reach.vshop.shop.VillagerTeleportGuard; import me.f0reach.vshop.shop.entity.ShopAppearanceRegistry; +import me.f0reach.vshop.shop.entity.ShopAppearanceService; import me.f0reach.vshop.shop.entity.ShopDisplayName; import me.f0reach.vshop.shop.entity.ShopEntityBackend; import me.f0reach.vshop.shop.entity.ShopEntityIntegration; @@ -67,6 +68,7 @@ public final class ModernVillagerShopPlugin extends JavaPlugin { private VillagerBackend villagerBackend; private ShopEntityService shopEntities; private ShopTargeting shopTargeting; + private ShopAppearanceService shopAppearanceService; private ShopInteractionRouter interactionRouter; private ShopEntityIntegration npcIntegration; private DialogService dialogService; @@ -134,6 +136,8 @@ public void onEnable() { this.shopEntities = new ShopEntityService(villagerBackend, shopAppearances, npcBackend); this.shopTargeting = new ShopTargeting(registry, shopEntities); this.shopService = new ShopService(storage, registry, shopEntities, config); + this.shopAppearanceService = new ShopAppearanceService(this, storage.appearance(), + shopAppearances, shopEntities, shopService); this.eggFactory = new SpawnEggFactory(this, messages); this.dialogService = new DialogService(this); this.iconConfig = new IconConfig(messages, config); @@ -265,6 +269,9 @@ public void reloadConfigInternal() { public ShopEntityService shopEntities() { return shopEntities; } public ShopAppearanceRegistry shopAppearances() { return shopAppearances; } public ShopTargeting shopTargeting() { return shopTargeting; } + public ShopAppearanceService shopAppearanceService() { return shopAppearanceService; } + /** Whether NPC-backed shops can actually be rendered right now. */ + public boolean hasNpcIntegration() { return npcIntegration != null; } public ShopDisplayName shopDisplayName() { return shopDisplayName; } public DialogService dialogService() { return dialogService; } public ShopBrowseUi browseUi() { return browseUi; } diff --git a/src/main/java/me/f0reach/vshop/command/VShopCommand.java b/src/main/java/me/f0reach/vshop/command/VShopCommand.java index 54a5d26..92e4321 100644 --- a/src/main/java/me/f0reach/vshop/command/VShopCommand.java +++ b/src/main/java/me/f0reach/vshop/command/VShopCommand.java @@ -6,6 +6,7 @@ import me.f0reach.vshop.ModernVillagerShopPlugin; import me.f0reach.vshop.command.sub.AdminExportSlotsCommand; import me.f0reach.vshop.command.sub.AdminImportSlotsCommand; +import me.f0reach.vshop.command.sub.AppearanceCommand; import me.f0reach.vshop.command.sub.CoOwnerCommand; import me.f0reach.vshop.command.sub.EditCommand; import me.f0reach.vshop.command.sub.EggCommand; @@ -33,6 +34,7 @@ public final class VShopCommand { private final ListCommand list; private final OpenCommand open; private final EditCommand edit; + private final AppearanceCommand appearance; private final CoOwnerCommand coowner; private final TransferCommand transfer; private final StatsCommand stats; @@ -50,6 +52,7 @@ public VShopCommand(ModernVillagerShopPlugin plugin) { this.list = new ListCommand(support); this.open = new OpenCommand(support); this.edit = new EditCommand(support); + this.appearance = new AppearanceCommand(support); this.coowner = new CoOwnerCommand(support); this.transfer = new TransferCommand(support); this.stats = new StatsCommand(support); @@ -69,6 +72,7 @@ public LiteralCommandNode build() { .then(list.node()) .then(open.node()) .then(edit.node()) + .then(appearance.node()) .then(coowner.node()) .then(transfer.node()) .then(stats.node()) diff --git a/src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java b/src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java new file mode 100644 index 0000000..cc25ad8 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java @@ -0,0 +1,462 @@ +package me.f0reach.vshop.command.sub; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.BoolArgumentType; +import com.mojang.brigadier.arguments.DoubleArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import me.f0reach.vshop.command.CommandSupport; +import me.f0reach.vshop.config.PluginConfig; +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.model.ShopEntityKind; +import me.f0reach.vshop.model.SkinVariant; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +/** + * {@code /vshop appearance} — command-only control over how a shop is rendered. + * There is deliberately no Dialog UI for this: it is an occasional, + * many-knobbed operation that reads better as a flat command surface. + */ +@SuppressWarnings("UnstableApiUsage") +public final class AppearanceCommand { + + /** + * FancyNpcs' equipment slots. Kept as strings rather than mirroring its enum + * — the set is version-dependent and belongs to that plugin, so these are + * only completions and the backend does the real validation. + */ + private static final List EQUIPMENT_SLOTS = + List.of("MAINHAND", "OFFHAND", "HEAD", "CHEST", "LEGS", "FEET", "BODY", "SADDLE"); + + /** Clears a skin/attribute rather than setting one. */ + private static final String CLEAR_TOKEN = "@none"; + + private final CommandSupport support; + + public AppearanceCommand(CommandSupport support) { + this.support = support; + } + + public LiteralArgumentBuilder node() { + return Commands.literal("appearance") + .requires(s -> s.getSender().hasPermission("modernvillagershop.edit.appearance") + || s.getSender().hasPermission("modernvillagershop.edit.others") + || s.getSender().hasPermission("modernvillagershop.admin.appearance")) + .then(Commands.argument("shopId", StringArgumentType.word()) + .suggests(shopIds()) + .then(Commands.literal("show") + .executes(ctx -> show(ctx, shopId(ctx)))) + .then(Commands.literal("npc") + .executes(ctx -> toNpc(ctx, shopId(ctx), null)) + .then(Commands.argument("skin", StringArgumentType.word()) + .suggests(onlinePlayers()) + .executes(ctx -> toNpc(ctx, shopId(ctx), + StringArgumentType.getString(ctx, "skin"))))) + .then(Commands.literal("villager") + .executes(ctx -> toVillager(ctx, shopId(ctx)))) + .then(Commands.literal("type") + .then(Commands.argument("type", StringArgumentType.word()) + .suggests(entityTypes()) + .executes(ctx -> setType(ctx, shopId(ctx), + StringArgumentType.getString(ctx, "type"))))) + .then(Commands.literal("skin") + .then(Commands.argument("skin", StringArgumentType.string()) + .suggests(onlinePlayers()) + .executes(ctx -> setSkin(ctx, shopId(ctx), + StringArgumentType.getString(ctx, "skin"), false)) + .then(Commands.literal("slim") + .executes(ctx -> setSkin(ctx, shopId(ctx), + StringArgumentType.getString(ctx, "skin"), true))))) + .then(Commands.literal("glow") + .then(Commands.argument("enabled", BoolArgumentType.bool()) + .executes(ctx -> setGlow(ctx, shopId(ctx), + BoolArgumentType.getBool(ctx, "enabled"), null)) + .then(Commands.argument("color", StringArgumentType.word()) + .suggests(colors()) + .executes(ctx -> setGlow(ctx, shopId(ctx), + BoolArgumentType.getBool(ctx, "enabled"), + StringArgumentType.getString(ctx, "color")))))) + .then(Commands.literal("scale") + .then(Commands.argument("scale", DoubleArgumentType.doubleArg(0.05, 10.0)) + .executes(ctx -> setScale(ctx, shopId(ctx), + (float) DoubleArgumentType.getDouble(ctx, "scale"))))) + .then(Commands.literal("equip") + .then(Commands.argument("slot", StringArgumentType.word()) + .suggests(equipmentSlots()) + .executes(ctx -> equip(ctx, shopId(ctx), + StringArgumentType.getString(ctx, "slot"), false)) + .then(Commands.literal("none") + .executes(ctx -> equip(ctx, shopId(ctx), + StringArgumentType.getString(ctx, "slot"), true))))) + .then(Commands.literal("attribute") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("value", StringArgumentType.word()) + .executes(ctx -> setAttribute(ctx, shopId(ctx), + StringArgumentType.getString(ctx, "name"), + StringArgumentType.getString(ctx, "value")))))) + .then(Commands.literal("reset") + .executes(ctx -> reset(ctx, shopId(ctx))))); + } + + // ---- subcommands ---- + + /** Read-only, so the console may run it too — it has no shop role to check. */ + private int show(CommandContext ctx, String shopIdPrefix) { + var sender = ctx.getSource().getSender(); + Shop shop = support.findShopByPrefix(shopIdPrefix); + if (shop == null) { + support.sendShopNotFound(sender, shopIdPrefix); + return 0; + } + if (sender instanceof Player player) { + try { + if (!support.plugin().editService().canEdit(player, shop)) { + player.sendMessage(support.messages().get("shop.edit.no-permission")); + return 0; + } + } catch (SQLException ex) { + support.sendGenericError(player, ex); + return 0; + } + } + new me.f0reach.vshop.ui.text.AppearanceView(support.messages(), support.enumLabels()) + .send(sender, shop, support.plugin().shopAppearanceService().current(shop)); + return Command.SINGLE_SUCCESS; + } + + private int toNpc(CommandContext ctx, String shopIdPrefix, String skin) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + if (!requireIntegration(target)) return 0; + if (skin != null && !allowsSkinSource(target.player(), skin)) return 0; + + String resolvedSkin = skin != null ? skin : defaultSkinFor(target.shop(), target.player()); + return mutate(target, a -> { + a.setBackend(ShopEntityKind.FANCY_NPC); + a.setEntityType(EntityType.PLAYER); + a.setSkin(resolvedSkin); + }, "command.appearance.npc-done", + Placeholder.parsed("shop_name", target.shop().name())); + } + + private int toVillager(CommandContext ctx, String shopIdPrefix) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + return mutate(target, a -> a.setBackend(ShopEntityKind.VILLAGER), + "command.appearance.villager-done", + Placeholder.parsed("shop_name", target.shop().name())); + } + + private int setType(CommandContext ctx, String shopIdPrefix, String raw) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + if (!requireIntegration(target)) return 0; + + EntityType type = parseEntityType(raw); + if (type == null) { + target.player().sendMessage(support.messages().get("command.appearance.invalid-type", + Placeholder.parsed("type", raw))); + return 0; + } + if (!fancyNpcs().allowsType(type) + && !target.player().hasPermission("modernvillagershop.admin.appearance")) { + target.player().sendMessage(support.messages().get("command.appearance.type-not-allowed", + Placeholder.parsed("type", type.name()))); + return 0; + } + return mutate(target, a -> a.setEntityType(type), "command.appearance.updated"); + } + + private int setSkin(CommandContext ctx, String shopIdPrefix, + String skin, boolean slim) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + if (!requireIntegration(target)) return 0; + + if (CLEAR_TOKEN.equalsIgnoreCase(skin)) { + return mutate(target, a -> { + a.setSkin(null); + a.setSkinVariant(null); + }, "command.appearance.updated"); + } + if (!allowsSkinSource(target.player(), skin)) return 0; + + ShopAppearance current = support.plugin().shopAppearanceService().current(target.shop()); + EntityType type = current.entityType() == null ? EntityType.PLAYER : current.entityType(); + if (type != EntityType.PLAYER) { + target.player().sendMessage(support.messages().get("command.appearance.skin-needs-player")); + return 0; + } + return mutate(target, a -> { + a.setSkin(skin); + a.setSkinVariant(slim ? SkinVariant.SLIM : SkinVariant.AUTO); + }, "command.appearance.updated"); + } + + private int setGlow(CommandContext ctx, String shopIdPrefix, + boolean enabled, String colorName) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + + NamedTextColor color = null; + if (colorName != null) { + color = NamedTextColor.NAMES.value(colorName.toLowerCase(Locale.ROOT)); + if (color == null) { + target.player().sendMessage(support.messages().get("command.appearance.invalid-color", + Placeholder.parsed("color", colorName))); + return 0; + } + } + NamedTextColor chosen = color; + return mutate(target, a -> { + a.setGlowing(enabled); + if (chosen != null) a.setGlowColor(chosen); + if (!enabled) a.setGlowColor(null); + }, "command.appearance.updated"); + } + + private int setScale(CommandContext ctx, String shopIdPrefix, float scale) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + + float max = fancyNpcs().maxScale(); + boolean unrestricted = target.player().hasPermission("modernvillagershop.admin.appearance"); + if (!unrestricted && (scale <= 0 || scale > max)) { + target.player().sendMessage(support.messages().get("command.appearance.invalid-scale", + Placeholder.parsed("min", "0.05"), + Placeholder.parsed("max", trim(max)))); + return 0; + } + return mutate(target, a -> a.setScale(scale), "command.appearance.updated"); + } + + private int equip(CommandContext ctx, String shopIdPrefix, + String rawSlot, boolean clear) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + if (!requireIntegration(target)) return 0; + + String slot = rawSlot.toUpperCase(Locale.ROOT); + if (!EQUIPMENT_SLOTS.contains(slot)) { + target.player().sendMessage(support.messages().get("command.appearance.invalid-slot", + Placeholder.parsed("slot", rawSlot))); + return 0; + } + if (clear) { + return mutate(target, a -> a.equipment().remove(slot), + "command.appearance.equip-cleared", Placeholder.parsed("slot", slot)); + } + + ItemStack held = target.player().getInventory().getItemInMainHand(); + if (held.getType().isAir()) { + target.player().sendMessage(support.messages().get("command.appearance.equip-empty-hand")); + return 0; + } + ItemStack copy = held.clone(); + copy.setAmount(1); + return mutate(target, a -> a.equipment().put(slot, copy), + "command.appearance.equip-done", + Placeholder.parsed("slot", slot), + Placeholder.component("item", me.f0reach.vshop.ui.text.Displays.item(copy))); + } + + private int setAttribute(CommandContext ctx, String shopIdPrefix, + String name, String value) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + if (!requireIntegration(target)) return 0; + + if (CLEAR_TOKEN.equalsIgnoreCase(value)) { + return mutate(target, a -> a.attributes().remove(name), + "command.appearance.attribute-cleared", Placeholder.parsed("name", name)); + } + // Attribute names and values belong to FancyNpcs and vary by entity type, + // so they are stored as given; the backend warns and skips invalid ones. + return mutate(target, a -> a.attributes().put(name, value), "command.appearance.updated"); + } + + private int reset(CommandContext ctx, String shopIdPrefix) { + Target target = resolve(ctx, shopIdPrefix); + if (target == null) return 0; + try { + support.plugin().shopAppearanceService().reset(target.shop(), + () -> target.player().sendMessage( + support.messages().get("command.appearance.reset-done"))); + } catch (SQLException ex) { + support.sendGenericError(target.player(), ex); + return 0; + } + return Command.SINGLE_SUCCESS; + } + + // ---- shared plumbing ---- + + private int mutate(Target target, Consumer mutation, String doneKey, + net.kyori.adventure.text.minimessage.tag.resolver.TagResolver... placeholders) { + try { + support.plugin().shopAppearanceService().apply(target.shop(), mutation, + () -> target.player().sendMessage(support.messages().get(doneKey, placeholders))); + } catch (SQLException ex) { + support.sendGenericError(target.player(), ex); + return 0; + } + return Command.SINGLE_SUCCESS; + } + + /** Resolves sender + shop + edit permission, reporting the failure itself. */ + private Target resolve(CommandContext ctx, String shopIdPrefix) { + var sender = ctx.getSource().getSender(); + if (!(sender instanceof Player player)) { + support.sendPlayerOnly(sender); + return null; + } + Shop shop = support.findShopByPrefix(shopIdPrefix); + if (shop == null) { + support.sendShopNotFound(player, shopIdPrefix); + return null; + } + try { + if (!support.plugin().editService().canEdit(player, shop)) { + player.sendMessage(support.messages().get("shop.edit.no-permission")); + return null; + } + } catch (SQLException ex) { + support.sendGenericError(player, ex); + return null; + } + return new Target(player, shop); + } + + /** NPC-only knobs are pointless without the integration; say so instead of silently storing them. */ + private boolean requireIntegration(Target target) { + if (support.plugin().hasNpcIntegration()) return true; + target.player().sendMessage(support.messages().get("command.appearance.unavailable")); + return false; + } + + /** + * URL skins pull an arbitrary remote image through the server, so they sit + * behind their own permission. Plain names and UUIDs go to Mojang only. + */ + private boolean allowsSkinSource(Player player, String skin) { + String lower = skin.toLowerCase(Locale.ROOT); + boolean isUrl = lower.startsWith("http://") || lower.startsWith("https://"); + if (!isUrl || player.hasPermission("modernvillagershop.edit.appearance.url")) return true; + player.sendMessage(support.messages().get("command.appearance.skin-url-no-permission")); + return false; + } + + /** A player shop defaults to its owner's skin; an admin shop keeps whoever ran the command. */ + private String defaultSkinFor(Shop shop, Player actor) { + if (shop.ownerUuid() != null) { + var cached = support.plugin().playerCacheService().findByUuid(shop.ownerUuid()).orElse(null); + if (cached != null) return cached.name(); + var offline = org.bukkit.Bukkit.getOfflinePlayer(shop.ownerUuid()); + if (offline.getName() != null) return offline.getName(); + } + return actor.getName(); + } + + private PluginConfig.FancyNpcsConfig fancyNpcs() { + return support.plugin().pluginConfig().fancyNpcs(); + } + + private static String shopId(CommandContext ctx) { + return StringArgumentType.getString(ctx, "shopId"); + } + + private static EntityType parseEntityType(String raw) { + NamespacedKey key = NamespacedKey.fromString(raw.toLowerCase(Locale.ROOT)); + if (key != null) { + EntityType byKey = Registry.ENTITY_TYPE.get(key); + if (byKey != null) return byKey; + } + try { + return EntityType.valueOf(raw.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static String trim(float value) { + return value == Math.rint(value) ? String.valueOf((int) value) : String.valueOf(value); + } + + // ---- completions ---- + + private SuggestionProvider shopIds() { + return (ctx, builder) -> { + String prefix = builder.getRemaining().toLowerCase(Locale.ROOT); + for (Shop shop : support.plugin().registry().all()) { + String id = shop.id().toString().substring(0, 8); + if (id.startsWith(prefix)) builder.suggest(id, () -> shop.name()); + } + return builder.buildFuture(); + }; + } + + private SuggestionProvider entityTypes() { + return (ctx, builder) -> { + String prefix = builder.getRemaining().toUpperCase(Locale.ROOT); + PluginConfig.FancyNpcsConfig cfg = fancyNpcs(); + for (EntityType type : EntityType.values()) { + if (!type.isSpawnable() || !cfg.allowsType(type)) continue; + if (type.name().startsWith(prefix)) builder.suggest(type.name()); + } + return builder.buildFuture(); + }; + } + + private SuggestionProvider onlinePlayers() { + return (ctx, builder) -> { + String prefix = builder.getRemaining().toLowerCase(Locale.ROOT); + if (CLEAR_TOKEN.startsWith(prefix)) builder.suggest(CLEAR_TOKEN); + for (Player online : org.bukkit.Bukkit.getOnlinePlayers()) { + if (online.getName().toLowerCase(Locale.ROOT).startsWith(prefix)) { + builder.suggest(online.getName()); + } + } + return builder.buildFuture(); + }; + } + + private SuggestionProvider colors() { + return (ctx, builder) -> { + String prefix = builder.getRemaining().toLowerCase(Locale.ROOT); + List names = new ArrayList<>(NamedTextColor.NAMES.keys()); + for (String name : names) { + if (name.startsWith(prefix)) builder.suggest(name); + } + return builder.buildFuture(); + }; + } + + private SuggestionProvider equipmentSlots() { + return (ctx, builder) -> { + String prefix = builder.getRemaining().toUpperCase(Locale.ROOT); + for (String slot : EQUIPMENT_SLOTS) { + if (slot.startsWith(prefix)) builder.suggest(slot); + } + return builder.buildFuture(); + }; + } + + private record Target(Player player, Shop shop) {} +} diff --git a/src/main/java/me/f0reach/vshop/command/sub/HelpCommand.java b/src/main/java/me/f0reach/vshop/command/sub/HelpCommand.java index ddcb85b..d064bc6 100644 --- a/src/main/java/me/f0reach/vshop/command/sub/HelpCommand.java +++ b/src/main/java/me/f0reach/vshop/command/sub/HelpCommand.java @@ -15,7 +15,7 @@ public final class HelpCommand { /** Display order; the text of each line lives in {@code command.help.}. */ private static final List ENTRIES = List.of( - "list", "open", "edit", "coowner", "transfer", "stats", + "list", "open", "edit", "appearance", "coowner", "transfer", "stats", "search", "history", "egg", "migrate", "reload"); private final CommandSupport support; diff --git a/src/main/java/me/f0reach/vshop/config/PluginConfig.java b/src/main/java/me/f0reach/vshop/config/PluginConfig.java index a84cead..edea529 100644 --- a/src/main/java/me/f0reach/vshop/config/PluginConfig.java +++ b/src/main/java/me/f0reach/vshop/config/PluginConfig.java @@ -97,7 +97,9 @@ public void reload(FileConfiguration cfg) { this.fancyNpcs = new FancyNpcsConfig( cfg.getBoolean("fancynpcs.enabled", true), cfg.getBoolean("fancynpcs.turnToPlayer", true), - (float) cfg.getDouble("fancynpcs.interactionCooldown", 0.0) + (float) cfg.getDouble("fancynpcs.interactionCooldown", 0.0), + (float) cfg.getDouble("fancynpcs.maxScale", 2.0), + List.copyOf(cfg.getStringList("fancynpcs.allowedTypes")) ); this.playerCache = new PlayerCacheConfig( @@ -197,7 +199,18 @@ public record VillagerLookConfig(boolean enabled, double radius) {} * plugin still has to be installed for NPC-backed shops to render at all. * {@code turnToPlayer} is the default for shops that have not overridden it. */ - public record FancyNpcsConfig(boolean enabled, boolean turnToPlayer, float interactionCooldown) {} + public record FancyNpcsConfig(boolean enabled, boolean turnToPlayer, float interactionCooldown, + float maxScale, List allowedTypes) { + + /** Empty list = every entity type is allowed. Matching is case-insensitive on the enum name. */ + public boolean allowsType(org.bukkit.entity.EntityType type) { + if (allowedTypes.isEmpty()) return true; + for (String allowed : allowedTypes) { + if (allowed.equalsIgnoreCase(type.name())) return true; + } + return false; + } + } public record PlayerCacheConfig(int maxEntries, PlayerCacheSort defaultSort, Duration textureTtl) {} } diff --git a/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java index 82c8c39..3dcdee6 100644 --- a/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java +++ b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java @@ -122,6 +122,29 @@ void removeById(UUID shopId) { npc.removeForAll(); } + @Override + public void prepare(ShopAppearance a) { + if (a.skin() == null) return; + EntityType type = a.entityType() == null ? EntityType.PLAYER : a.entityType(); + if (type != EntityType.PLAYER) return; + try { + // Populates FancyNpcs' skin cache so the later setSkin() on the main + // thread is a hit rather than a ~0.7s round trip to Mojang. + FancyNpcsPlugin.get().getSkinManager().getByIdentifier(a.skin(), variantOf(a)); + } catch (SkinLoadException ex) { + // Reported again with the shop id attached when the NPC is built. + plugin.getLogger().warning("Could not pre-load skin '" + a.skin() + "': " + ex.getReason()); + } catch (RuntimeException ex) { + plugin.getLogger().warning("Skin pre-load failed for '" + a.skin() + "': " + ex); + } + } + + private static SkinData.SkinVariant variantOf(ShopAppearance a) { + return a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM + ? SkinData.SkinVariant.SLIM + : SkinData.SkinVariant.AUTO; + } + Npc find(UUID shopId) { return FancyNpcsPlugin.get().getNpcManager().getNpc(npcName(shopId)); } @@ -158,11 +181,8 @@ private void applySkin(NpcData data, ShopAppearance a, Shop shop) { data.setSkinData(null); return; } - SkinData.SkinVariant variant = a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM - ? SkinData.SkinVariant.SLIM - : SkinData.SkinVariant.AUTO; try { - data.setSkin(a.skin(), variant); + data.setSkin(a.skin(), variantOf(a)); } catch (SkinLoadException ex) { plugin.getLogger().warning("Shop " + shop.id() + ": could not load skin '" + a.skin() + "' (" + ex.getReason() + "); rendering without one"); diff --git a/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java index 38bbc12..1fdc787 100644 --- a/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java +++ b/src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java @@ -2,11 +2,8 @@ import de.oliver.fancynpcs.api.FancyNpcsPlugin; import de.oliver.fancynpcs.api.events.NpcsLoadedEvent; -import de.oliver.fancynpcs.api.skins.SkinData; -import de.oliver.fancynpcs.api.skins.SkinLoadException; import me.f0reach.vshop.config.PluginConfig; import me.f0reach.vshop.model.Shop; -import me.f0reach.vshop.model.ShopAppearance; import me.f0reach.vshop.model.ShopEntityKind; import me.f0reach.vshop.shop.ShopInteractionRouter; import me.f0reach.vshop.shop.ShopRegistry; @@ -16,15 +13,12 @@ import me.f0reach.vshop.shop.entity.ShopEntityIntegration; import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.entity.EntityType; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.plugin.Plugin; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; /** * Owns the FancyNpcs side of the plugin: the backend, the interaction listener, @@ -92,45 +86,16 @@ private synchronized void spawnAll() { List targets = npcBackedShops(); if (targets.isEmpty()) return; - // Resolving a skin blocks the calling thread on a cache miss (~0.7s per - // unseen name against Mojang), so warm the cache off-thread first and - // only then build the NPCs, where the same lookups are a cache hit. - Set skins = new LinkedHashSet<>(); - for (Shop shop : targets) { - ShopAppearance a = appearances.getOrDefault(shop.id()); - EntityType type = a.entityType() == null ? EntityType.PLAYER : a.entityType(); - if (a.skin() != null && type == EntityType.PLAYER) { - skins.add(new SkinRequest(a.skin(), - a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM - ? SkinData.SkinVariant.SLIM : SkinData.SkinVariant.AUTO)); - } - } - - if (skins.isEmpty()) { - spawnOnMain(targets); - return; - } + // Skin resolution blocks its caller on a cache miss, so warm every + // appearance off-thread and only then build the NPCs on the main thread. Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - warmSkinCache(skins); + for (Shop shop : targets) { + backend.prepare(appearances.getOrDefault(shop.id())); + } Bukkit.getScheduler().runTask(plugin, () -> spawnOnMain(targets)); }); } - private void warmSkinCache(Set skins) { - var manager = FancyNpcsPlugin.get().getSkinManager(); - for (SkinRequest request : skins) { - try { - manager.getByIdentifier(request.identifier(), request.variant()); - } catch (SkinLoadException ex) { - // Reported again per shop by the backend, with the shop id attached. - plugin.getLogger().warning("Could not pre-load skin '" + request.identifier() - + "': " + ex.getReason()); - } catch (RuntimeException ex) { - plugin.getLogger().warning("Skin pre-load failed for '" + request.identifier() + "': " + ex); - } - } - } - private void spawnOnMain(List targets) { int created = 0; for (Shop shop : targets) { @@ -157,5 +122,4 @@ private List npcBackedShops() { return out; } - private record SkinRequest(String identifier, SkinData.SkinVariant variant) {} } diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceService.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceService.java new file mode 100644 index 0000000..af19d6c --- /dev/null +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceService.java @@ -0,0 +1,114 @@ +package me.f0reach.vshop.shop.entity; + +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopAppearance; +import me.f0reach.vshop.model.ShopEntityKind; +import me.f0reach.vshop.shop.ShopService; +import me.f0reach.vshop.storage.repo.ShopAppearanceRepository; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +import java.sql.SQLException; +import java.time.Instant; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.logging.Level; + +/** + * Write path for a shop's appearance: mutate, persist, re-render. + * + *

Callers describe the change and this keeps the three copies in step — the + * {@code shop_appearance} row, the in-memory {@link ShopAppearanceRegistry}, and + * whatever is standing in the world. + */ +public final class ShopAppearanceService { + + private final Plugin plugin; + private final ShopAppearanceRepository repository; + private final ShopAppearanceRegistry registry; + private final ShopEntityService entities; + private final ShopService shops; + + public ShopAppearanceService(Plugin plugin, ShopAppearanceRepository repository, + ShopAppearanceRegistry registry, ShopEntityService entities, + ShopService shops) { + this.plugin = plugin; + this.repository = repository; + this.registry = registry; + this.entities = entities; + this.shops = shops; + } + + /** The shop's stored appearance, or the implicit Villager default. */ + public ShopAppearance current(Shop shop) { + return registry.getOrDefault(shop.id()); + } + + /** + * Applies {@code mutation}, writes it through, and rebuilds the shop's + * representation. {@code onRendered} runs on the main thread once the change + * is actually visible — later than this call returns, because the render may + * need an off-thread skin lookup first. + * + *

An appearance mutated back to all-defaults deletes its row instead of + * storing a no-op, keeping "no row means plain Villager" true. + */ + public void apply(Shop shop, Consumer mutation, Runnable onRendered) throws SQLException { + ShopAppearance appearance = registry.find(shop.id()) + .orElseGet(() -> ShopAppearance.defaultFor(shop.id())); + ShopEntityKind before = appearance.backend(); + + mutation.accept(appearance); + appearance.setUpdatedAt(Instant.now()); + + if (appearance.isDefault()) { + repository.delete(shop.id()); + registry.remove(shop.id()); + } else { + repository.upsert(appearance); + registry.put(appearance); + } + + boolean backendChanged = before != registry.backendOf(shop.id()); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + entities.prepare(shop); + Bukkit.getScheduler().runTask(plugin, () -> { + render(shop, backendChanged); + if (onRendered != null) onRendered.run(); + }); + }); + } + + /** Drops every override and returns the shop to a plain Villager. */ + public void reset(Shop shop, Runnable onRendered) throws SQLException { + apply(shop, appearance -> { + appearance.setBackend(ShopEntityKind.VILLAGER); + appearance.setEntityType(null); + appearance.setSkin(null); + appearance.setSkinVariant(null); + appearance.setGlowing(false); + appearance.setGlowColor(null); + appearance.setScale(null); + appearance.setTurnToPlayer(null); + appearance.attributes().clear(); + appearance.equipment().clear(); + }, onRendered); + } + + private void render(Shop shop, boolean backendChanged) { + if (!backendChanged) { + entities.refresh(shop); + return; + } + // The old representation belongs to the other backend, so respawn tears + // down both. The new entity id (null for NPCs) has to be persisted. + UUID entityId = entities.respawn(shop); + shop.setVillagerEntityId(entityId); + try { + shops.update(shop); + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, + "Failed to persist the new entity id for shop " + shop.id(), ex); + } + } +} diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java index 45003cf..b92b734 100644 --- a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java @@ -1,14 +1,15 @@ package me.f0reach.vshop.shop.entity; import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopAppearance; import org.bukkit.Location; import java.util.UUID; /** * Strategy for the in-world representation of a shop — the thing a player walks - * up to and clicks. Today the only implementation is {@link VillagerBackend}; - * a FancyNpcs-backed one is planned, which is why callers should depend on + * up to and clicks: a real Villager ({@link VillagerBackend}) or a packet NPC + * ({@code integration.fancynpcs.FancyNpcBackend}). Callers depend on * {@link ShopEntityService} rather than on a concrete backend. * *

All methods run on the main thread and are best-effort: if the shop's @@ -32,4 +33,13 @@ public interface ShopEntityBackend { /** Despawns the representation. Does not touch persistence. */ void remove(Shop shop); + + /** + * Off-main-thread warm-up for an appearance that is about to be spawned or + * refreshed. Backends whose rendering needs a slow lookup — FancyNpcs + * resolves a skin name against Mojang, blocking the caller for the better + * part of a second on a cache miss — do it here so the main thread only + * hits the cache. Must be safe to call from any thread, and safe to skip. + */ + default void prepare(ShopAppearance appearance) {} } diff --git a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java index 44bb336..ef8dd29 100644 --- a/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java +++ b/src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java @@ -1,6 +1,7 @@ package me.f0reach.vshop.shop.entity; import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopAppearance; import me.f0reach.vshop.model.ShopEntityKind; import org.bukkit.Location; @@ -42,16 +43,30 @@ public ShopAppearanceRegistry appearances() { /** True when the shop currently renders as something other than a Villager. */ public boolean isNpcBacked(Shop shop) { - return backendFor(shop) == npc; + return npc != null && backendFor(shop) == npc; } private ShopEntityBackend backendFor(Shop shop) { - if (npc != null && appearances.backendOf(shop.id()) == ShopEntityKind.FANCY_NPC) { + return backendFor(shop.id()); + } + + private ShopEntityBackend backendFor(UUID shopId) { + if (npc != null && appearances.backendOf(shopId) == ShopEntityKind.FANCY_NPC) { return npc; } return villagers; } + @Override + public void prepare(ShopAppearance appearance) { + backendFor(appearance.shopId()).prepare(appearance); + } + + /** Off-thread warm-up for this shop's current appearance. See {@link ShopEntityBackend#prepare}. */ + public void prepare(Shop shop) { + prepare(appearances.getOrDefault(shop.id())); + } + @Override public UUID spawn(Shop shop, Location at) { return backendFor(shop).spawn(shop, at); diff --git a/src/main/java/me/f0reach/vshop/ui/text/AppearanceView.java b/src/main/java/me/f0reach/vshop/ui/text/AppearanceView.java new file mode 100644 index 0000000..1ab7fde --- /dev/null +++ b/src/main/java/me/f0reach/vshop/ui/text/AppearanceView.java @@ -0,0 +1,85 @@ +package me.f0reach.vshop.ui.text; + +import me.f0reach.vshop.locale.EnumLabels; +import me.f0reach.vshop.locale.MessageManager; +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopAppearance; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import java.util.Map; + +/** + * Chat rendering for {@code /vshop appearance show}. + * + *

Unset values print the {@code none} placeholder rather than being skipped, + * so the output doubles as a list of the knobs that exist. + */ +public final class AppearanceView { + + private final MessageManager messages; + private final EnumLabels enumLabels; + + public AppearanceView(MessageManager messages, EnumLabels enumLabels) { + this.messages = messages; + this.enumLabels = enumLabels; + } + + public void send(Audience to, Shop shop, ShopAppearance appearance) { + to.sendMessage(messages.get("command.appearance.header", + Placeholder.component("shop_name", + Displays.nameWithHover(Displays.truncate(shop.name(), 32), shop.name())))); + + to.sendMessage(messages.get("command.appearance.line-backend", + Placeholder.component("backend", enumLabels.label(appearance.backend())))); + to.sendMessage(messages.get("command.appearance.line-type", + Placeholder.component("type", text(appearance.entityType() == null + ? null : appearance.entityType().name())))); + to.sendMessage(messages.get("command.appearance.line-skin", + Placeholder.component("skin", text(appearance.skin())), + Placeholder.component("variant", appearance.skinVariant() == null + ? none() : enumLabels.label(appearance.skinVariant())))); + to.sendMessage(messages.get("command.appearance.line-glow", + Placeholder.component("state", state(appearance.glowing())), + Placeholder.component("color", text(colorName(appearance.glowColor()))))); + to.sendMessage(messages.get("command.appearance.line-scale", + Placeholder.component("scale", text(appearance.scale() == null + ? null : String.valueOf(appearance.scale()))))); + to.sendMessage(messages.get("command.appearance.line-turn", + Placeholder.component("state", appearance.turnToPlayer() == null + ? none() : state(appearance.turnToPlayer())))); + + if (appearance.equipment().isEmpty()) { + to.sendMessage(messages.get("command.appearance.line-equipment-empty")); + } else { + for (Map.Entry entry : appearance.equipment().entrySet()) { + to.sendMessage(messages.get("command.appearance.line-equipment", + Placeholder.parsed("slot", entry.getKey()), + Placeholder.component("item", Displays.item(entry.getValue())))); + } + } + for (Map.Entry entry : appearance.attributes().entrySet()) { + to.sendMessage(messages.get("command.appearance.line-attribute", + Placeholder.parsed("name", entry.getKey()), + Placeholder.parsed("value", entry.getValue()))); + } + } + + private Component text(String value) { + return value == null || value.isBlank() ? none() : Component.text(value); + } + + private Component state(boolean on) { + return Component.text(messages.getRaw(on ? "action.state-on" : "action.state-off")); + } + + private Component none() { + return messages.get("command.appearance.none"); + } + + private static String colorName(NamedTextColor color) { + return color == null ? null : NamedTextColor.NAMES.key(color); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3219433..836e462 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -57,6 +57,11 @@ fancynpcs: turnToPlayer: true # 同一プレイヤーの連続クリックを無視する秒数(0 = 無効) interactionCooldown: 0.0 + # /vshop appearance scale で指定できる最大倍率 + maxScale: 2.0 + # NPC に指定できるエンティティタイプのホワイトリスト。空 = 全許可。 + # modernvillagershop.admin.appearance 保持者はこの制限を受けない。 + allowedTypes: [] playerCache: maxEntries: 5000 diff --git a/src/main/resources/lang/messages_en.yml b/src/main/resources/lang/messages_en.yml index 2553830..1ac3390 100644 --- a/src/main/resources/lang/messages_en.yml +++ b/src/main/resources/lang/messages_en.yml @@ -6,6 +6,7 @@ command: list: "/vshop list [page] - List shops" open: "/vshop open - Open a shop" edit: "/vshop edit - Open the editor UI" + appearance: "/vshop appearance - Change how the shop looks (npc/villager/type/skin/glow/scale/equip/attribute/show/reset)" coowner: "/vshop coowner - Manage co-owners" transfer: "/vshop transfer - Transfer ownership" stats: "/vshop stats - Shop statistics" @@ -48,6 +49,34 @@ command: player-not-found: "Player not found: " line: "

Only acts when the entity is actually reachable. If its chunk is not + * loaded the id has to stay put — clearing it would orphan the villager with + * nothing left pointing at it. {@code ShopVillagerListener} finishes the job + * when the chunk does load. + * + *

Exists because a crash between despawning a villager and persisting that + * leaves the two out of step, and spawn chunks are already loaded before + * plugins enable, so the chunk-load pass alone never sees them. + */ + public boolean discardStrayVillager(Shop shop) { + if (!isNpcBacked(shop) || shop.villagerEntityId() == null) return false; + var stray = villagers.findEntity(shop); + if (stray == null) return false; + stray.remove(); + shop.setVillagerEntityId(null); + return true; + } + /** * Rebuilds the representation from the current appearance, switching backend * if it changed. Returns the Bukkit entity id to persist on the shop — null diff --git a/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java b/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java index 658a882..b17dc2c 100644 --- a/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java +++ b/src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java @@ -106,26 +106,47 @@ public void onChunkLoad(ChunkLoadEvent event) { int sz = (int) Math.floor(shop.location().z()) >> 4; if (sx != cx || sz != cz) continue; - // NPC-backed shops are packet-based and have no entity id: they are - // spawned once at boot and are unaffected by chunk loading. - if (shops.entities().isNpcBacked(shop)) continue; - UUID villagerId = shop.villagerEntityId(); + + // NPC-backed shops are packet-based: spawned once at boot, unaffected + // by chunk loading. They should own no villager at all, so a leftover + // id means a crash landed between despawning one and persisting that + // — clean it up now that the chunk is finally loaded. + if (shops.entities().isNpcBacked(shop)) { + if (villagerId != null) discardStrayVillager(event, shop, villagerId); + continue; + } + if (villagerId == null) continue; - Entity entity = event.getWorld().getEntities().stream() - .filter(e -> e.getUniqueId().equals(villagerId)) - .findFirst().orElse(null); + Entity entity = findInWorld(event, villagerId); if (entity == null) { var at = shop.location().toBukkit(); if (at == null) continue; UUID newId = shops.entities().spawn(shop, at); shop.setVillagerEntityId(newId); - try { - shops.update(shop); - } catch (java.sql.SQLException ex) { - // Already logged in the service layer; we don't need to abort the chunk-load. - } + persist(shop); } } } + + private void discardStrayVillager(ChunkLoadEvent event, Shop shop, UUID villagerId) { + Entity stray = findInWorld(event, villagerId); + if (stray != null) stray.remove(); + shop.setVillagerEntityId(null); + persist(shop); + } + + private static Entity findInWorld(ChunkLoadEvent event, UUID entityId) { + return event.getWorld().getEntities().stream() + .filter(e -> e.getUniqueId().equals(entityId)) + .findFirst().orElse(null); + } + + private void persist(Shop shop) { + try { + shops.update(shop); + } catch (java.sql.SQLException ex) { + // Already logged in the service layer; we don't need to abort the chunk-load. + } + } } From 0c935a1635d0e235584d40d7b8227fedc29e9321 Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 22:40:12 +0900 Subject: [PATCH 8/9] docs: document the FancyNpcs appearance feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec.md gains the appearance backend under §3.1, the /vshop appearance surface under §4, the two new tables under §8.1, and a §12.2 covering the FancyNpcs integration — including why the build pins 2.9.2 and the three measured facts the design rests on (synchronous interact event, blocking setSkin, NPCs absent from the Bukkit entity API). README, both admin guides and both player-advanced guides updated in step, with the wording for each language taken from its own locale file rather than translated across. The admin guides call out the operational consequences rather than restating the command list: URL skins sit behind their own permission because they pull a remote image through the server, the profession button disappears while a shop renders as an NPC, NPCs are invisible to other plugins' entity handling, and /vshop reload re-sends them so config changes land without a restart. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 30 +++++++++++++++++- docs/guide/admin.md | 56 +++++++++++++++++++++++++++++++--- docs/guide/en/admin.md | 56 +++++++++++++++++++++++++++++++--- docs/guide/en/user-advanced.md | 17 ++++++++++- docs/guide/user-advanced.md | 17 ++++++++++- spec.md | 44 ++++++++++++++++++++++++-- 6 files changed, 205 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 765bfd8..3edbd98 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ ModernVillagerShop replaces the vanilla trade window with a **chest UI + Dialog* - **Co-owners with revenue sharing.** `PRIMARY` / `MANAGER` / `STAFF` roles per shop, percentage shares that always add up to 100%, instant payout split on every sale, and an ownership-transfer flow. - **Trade limits.** Per-slot caps, counted per player or server-wide, with an optional rolling reset window. Remaining amount and time-to-reset are shown in the slot lore. - **Villagers that stay put.** Shop villagers are AI-locked, invulnerable, protected from portals, and respawned from the database on chunk load if something removes them. +- **Custom shop appearance.** With [FancyNpcs](https://modrinth.com/plugin/fancynpcs) installed, a shop can render as an NPC instead of a villager — a player skin, any entity type, glow colour, scale and equipment — all through `/vshop appearance`, no UI to click through. - **Spawn-egg based creation.** `/vshop egg` hands out an egg that encodes how many listing rows the shop gets — 1 to *n* rows, or unlimited. - **Full trade history and statistics.** Filterable history (`--side`, `--from`, `--to`, `--player`, `--shop`), per-shop stats, cumulative fees, and audit fields (`basePrice` / `finalPrice` / `resolvedBy`) on every record. - **Owner notifications.** Chat notification on each trade while online, a summary on next login while offline, toggleable per player. @@ -28,6 +29,7 @@ ModernVillagerShop replaces the vanilla trade window with a **chest UI + Dialog* | [BedrockDialog](https://modrinth.com/plugin/bedrockdialog) | **required** | | Geyser + Floodgate | optional — needed only to serve Bedrock clients | | [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) | optional | +| [FancyNpcs](https://modrinth.com/plugin/fancynpcs) | optional — lets shops render as NPCs instead of villagers | ## Installation @@ -67,6 +69,7 @@ Root command is `/vshop` (bare `/vshop` prints help). | `/vshop stats ` | Shop statistics | `modernvillagershop.stats` | | `/vshop history [page] [--shop ] [--side sell\|buy] [--from ] [--to ] [--player ]` | Trade history | `history` / `history.others` | | `/vshop edit [shopId]` | Open the owner/editor menu | `edit` / `edit.others` | +| `/vshop appearance ` | Change how the shop looks (needs FancyNpcs) | `edit.appearance` / `edit.others` | | `/vshop coowner ` | Co-owner management UI | `coowner.manage` / `.others` | | `/vshop transfer ` | Transfer `PRIMARY` ownership | `coowner.transfer` / `.others` | | `/vshop egg ` | Give a shop spawn egg | `egg` / `admin.egg` | @@ -77,12 +80,33 @@ Root command is `/vshop` (bare `/vshop` prints help). `--from` / `--to` accept `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm[:ss]`, interpreted in the server's default time zone. +### `/vshop appearance` + +Command-only, by design — this is an occasional operation with a lot of knobs, which reads better flat than as a menu tree. + +| Subcommand | Effect | +| --- | --- | +| `show` | Print the current settings (read-only, so the console can run it too) | +| `npc [skin]` | Switch to a PLAYER NPC. Without `skin`, uses the owner's name | +| `villager` | Switch back to a plain villager | +| `type ` | Change the NPC's entity type | +| `skin [slim]` | Set or clear the skin (PLAYER type only) | +| `glow [color]` | Glow and glow colour | +| `scale ` | Size multiplier | +| `equip [none]` | Equip the item in your hand, or clear the slot | +| `attribute ` | Set a FancyNpcs attribute (e.g. `pose sitting`) | +| `reset` | Drop every override and go back to a villager | + +Equipment slots are FancyNpcs': `MAINHAND`, `OFFHAND`, `HEAD`, `CHEST`, `LEGS`, `FEET`, `BODY`, `SADDLE`. + +Appearance is stored in this plugin's own database, so it travels with `/vshop migrate` and NPCs are rebuilt from it on every boot. If FancyNpcs is missing or `fancynpcs.enabled` is false, NPC-backed shops fall back to villagers with a single warning — cosmetics never take a shop offline. + ## Permissions All nodes are prefixed `modernvillagershop.`. Two convenience bundles exist: - **`modernvillagershop.player`** (default: everyone) — `use`, `egg`, `list`, `search`, `stats`, `history`, `open.nearby`, and the `edit.*` / `coowner.*` nodes needed to run your own shop. -- **`modernvillagershop.admin`** (default: op) — `admin.egg`, `admin.edit`, `admin.export`, `admin.import`, `edit.others`, `coowner.manage.others`, `coowner.transfer.others`, `history.others`, `open.any`, `migrate`, `reload`. +- **`modernvillagershop.admin`** (default: op) — `admin.egg`, `admin.edit`, `admin.export`, `admin.import`, `admin.appearance`, `edit.others`, `coowner.manage.others`, `coowner.transfer.others`, `history.others`, `open.any`, `migrate`, `reload`. Two independent layers decide what a player can do: @@ -91,6 +115,8 @@ Two independent layers decide what a player can do: The `*.others` nodes are moderation overrides — they ignore role entirely and apply to any shop. +Two appearance nodes sit outside the bundles' defaults: `edit.appearance.url` (op) gates loading a skin from an arbitrary URL, since that pulls a remote image through the server, and `admin.appearance` (op) bypasses the `fancynpcs.allowedTypes` and `fancynpcs.maxScale` limits. + ## Configuration highlights Full annotated defaults live in [`src/main/resources/config.yml`](https://github.com/f0reachARR/ModernVillagerShop/blob/main/src/main/resources/config.yml). @@ -158,6 +184,8 @@ which copies shops, listings, stock, transactions, notifications, limits, co-own | `%mvshop_total_sales_%` | Cumulative sales | | `%mvshop_total_purchases_%` | Cumulative purchases | +**FancyNpcs** (`fancynpcs.enabled: true`) — renders shops as NPCs; see [`/vshop appearance`](#vshop-appearance). Built against the `de.oliver:FancyNpcs` 2.x API. FancyNpcs 2.10.0+ requires Java 25 on the server; if you are on Java 21, use the `-java21` builds FancyNpcs publishes. + **Events** — `ShopCreateEvent`, `ShopDeleteEvent`, `ShopPreTransactionEvent` (cancellable), `ShopTransactionEvent`, `ShopSlotChangeEvent`. **Public API** — `ModernVillagerShopAPI` is registered with Bukkit's `ServicesManager` and exposes shop lookup, search, statistics and history for dashboards, logging and third-party integrations. diff --git a/docs/guide/admin.md b/docs/guide/admin.md index ed6b554..713ebb3 100644 --- a/docs/guide/admin.md +++ b/docs/guide/admin.md @@ -13,6 +13,8 @@ - **Vault**: 必須。Vault 対応の Economy プラグイン(EssentialsX Economy など)が別途必要です。 - **BedrockDialog**: 必須。Modrinth 配布の Paper プラグイン。Bedrock 対応をしたい場合は Geyser + Floodgate も併せて導入します。 - **PlaceholderAPI**: 任意。導入すればプレースホルダーが利用できます。 +- **FancyNpcs**: 任意。導入すると、ショップの見た目を村人以外(主にプレイヤー NPC)にできます。 + - FancyNpcs 2.10.0 以降はサーバー側に **Java 25** を要求します。Java 21 で運用する場合は FancyNpcs が配布している `-java21` ビルドを使ってください。 ### 1.2 インストール @@ -94,7 +96,22 @@ shop: - `DROP`: 在庫を店の位置にドロップして削除 - `REFUSE`: 在庫があるうちは削除を拒否(既定・安全側) -### 2.5 プレイヤーキャッシュ +### 2.5 FancyNpcs 連携 + +```yaml +fancynpcs: + enabled: true + turnToPlayer: true # ショップ側で個別指定がないときの既定値 + interactionCooldown: 0.0 # 同一プレイヤーの連続クリックを無視する秒数(0 = 無効) + maxScale: 2.0 # /vshop appearance scale で指定できる最大倍率 + allowedTypes: [] # 空 = 全許可 +``` + +- `enabled: false` にすると、FancyNpcs が入っていても連携を止められます。NPC 指定のショップは村人として表示され、起動時に警告が 1 行出るだけで、ショップ自体は通常どおり動きます。 +- `allowedTypes` / `maxScale` は `modernvillagershop.admin.appearance` を持つプレイヤーには適用されません。 +- 見た目の設定はこのプラグイン自身の DB(`shop_appearance` テーブル)に保存されます。FancyNpcs 側の `npcs.yml` には書き込まないため、`/vshop migrate` でストレージを移すときも一緒に移動します。NPC は起動のたびに DB から作り直されます。 + +### 2.6 プレイヤーキャッシュ ```yaml playerCache: @@ -105,7 +122,7 @@ playerCache: プレイヤー選択UI(共同オーナー追加、PRIMARY 移譲先、`--player` 指定など)で使うキャッシュです。ログイン時・ログアウト時・共同オーナー参照時にアップサートされます。 -### 2.6 取引禁止アイテム +### 2.7 取引禁止アイテム ```yaml items: @@ -119,7 +136,7 @@ items: - 既定でシュルカーボックス系とバンドルが入っています。内部を持てるアイテムは、想定外の複製・搾取経路になるため慎重に扱ってください。 - プラグイン側の強制ブラックリストはありません。運用ポリシーに応じて追加削除してください。 -### 2.7 UI アイコン +### 2.8 UI アイコン `ui.chest.icons.*` で、チェストUI 内のナビゲーション用アイコン(次/前ページ、閉じる、絞り込み、並び替え、戻る、空スロット、利用不可、不明プレイヤーヘッド)のマテリアル・表示名・ロア・カスタムモデルデータをすべて上書きできます。テクスチャパック運用と組み合わせて外観を整えられます。 @@ -130,7 +147,7 @@ items: `paper-plugin.yml` に、次のロール的グルーピングが定義されています。LuckPerms などの権限プラグインで付与すると便利です。 - **`modernvillagershop.player`** (default: `true`): 一般プレイヤーが必要とする権限のパック。`use`, `egg`, `list`, `search`, `stats`, `history`, `open.nearby`, `edit.*`, `coowner.manage`, `coowner.transfer` を含む。 -- **`modernvillagershop.admin`** (default: `op`): 管理者権限パック。`admin.egg`, `admin.edit`, `admin.export`, `admin.import`, `edit.others`, `coowner.manage.others`, `coowner.transfer.others`, `history.others`, `open.any`, `migrate`, `reload` を含む。 +- **`modernvillagershop.admin`** (default: `op`): 管理者権限パック。`admin.egg`, `admin.edit`, `admin.export`, `admin.import`, `edit.others`, `coowner.manage.others`, `coowner.transfer.others`, `history.others`, `open.any`, `migrate`, `reload`, `admin.appearance` を含む。 ### 3.2 個別権限 @@ -142,7 +159,9 @@ items: | `modernvillagershop.egg` | プレイヤー用スポーンエッグの使用 | | `modernvillagershop.admin.egg` | 管理者用スポーンエッグの使用 | | `modernvillagershop.admin.edit` | 管理者ショップの編集 | -| `modernvillagershop.edit.*` | 自ショップの各種編集操作(move / rename / profession / suspend / delete / delete.refund) | +| `modernvillagershop.edit.*` | 自ショップの各種編集操作(move / rename / profession / appearance / suspend / delete / delete.refund) | +| `modernvillagershop.edit.appearance.url` | 任意の URL からスキンを読み込む(既定 op) | +| `modernvillagershop.admin.appearance` | `fancynpcs.allowedTypes` / `maxScale` の制限を無視する | | `modernvillagershop.edit.others` | 他者ショップの編集(ロール無視) | | `modernvillagershop.coowner.manage.others` | 任意ショップの共同オーナー管理 | | `modernvillagershop.coowner.transfer.others` | 任意ショップの PRIMARY 強制移譲(離脱者対応など) | @@ -211,6 +230,7 @@ items: | `/vshop stats ` | 統計表示 | `modernvillagershop.stats` | | `/vshop history [shopId] [page] [--flags]` | 取引履歴 | `history` / `history.others` | | `/vshop edit [shopId]` | 編集メニュー | `edit` / `edit.others` | +| `/vshop appearance ` | 見た目の変更(FancyNpcs 必須) | `edit.appearance` / `edit.others` | | `/vshop coowner ` | 共同オーナー管理UI | `coowner.manage` / `.others` | | `/vshop transfer ` | PRIMARY 移譲 | `coowner.transfer` / `.others` | | `/vshop egg ` | スポーンエッグ配布 | `egg` / `admin.egg` | @@ -221,6 +241,32 @@ items: `/vshop history` の `--from` / `--to` は `YYYY-MM-DD` または `YYYY-MM-DDTHH:mm[:ss]` を受け付け、サーバーのデフォルトタイムゾーンで解釈します。 +### 5.1 `/vshop appearance` の詳細 + +見た目の変更は Dialog UI を用意せず、コマンドだけで操作します。設定項目が多く、たまにしか触らない操作なので、メニューを潜るより一覧で見えるほうが扱いやすいためです。 + +| サブコマンド | 内容 | +| --- | --- | +| `show` | 現在の設定を表示(読み取り専用なのでコンソールからも実行可) | +| `npc [skin]` | プレイヤー NPC に切り替える。`skin` 省略時はオーナー名を使う | +| `villager` | 通常の村人に戻す | +| `type ` | NPC のエンティティタイプを変える | +| `skin [slim]` | スキンを設定・解除する(PLAYER タイプのみ) | +| `glow [color]` | 発光と発光色 | +| `scale <倍率>` | 大きさ | +| `equip [none]` | 手に持っているアイテムを装備させる / 外す | +| `attribute ` | FancyNpcs の属性を設定・削除する(例: `pose sitting`) | +| `reset` | 設定をすべて破棄して村人に戻す | + +装備スロットは FancyNpcs のもので、`MAINHAND` / `OFFHAND` / `HEAD` / `CHEST` / `LEGS` / `FEET` / `BODY` / `SADDLE` です。 + +運用上の注意: + +- **URL スキンは別権限**です。`modernvillagershop.edit.appearance.url`(既定 op)を持つ人だけが `https://...` を指定できます。任意の外部画像をサーバー経由で取得することになるため、一般プレイヤーには開けないでおくのが無難です。 +- NPC 表示中のショップでは、編集メニューから**職業変更のボタンが消えます**。職業は村人固有の設定だからです。設定値自体は残るので、`villager` に戻せば元の職業で復元されます。 +- NPC はサーバー上のエンティティとして存在しません。他プラグインのエンティティ一覧やモブカウント、`/kill` などの対象にはなりません。 +- `/vshop reload` を実行すると、`fancynpcs` セクションの変更を反映するため NPC を再送信します。 + ## 6. 監査と取引ログ - 取引履歴は DB に永続化され、他者・他ショップは `modernvillagershop.history.others` を持つユーザーが `/vshop history ` や `--player ` で閲覧できます。 diff --git a/docs/guide/en/admin.md b/docs/guide/en/admin.md index 7f83529..fd804ae 100644 --- a/docs/guide/en/admin.md +++ b/docs/guide/en/admin.md @@ -13,6 +13,8 @@ A guide for server operators and OPs. It covers installation, configuration, per - **Vault**: required. A Vault-compatible economy plugin (e.g. EssentialsX Economy) is needed separately. - **BedrockDialog**: required. A Paper plugin distributed on Modrinth. Add Geyser + Floodgate as well if you want Bedrock support. - **PlaceholderAPI**: optional. Install it to use the placeholders. +- **FancyNpcs**: optional. Install it to render shops as something other than a villager (mostly player NPCs). + - FancyNpcs 2.10.0 and later require **Java 25** on the server. If you run Java 21, use the `-java21` builds FancyNpcs publishes. ### 1.2 Installing @@ -94,7 +96,22 @@ shop: - `DROP`: drop the stock at the shop location and delete - `REFUSE`: refuse deletion while stock remains (default, safest) -### 2.5 Player cache +### 2.5 FancyNpcs integration + +```yaml +fancynpcs: + enabled: true + turnToPlayer: true # default when a shop does not override it + interactionCooldown: 0.0 # seconds to ignore repeat clicks from the same player (0 = off) + maxScale: 2.0 # largest multiplier /vshop appearance scale accepts + allowedTypes: [] # empty = everything allowed +``` + +- `enabled: false` turns the integration off even with FancyNpcs installed. NPC-backed shops then render as villagers, you get one warning at startup, and the shops themselves keep working normally. +- `allowedTypes` and `maxScale` do not apply to players holding `modernvillagershop.admin.appearance`. +- Appearance is stored in this plugin's own database (the `shop_appearance` table), never in FancyNpcs' `npcs.yml`. That means it travels with `/vshop migrate`, and NPCs are rebuilt from the database on every boot. + +### 2.6 Player cache ```yaml playerCache: @@ -105,7 +122,7 @@ playerCache: This cache backs the player-picker UI (adding co-owners, choosing a PRIMARY transfer target, `--player` arguments, and so on). It is upserted on login, on logout, and when co-owners are looked up. -### 2.6 Forbidden items +### 2.7 Forbidden items ```yaml items: @@ -119,7 +136,7 @@ items: - Shulker boxes and bundles are included by default. Treat items that can hold other items carefully — they open unintended duplication and extraction paths. - The plugin enforces no built-in blacklist of its own. Add and remove entries to match your policy. -### 2.7 UI icons +### 2.8 UI icons `ui.chest.icons.*` lets you override the material, display name, lore and custom model data of every navigation icon in the chest UI (next/prev page, close, filter, sort, back, empty slot, unavailable, unknown player head). Combine it with a resource pack to match your server's look. @@ -130,7 +147,7 @@ items: `paper-plugin.yml` defines the following role-like groupings. Granting them through a permission plugin such as LuckPerms is convenient. - **`modernvillagershop.player`** (default: `true`): the pack a regular player needs. Includes `use`, `egg`, `list`, `search`, `stats`, `history`, `open.nearby`, `edit.*`, `coowner.manage`, `coowner.transfer`. -- **`modernvillagershop.admin`** (default: `op`): the admin pack. Includes `admin.egg`, `admin.edit`, `admin.export`, `admin.import`, `edit.others`, `coowner.manage.others`, `coowner.transfer.others`, `history.others`, `open.any`, `migrate`, `reload`. +- **`modernvillagershop.admin`** (default: `op`): the admin pack. Includes `admin.egg`, `admin.edit`, `admin.export`, `admin.import`, `edit.others`, `coowner.manage.others`, `coowner.transfer.others`, `history.others`, `open.any`, `migrate`, `reload`, `admin.appearance`. ### 3.2 Individual permissions @@ -142,7 +159,9 @@ The notable ones: | `modernvillagershop.egg` | Use a player shop spawn egg | | `modernvillagershop.admin.egg` | Use an admin shop spawn egg | | `modernvillagershop.admin.edit` | Edit admin shops | -| `modernvillagershop.edit.*` | The individual edit operations on your own shop (move / rename / profession / suspend / delete / delete.refund) | +| `modernvillagershop.edit.*` | The individual edit operations on your own shop (move / rename / profession / appearance / suspend / delete / delete.refund) | +| `modernvillagershop.edit.appearance.url` | Load a skin from an arbitrary URL (default op) | +| `modernvillagershop.admin.appearance` | Bypass the `fancynpcs.allowedTypes` / `maxScale` limits | | `modernvillagershop.edit.others` | Edit someone else's shop (ignores role) | | `modernvillagershop.coowner.manage.others` | Manage co-owners of any shop | | `modernvillagershop.coowner.transfer.others` | Force a PRIMARY transfer on any shop (e.g. for players who left) | @@ -211,6 +230,7 @@ Practical uses: keep exported YAML under review in pull requests, or build it in | `/vshop stats ` | Show statistics | `modernvillagershop.stats` | | `/vshop history [shopId] [page] [--flags]` | Trade history | `history` / `history.others` | | `/vshop edit [shopId]` | Edit menu | `edit` / `edit.others` | +| `/vshop appearance ` | Change how the shop looks (needs FancyNpcs) | `edit.appearance` / `edit.others` | | `/vshop coowner ` | Co-owner management UI | `coowner.manage` / `.others` | | `/vshop transfer ` | Transfer PRIMARY | `coowner.transfer` / `.others` | | `/vshop egg ` | Give a spawn egg | `egg` / `admin.egg` | @@ -221,6 +241,32 @@ Practical uses: keep exported YAML under review in pull requests, or build it in `--from` / `--to` on `/vshop history` accept `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm[:ss]`, interpreted in the server's default time zone. +### 5.1 `/vshop appearance` in detail + +Appearance has no Dialog UI on purpose — it is an occasional operation with a lot of knobs, and a flat command surface is easier to work with than a menu tree. + +| Subcommand | Effect | +| --- | --- | +| `show` | Print the current settings (read-only, so the console can run it too) | +| `npc [skin]` | Switch to a player NPC. Without `skin`, uses the owner's name | +| `villager` | Switch back to a plain villager | +| `type ` | Change the NPC's entity type | +| `skin [slim]` | Set or clear the skin (PLAYER type only) | +| `glow [color]` | Glow and glow colour | +| `scale ` | Size multiplier | +| `equip [none]` | Equip the item in your hand, or clear the slot | +| `attribute ` | Set or remove a FancyNpcs attribute (e.g. `pose sitting`) | +| `reset` | Drop every setting and go back to a villager | + +Equipment slots are FancyNpcs': `MAINHAND`, `OFFHAND`, `HEAD`, `CHEST`, `LEGS`, `FEET`, `BODY`, `SADDLE`. + +Things worth knowing when running this: + +- **URL skins need their own permission.** Only holders of `modernvillagershop.edit.appearance.url` (default op) may pass `https://...`. It pulls an arbitrary remote image through your server, so leaving it closed to regular players is the safe default. +- While a shop renders as an NPC, **the profession button disappears** from its edit menu, since professions are a villager-only concept. The stored value is kept, so switching back with `villager` restores the original profession. +- NPCs do not exist as server-side entities. They will not show up in other plugins' entity listings or mob counts, and `/kill` cannot touch them. +- `/vshop reload` re-sends the NPCs so changes to the `fancynpcs` section take effect immediately. + ## 6. Auditing and trade logs - Trade history is persisted in the database. Users with `modernvillagershop.history.others` can inspect other players and other shops via `/vshop history ` or `--player `. diff --git a/docs/guide/en/user-advanced.md b/docs/guide/en/user-advanced.md index e20a907..d01c283 100644 --- a/docs/guide/en/user-advanced.md +++ b/docs/guide/en/user-advanced.md @@ -97,7 +97,7 @@ Clicking `Edit items` in the edit menu opens the **editor chest UI**, distinguis Items delivered into your BUY slots pile up in this stock, so you can resell them through SELL slots. -## 5. Changing name, profession, state and location +## 5. Changing name, appearance, state and location These live in the `Shop settings` submenu of the edit menu. @@ -106,6 +106,21 @@ These live in the `Shop settings` submenu of the edit menu. - **Suspend / Resume**: while suspended, both buying and delivering are rejected. Useful when you'll be away for a while. - **Move**: with the `modernvillagershop.edit.move` permission, you can relocate the shop. +If your server has FancyNpcs installed, the shop can look like an **NPC** instead of a villager. That one is driven by commands rather than the menu. + +``` +/vshop appearance npc # a player NPC wearing your own skin +/vshop appearance npc Notch # pick a specific skin +/vshop appearance glow true gold # make it glow gold +/vshop appearance equip MAINHAND # hand it the item you are holding +/vshop appearance show # check the current settings +/vshop appearance villager # back to a villager +``` + +`` is the first 8 characters of the shop ID, and Tab completes it. There is also `type` (entity kind), `scale`, `skin`, `attribute` and `reset`. + +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. + ## 6. Co-owners (player shops only) Instead of running a shop alone, several players can run it together. diff --git a/docs/guide/user-advanced.md b/docs/guide/user-advanced.md index baec876..f5a0579 100644 --- a/docs/guide/user-advanced.md +++ b/docs/guide/user-advanced.md @@ -97,7 +97,7 @@ BUY 枠に対する納品は在庫に積み上がるので、それを SELL 枠として再販する運用も可能です。 -## 5. 名前・職業・公開状態・場所を変える +## 5. 名前・見た目・公開状態・場所を変える 編集メニュー内の `店舗設定` サブメニューから変更できます。 @@ -106,6 +106,21 @@ BUY 枠に対する納品は在庫に積み上がるので、それを SELL 枠 - **一時停止 / 公開再開**: 一時停止中は購入も納品も拒否されます。長期不在時に使えます。 - **移動**: 権限 `modernvillagershop.edit.move` がある場合、ショップを別の位置に移動できます。 +サーバーに FancyNpcs が導入されていれば、村人ではなく **NPC** の見た目にもできます。こちらはメニューではなくコマンドで操作します。 + +``` +/vshop appearance npc # 自分のスキンのプレイヤー NPC にする +/vshop appearance npc Notch # スキンを指定する +/vshop appearance glow true gold # 金色に光らせる +/vshop appearance equip MAINHAND # 手に持っているアイテムを持たせる +/vshop appearance show # 今の設定を確認する +/vshop appearance villager # 村人に戻す +``` + +`` はショップ ID の先頭 8 文字で、Tab キーで補完できます。ほかに `type`(エンティティの種類)・`scale`(大きさ)・`skin`・`attribute`・`reset` があります。 + +NPC 表示にしている間は職業変更のボタンが消えます。職業は村人だけの設定だからです。`villager` に戻せば元の職業のまま復元されます。 + ## 6. 共同オーナー(プレイヤーショップのみ) 自分ひとりで運営するだけでなく、複数人でショップを共同運営できます。 diff --git a/spec.md b/spec.md index f9fcd07..37a4f1b 100644 --- a/spec.md +++ b/spec.md @@ -91,6 +91,16 @@ - 配置制限 - 既存ショップVillagerの座標から `shop.minDistance`(デフォルト: 0.5ブロック)未満の距離にはショップを配置できない - 設置時にスポーンエッグ使用を検証し、満たさない場合はエッグ消費せず拒否する + - 見た目(外見バックエンド) + - ショップの実体は **Villager(既定)** または **FancyNpcs の NPC** のいずれかで描画する + - 切り替えと外見の設定は `/vshop appearance`(§4)でのみ行う。Dialog UI は提供しない + - FancyNpcs 未導入・`fancynpcs.enabled: false` の場合、NPC 指定のショップも Villager として描画し、起動時に警告を1行出す。ショップ機能自体は停止しない + - NPC はパケットベースでサーバー側のエンティティを持たない。したがって + - チャンクロード時の再スポーン処理の対象外とする + - 視線によるショップ特定(`/vshop admin export|import`)はエンティティレイキャストではなく、ショップ座標に置いた当たり判定との交差で行う + - 職業(profession)の設定は Villager 専用とし、NPC 表示中は編集UIから隠す(値自体はDBに保持し、Villager に戻したときに復元する) + - NPC は FancyNpcs 側に永続化させない(`saveToFile(false)`)。起動のたびに `shop_appearance` から再生成し、`onDisable` で撤去する + - これにより外見設定は `/vshop migrate` の対象に含まれ、異常終了時にも孤児 NPC が残らない - プレイヤーショップ - 所有者(PRIMARY)付き。共同オーナーを設定可能(詳細は §3.6) - 在庫はショップ専用ストレージで保持し、論理上の容量制限は設けない(DB保持) @@ -184,6 +194,18 @@ - `/vshop list [page]`: ショップ一覧・ページング対応 - `/vshop search [page]`: アイテム名・IDでショップ検索(ページング対応) - `/vshop edit `: ショップ編集(PRIMARY / MANAGER / STAFF が役割範囲で実行可能。詳細は §3.6) + - `/vshop appearance `: ショップの見た目を変更する(コマンドのみ。UI は提供しない) + - `show`: 現在の設定を一覧表示(読み取り専用のためコンソールからも実行可) + - `npc [skin]`: FancyNpcs の PLAYER NPC に切り替える。`skin` 省略時は PRIMARY(管理者ショップは実行者)の名前を使う + - `villager`: 通常の Villager に戻す + - `type `: NPC のエンティティタイプを変更する + - `skin [slim]`: スキンを設定する。`@none` で解除。PLAYER タイプ以外では拒否する + - `glow [color]`: 発光と発光色 + - `scale <倍率>`: 大きさ + - `equip [none]`: 手に持っているアイテムを装備させる。`none` で解除。slot は FancyNpcs の `NpcEquipmentSlot`(`MAINHAND` / `OFFHAND` / `HEAD` / `CHEST` / `LEGS` / `FEET` / `BODY` / `SADDLE`) + - `attribute `: FancyNpcs の属性を設定・削除する。名前と値は FancyNpcs 側の定義に従い、不正な組み合わせは適用時に警告を出してスキップする + - `reset`: 外見設定をすべて破棄し Villager に戻す + - 実行可否はショップのロール(§3.6)で判定する。FancyNpcs が利用できない場合、NPC 専用の操作は拒否する - `/vshop coowner `: 共同オーナー管理UIを開く(PRIMARY のみ) - `/vshop transfer `: PRIMARY 権限を譲渡する(PRIMARY のみ。確認 Dialog 経由) - `/vshop history [shopId] [page] [--side sell|buy] [--from ] [--to ] [--player ]`: 取引履歴表示(shopId省略時は自身が関与した履歴を表示) @@ -368,6 +390,11 @@ - `shops`: ショップID・種別(player/admin)・所有者UUID(PRIMARYのキャッシュ)・座標・職業・名称・公開状態 - `shop_co_owners`: 共同オーナー(ショップID・プレイヤーUUID・役割 `PRIMARY|MANAGER|STAFF`・持分 DECIMAL(5,2)・追加日時・追加者UUID、`(shop_id, player_uuid)` を主キーとする) - `shops.owner_uuid` は本テーブルの PRIMARY 行とアプリケーション層で同期する + - `shop_appearance`: 外見設定(ショップID・バックエンド `VILLAGER|FANCY_NPC`・エンティティタイプ・スキン・スキンバリアント・発光・発光色・大きさ・プレイヤー追従・属性JSON・更新日時) + - 行が存在しない = 素の Villager。既存ショップへのバックフィルは不要 + - 装備スロット名と属性キーは FancyNpcs 側の名前空間に属し版によって増減するため、文字列として保持し、適用側で検証する + - `shop_appearance_equipment`: 外見の装備(ショップID・スロット名・アイテムBLOB、`(shop_id, slot)` を主キーとする) + - `shop_appearance` の upsert 時に全置換する(スロットを外したときに古い行が残らないようにするため) - `shop_slots`: 出品枠(ショップID・slot_index・種別SELL/BUY/BOTH・アイテムBLOB・単価・数量上限・取引上限・上限スコープ・リセット周期) - `slot_index` はフラットな整数で、編集モードと閲覧モードで同じ座標表現を共有する - `rowCount ≤ 6` のショップ: `slot_index` ∈ `[0, rowCount * 9)`(単一ページ) @@ -487,7 +514,20 @@ - `%mvshop_total_purchases_%`: プレイヤーの累計購入額 - 必要に応じて拡張可能な設計とする -### 12.2 公開API / イベント +### 12.2 FancyNpcs + +- 任意依存。導入時、ショップの実体を Villager ではなく NPC で描画できる(§3.1「見た目」)。 +- 対応バージョン: `de.oliver:FancyNpcs` 2.x 系(API パッケージ `de.oliver.fancynpcs.api`)。 + - ビルドは 2.9.2 に対して行う。2.10.0 以降の API jar は Java 25 クラスファイルで、本プラグインの JDK 21 ツールチェインでは読めないため。2.9.2 の公開 API は 2.10.x / 2.11.x にそのまま存在するので、実行時は最新版で問題ない。 + - サーバーが Java 25 を用意できない場合、FancyNpcs 側が配布する `-java21` ビルドを使う。 +- 実装上の制約(実測に基づく) + - `NpcInteractEvent` は同期 Bukkit イベント(Paper の `PlayerUseUnknownEntityEvent` 経由)であり、メインスレッドで発火する。 + - `NpcData#setSkin` はキャッシュミス時に呼び出しスレッドを 0.7 秒前後ブロックする。生成前に非同期でスキンを解決してから、メインスレッドで NPC を組み立てる。 + - NPC はサーバー側エンティティとして存在しない(`World#getEntities` / `Bukkit#getEntity` から見えない)。 + - `NpcData#getId()` は起動ごとに変わるため、NPC の同定には名前 `vshop-` を使う。 +- FancyNpcs のクラスを参照するのは `integration/fancynpcs` パッケージのみとし、未導入環境でクラス解決が発生しないようにする。 + +### 12.3 公開API / イベント - 他プラグインから利用できる Bukkit Event を提供する。 - `ShopCreateEvent` / `ShopDeleteEvent` @@ -499,7 +539,7 @@ - サードパーティ統合(ログ、ダッシュボード等)を想定 - APIはセマンティックバージョニングに従い、互換性を維持する。 -### 12.3 動的価格API(PriceProvider) +### 12.4 動的価格API(PriceProvider) - 管理者ショップの価格を拡張プラグインから動的に決定するための SPI を提供する。 - パイプライン型 SPI として設計し、複数の Provider を順序付きで重ねがけできる。 From 12fc2314cf4949e816d0b3d139dd743a0f3de418 Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Sun, 26 Jul 2026 23:31:02 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(command):=20shopId=20=E8=A3=9C?= =?UTF-8?q?=E5=AE=8C=E3=82=92=E8=BF=91=E3=81=8F=E3=81=AE=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=81=AB=E7=B5=9E=E3=82=8A=E3=83=84=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=81=E3=83=83=E3=83=97=E3=82=92=E4=BB=98=E3=81=91?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /vshop appearance の補完は登録済みショップを全件返していたため、 ショップ数が増えるとサーバー全体の 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) --- docs/guide/en/user-advanced.md | 2 +- docs/guide/user-advanced.md | 2 +- .../vshop/command/ShopIdSuggestions.java | 133 ++++++++++++++++++ .../vshop/command/sub/AppearanceCommand.java | 16 +-- src/main/resources/lang/messages_en.yml | 6 + src/main/resources/lang/messages_ja.yml | 6 + .../vshop/command/ShopIdSuggestionsTest.java | 94 +++++++++++++ 7 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 src/main/java/me/f0reach/vshop/command/ShopIdSuggestions.java create mode 100644 src/test/java/me/f0reach/vshop/command/ShopIdSuggestionsTest.java diff --git a/docs/guide/en/user-advanced.md b/docs/guide/en/user-advanced.md index d01c283..0aa30bd 100644 --- a/docs/guide/en/user-advanced.md +++ b/docs/guide/en/user-advanced.md @@ -117,7 +117,7 @@ If your server has FancyNpcs installed, the shop can look like an **NPC** instea /vshop appearance villager # back to a villager ``` -`` is the first 8 characters of the shop ID, and Tab completes it. There is also `type` (entity kind), `scale`, `skin`, `attribute` and `reset`. +`` 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`. 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. diff --git a/docs/guide/user-advanced.md b/docs/guide/user-advanced.md index f5a0579..7a1adb9 100644 --- a/docs/guide/user-advanced.md +++ b/docs/guide/user-advanced.md @@ -117,7 +117,7 @@ BUY 枠に対する納品は在庫に積み上がるので、それを SELL 枠 /vshop appearance villager # 村人に戻す ``` -`` はショップ ID の先頭 8 文字で、Tab キーで補完できます。ほかに `type`(エンティティの種類)・`scale`(大きさ)・`skin`・`attribute`・`reset` があります。 +`` はショップ ID の先頭 8 文字で、Tab キーで補完できます。補完候補に並ぶのは自分から 64 ブロック以内にあるショップだけで、候補を選ぶとショップ名・種別・オーナー・距離が表示されます。遠くのショップも ID を直接入力すれば操作できます。ほかに `type`(エンティティの種類)・`scale`(大きさ)・`skin`・`attribute`・`reset` があります。 NPC 表示にしている間は職業変更のボタンが消えます。職業は村人だけの設定だからです。`villager` に戻せば元の職業のまま復元されます。 diff --git a/src/main/java/me/f0reach/vshop/command/ShopIdSuggestions.java b/src/main/java/me/f0reach/vshop/command/ShopIdSuggestions.java new file mode 100644 index 0000000..8ac7663 --- /dev/null +++ b/src/main/java/me/f0reach/vshop/command/ShopIdSuggestions.java @@ -0,0 +1,133 @@ +package me.f0reach.vshop.command; + +import com.mojang.brigadier.Message; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.MessageComponentSerializer; +import me.f0reach.vshop.locale.MessageManager; +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.ui.text.Displays; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * Completions for the {@code } command argument. + * + *

A server can hold hundreds of shops, so the full id list is noise to a + * player standing in front of one. Only shops within {@link #NEARBY_RADIUS} + * blocks of the sender are offered, nearest first, each carrying a tooltip with + * name / type / owner / distance so the truncated id is identifiable.

+ * + *

This restricts suggestions only — {@link CommandSupport#findShopByPrefix} + * still resolves any id that is typed out, so a distant shop stays reachable + * (and console, which has no "near", keeps seeing the whole list).

+ */ +@SuppressWarnings("UnstableApiUsage") +public final class ShopIdSuggestions { + + /** Generous enough to cover a base, small enough to stay a local list. */ + private static final double NEARBY_RADIUS = 64.0; + + /** The client only shows ~10 rows; the cap keeps the packet small. */ + private static final int MAX_SUGGESTIONS = 20; + + private final CommandSupport support; + + public ShopIdSuggestions(CommandSupport support) { + this.support = support; + } + + public SuggestionProvider provider() { + return (ctx, builder) -> { + Location origin = ctx.getSource().getSender() instanceof Player player + ? player.getLocation() + : null; + for (Candidate candidate : select(support.plugin().registry().all(), + builder.getRemaining().toLowerCase(Locale.ROOT), origin, MAX_SUGGESTIONS)) { + builder.suggest(candidate.id(), tooltip(candidate)); + } + return builder.buildFuture(); + }; + } + + /** + * The shops to offer for {@code prefix}, nearest first and capped at + * {@code limit}. A null {@code origin} means the sender has no position + * (console), which drops the distance filter and orders by name instead. + */ + static List select(Collection shops, String prefix, Location origin, int limit) { + List candidates = new ArrayList<>(); + for (Shop shop : shops) { + String id = shop.id().toString().substring(0, 8); + if (!id.startsWith(prefix)) continue; + Double distance = origin == null ? null : distanceTo(origin, shop); + if (origin != null && distance == null) continue; // other world / out of range + candidates.add(new Candidate(id, shop, distance)); + } + // Brigadier re-sorts the built suggestion list alphabetically, so this + // ordering only decides which shops survive the cap. + candidates.sort(Comparator + .comparingDouble(c -> c.distance() == null ? Double.MAX_VALUE : c.distance()) + .thenComparing(c -> c.shop().name(), Comparator.nullsLast(String::compareTo))); + return candidates.subList(0, Math.min(limit, candidates.size())); + } + + /** Distance in blocks, or null when the shop is in another world or too far. */ + private static Double distanceTo(Location origin, Shop shop) { + Location loc = shop.location() == null ? null : shop.location().toBukkit(); + if (loc == null || !origin.getWorld().equals(loc.getWorld())) return null; + double squared = loc.distanceSquared(origin); + if (squared > NEARBY_RADIUS * NEARBY_RADIUS) return null; + return Math.sqrt(squared); + } + + private Message tooltip(Candidate candidate) { + MessageManager messages = support.messages(); + Shop shop = candidate.shop(); + Component owner = shop.ownerUuid() == null + ? messages.get("command.shop-suggest.owner-none") + : Component.text(ownerName(shop.ownerUuid())); + Component distance = candidate.distance() == null + ? messages.get("command.shop-suggest.distance-unknown") + : messages.get("command.shop-suggest.distance", + Placeholder.parsed("blocks", String.valueOf(Math.round(candidate.distance())))); + + // Shop names are player-supplied: inserted as a component so a name + // containing MiniMessage syntax cannot inject formatting. + Component text = messages.get("command.shop-suggest.tooltip", + Placeholder.component("shop_name", + Component.text(Displays.truncate(shop.name(), 32))), + Placeholder.component("type", support.enumLabels().label(shop.type())), + Placeholder.component("owner", owner), + Placeholder.component("distance", distance), + Placeholder.component("suspended", shop.suspended() + ? messages.get("command.shop-suggest.suspended-mark") + : Component.empty())); + return MessageComponentSerializer.message().serialize(text); + } + + /** + * Suggestions are recomputed on every keystroke, so this deliberately skips + * the DB-backed player cache: online player, then the server's local profile + * cache, then the shortened id. + */ + private static String ownerName(UUID uuid) { + Player online = Bukkit.getPlayer(uuid); + if (online != null) return online.getName(); + String cached = Bukkit.getOfflinePlayer(uuid).getName(); + return cached != null ? cached : uuid.toString().substring(0, 8); + } + + /** {@code distance} is null only for senders without a location (console). */ + record Candidate(String id, Shop shop, Double distance) {} +} diff --git a/src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java b/src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java index cc25ad8..f6cf4c1 100644 --- a/src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java +++ b/src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java @@ -10,6 +10,7 @@ import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.Commands; import me.f0reach.vshop.command.CommandSupport; +import me.f0reach.vshop.command.ShopIdSuggestions; import me.f0reach.vshop.config.PluginConfig; import me.f0reach.vshop.model.Shop; import me.f0reach.vshop.model.ShopAppearance; @@ -49,9 +50,11 @@ public final class AppearanceCommand { private static final String CLEAR_TOKEN = "@none"; private final CommandSupport support; + private final ShopIdSuggestions shopIds; public AppearanceCommand(CommandSupport support) { this.support = support; + this.shopIds = new ShopIdSuggestions(support); } public LiteralArgumentBuilder node() { @@ -60,7 +63,7 @@ public LiteralArgumentBuilder node() { || s.getSender().hasPermission("modernvillagershop.edit.others") || s.getSender().hasPermission("modernvillagershop.admin.appearance")) .then(Commands.argument("shopId", StringArgumentType.word()) - .suggests(shopIds()) + .suggests(shopIds.provider()) .then(Commands.literal("show") .executes(ctx -> show(ctx, shopId(ctx)))) .then(Commands.literal("npc") @@ -401,17 +404,6 @@ private static String trim(float value) { // ---- completions ---- - private SuggestionProvider shopIds() { - return (ctx, builder) -> { - String prefix = builder.getRemaining().toLowerCase(Locale.ROOT); - for (Shop shop : support.plugin().registry().all()) { - String id = shop.id().toString().substring(0, 8); - if (id.startsWith(prefix)) builder.suggest(id, () -> shop.name()); - } - return builder.buildFuture(); - }; - } - private SuggestionProvider entityTypes() { return (ctx, builder) -> { String prefix = builder.getRemaining().toUpperCase(Locale.ROOT); diff --git a/src/main/resources/lang/messages_en.yml b/src/main/resources/lang/messages_en.yml index 1ac3390..dcc6102 100644 --- a/src/main/resources/lang/messages_en.yml +++ b/src/main/resources/lang/messages_en.yml @@ -18,6 +18,12 @@ command: no-permission: "You don't have permission." player-only: "This command can only be used by a player." shop-not-found: "Shop not found: " + shop-suggest: + tooltip: " / / " + distance: " blocks away" + distance-unknown: "distance unknown" + owner-none: "no owner" + suspended-mark: " [suspended]" egg: issued: "Gave a spawn egg to ." not-found: "Player not found: " diff --git a/src/main/resources/lang/messages_ja.yml b/src/main/resources/lang/messages_ja.yml index a543ee7..d09f86f 100644 --- a/src/main/resources/lang/messages_ja.yml +++ b/src/main/resources/lang/messages_ja.yml @@ -18,6 +18,12 @@ command: no-permission: "権限がありません。" player-only: "このコマンドはプレイヤーのみ実行できます。" shop-not-found: "指定されたショップが見つかりません: " + shop-suggest: + tooltip: " / / " + distance: "ブロック先" + distance-unknown: "距離不明" + owner-none: "オーナーなし" + suspended-mark: " [停止中]" egg: issued: "スポーンエッグを に配布しました。" not-found: "対象プレイヤーが見つかりません: " diff --git a/src/test/java/me/f0reach/vshop/command/ShopIdSuggestionsTest.java b/src/test/java/me/f0reach/vshop/command/ShopIdSuggestionsTest.java new file mode 100644 index 0000000..f36f75d --- /dev/null +++ b/src/test/java/me/f0reach/vshop/command/ShopIdSuggestionsTest.java @@ -0,0 +1,94 @@ +package me.f0reach.vshop.command; + +import me.f0reach.vshop.model.Shop; +import me.f0reach.vshop.model.ShopLocation; +import me.f0reach.vshop.model.ShopType; +import me.f0reach.vshop.testsupport.BukkitTestSupport; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Which shops the {@code } completion offers. The tooltip rendering + * needs a live plugin, so only the selection is covered here — that is where + * the "nearby only" rule lives. + */ +class ShopIdSuggestionsTest { + + private static World world; + private static World other; + + @BeforeAll + static void bootBukkit() { + BukkitTestSupport.ensureBukkit(); + world = MockBukkit.getMock().addSimpleWorld("suggest-test-" + System.nanoTime()); + other = MockBukkit.getMock().addSimpleWorld("suggest-other-" + System.nanoTime()); + } + + private static Shop shopAt(String name, World w, double x, double z) { + Instant now = Instant.now(); + return new Shop(UUID.randomUUID(), ShopType.PLAYER, UUID.randomUUID(), + new ShopLocation(w.getUID(), x, 64, z, 0f, 0f), + null, null, name, false, 3, now, now); + } + + private static List names(List candidates) { + return candidates.stream().map(c -> c.shop().name()).toList(); + } + + @Test + void onlyShopsWithinRangeOfThePlayerAreOffered() { + Shop near = shopAt("near", world, 10, 0); + Shop far = shopAt("far", world, 400, 0); + var picked = ShopIdSuggestions.select(List.of(near, far), "", + new Location(world, 0, 64, 0), 20); + assertEquals(List.of("near"), names(picked)); + } + + @Test + void shopsInAnotherWorldAreNeverNear() { + Shop sameSpotOtherWorld = shopAt("elsewhere", other, 0, 0); + var picked = ShopIdSuggestions.select(List.of(sameSpotOtherWorld), "", + new Location(world, 0, 64, 0), 20); + assertTrue(picked.isEmpty()); + } + + @Test + void nearestSurviveTheCapAndCarryTheirDistance() { + Shop a = shopAt("a", world, 30, 0); + Shop b = shopAt("b", world, 5, 0); + Shop c = shopAt("c", world, 15, 0); + var picked = ShopIdSuggestions.select(List.of(a, b, c), "", + new Location(world, 0, 64, 0), 2); + assertEquals(List.of("b", "c"), names(picked)); + assertEquals(5.0, picked.get(0).distance(), 1e-9); + } + + @Test + void aSenderWithoutAPositionKeepsEveryShopOrderedByName() { + Shop far = shopAt("zulu", world, 5000, 0); + Shop elsewhere = shopAt("alpha", other, 0, 0); + var picked = ShopIdSuggestions.select(List.of(far, elsewhere), "", null, 20); + assertEquals(List.of("alpha", "zulu"), names(picked)); + assertNull(picked.get(0).distance()); + } + + @Test + void thePrefixStillFiltersByTheShortId() { + Shop shop = shopAt("only", world, 1, 0); + String id = shop.id().toString().substring(0, 8); + Location origin = new Location(world, 0, 64, 0); + assertEquals(1, ShopIdSuggestions.select(List.of(shop), id, origin, 20).size()); + assertTrue(ShopIdSuggestions.select(List.of(shop), "zzzzzzzz", origin, 20).isEmpty()); + } +}