feat(fancynpcs): ショップを FancyNpcs の NPC として描画できるようにする - #3
Open
f0reachARR wants to merge 11 commits into
Open
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
/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) <noreply@anthropic.com>
Command-only control over how a shop renders, as requested — no Dialog UI.
`/vshop appearance <id> 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 <entity> | skin <name|@none> [slim]
glow <bool> [color] | scale <n> | equip <slot> [none]
attribute <name> <value|@none> | 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) <noreply@anthropic.com>
Sweeps the flows that still assumed every shop owns a villager. ShopRegistry#put only cleared a shop's old villager mapping when the new id was non-null — which is exactly the case it needed to handle, since switching a shop to an NPC sets the id to null. The stale entry stayed behind claiming a villager that no longer belonged to that shop. A crash between despawning a villager and persisting that leaves a shop marked NPC while still holding a villager id, so both would render. Two passes clean that up, and they need to be two: spawn chunks are already loaded before plugins enable, so the chunk-load pass alone never sees them. The boot pass only acts on a reachable entity — clearing the id for an unloaded chunk would orphan the villager with nothing left pointing at it, so that case is deliberately left to the chunk-load pass. The profession button is hidden for NPC-backed shops; professions are a villager concept and the setting stays stored for if the shop switches back. /vshop reload now re-sends NPCs. A villager re-derives its attributes on the next chunk load, but a packet NPC bakes config in at spawn, so turn-to-player, the interaction cooldown and the name format would have stayed stale until restart. Verified on the test server: force-loading an NPC-backed shop's chunk removes the stray villager and clears the id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
/vshop appearance <shopId> の補完は登録済みショップを全件返していたため、 ショップ数が増えるとサーバー全体の ID が並び、8 文字の ID だけでは どれがどのショップか判別できなかった。 - 補完対象を実行者から 64 ブロック以内・同一ワールドのショップに限定し、 近い順に並べて 20 件で打ち切る。Brigadier が最終的にアルファベット順へ 並べ替えるため、距離順は 20 件に残すショップの選定にのみ効く - 候補にショップ名・種別・オーナー・距離・停止中マークのツールチップを 付ける。ショップ名は Placeholder.component で差し込み、名前に MiniMessage が含まれていても書式注入にならないようにする - オーナー名は補完が打鍵ごとに再計算される経路なので DB のプレイヤー キャッシュは使わず、オンライン → サーバーのプロファイルキャッシュ → 短縮 UUID の順で解決する - 位置を持たないコンソールは距離フィルタを外して名前順で全件返す - 補完を絞るだけで findShopByPrefix は変更しないため、遠くのショップも ID を入力すれば従来どおり操作できる ロジックは AppearanceCommand から ShopIdSuggestions に切り出し、他の shopId を取るサブコマンドからも使えるようにした。選定部分は ShopIdSuggestionsTest で範囲外・別ワールド・件数上限・コンソール・ prefix 一致を検証している。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an optional FancyNpcs integration layer so shops can render either as the existing in-world Villager entity or as a packet-based FancyNpcs NPC, with per-shop appearance settings persisted in the plugin’s database and carried across /vshop migrate.
Changes:
- Introduces
ShopEntityBackend+ShopEntityService(facade) to support both Villager-backed and FancyNpcs-backed shop rendering, plus unified interaction routing. - Adds persistent appearance storage (
shop_appearance,shop_appearance_equipment) with SQLite/MySQL repositories and migration support, and exposes control via/vshop appearance. - Improves “shop under crosshair” resolution for NPC-backed shops (
ShopTargeting) and adds nearby-only<shopId>tab completion (ShopIdSuggestions) with tooltips.
Reviewed changes
Copilot reviewed 58 out of 59 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepositoryTest.java | Adds SQLite backend test for appearance repository contract |
| src/test/java/me/f0reach/vshop/storage/ShopAppearanceRepositoryContract.java | Adds cross-backend contract tests for appearance persistence |
| src/test/java/me/f0reach/vshop/storage/SchemaInitializerContract.java | Updates expected logical tables to include appearance tables |
| src/test/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepositoryTest.java | Adds MySQL backend test for appearance repository contract |
| src/test/java/me/f0reach/vshop/shop/entity/ShopTargetingTest.java | Adds geometry/raycast unit tests for NPC hitbox targeting |
| src/test/java/me/f0reach/vshop/locale/EnumLabelsTest.java | Extends enum label coverage for new enums |
| src/test/java/me/f0reach/vshop/command/ShopIdSuggestionsTest.java | Adds unit tests for nearby-only shop ID suggestions |
| src/main/resources/paper-plugin.yml | Declares optional FancyNpcs dependency + new permissions |
| src/main/resources/lang/messages_ja.yml | Adds command/help/appearance/suggestion messages + enum labels |
| src/main/resources/lang/messages_en.yml | Adds command/help/appearance/suggestion messages + enum labels |
| src/main/resources/config.yml | Adds fancynpcs configuration section (limits + defaults) |
| src/main/java/me/f0reach/vshop/ui/text/AppearanceView.java | Implements /vshop appearance ... show chat rendering |
| src/main/java/me/f0reach/vshop/storage/StorageManager.java | Exposes appearance() repository wiring for both DB backends |
| src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopRepository.java | Ensures shop delete cascades to appearance tables in SQLite |
| src/main/java/me/f0reach/vshop/storage/sqlite/SqliteShopAppearanceRepository.java | Implements SQLite appearance repository (row + equipment child table) |
| src/main/java/me/f0reach/vshop/storage/sqlite/SqliteSchemaInitializer.java | Creates appearance tables in SQLite schema bootstrap |
| src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceSql.java | Shared mapping/binding/codec logic for appearance rows |
| src/main/java/me/f0reach/vshop/storage/repo/ShopAppearanceRepository.java | Adds appearance repository interface contract |
| src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopRepository.java | Ensures shop delete cascades to appearance tables in MySQL |
| src/main/java/me/f0reach/vshop/storage/mysql/MysqlShopAppearanceRepository.java | Implements MySQL appearance repository (row + equipment child table) |
| src/main/java/me/f0reach/vshop/storage/mysql/MysqlSchemaInitializer.java | Creates appearance tables in MySQL schema bootstrap |
| src/main/java/me/f0reach/vshop/storage/migrate/MigrationService.java | Migrates appearance data during /vshop migrate |
| src/main/java/me/f0reach/vshop/shop/ShopVillagerManager.java | Removes legacy villager-only entity manager (replaced by new backends) |
| src/main/java/me/f0reach/vshop/shop/ShopService.java | Switches to ShopEntityService for spawn/delete/name refresh hooks |
| src/main/java/me/f0reach/vshop/shop/ShopRegistry.java | Fixes villager-id index updates when switching away from villager backend |
| src/main/java/me/f0reach/vshop/shop/ShopInteractionRouter.java | Unifies right-click behavior across villager + NPC interactions |
| src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java | Routes interaction via router + adds NPC-backed cleanup handling on chunk load |
| src/main/java/me/f0reach/vshop/shop/entity/VillagerBackend.java | Implements villager-backed shop entity backend |
| src/main/java/me/f0reach/vshop/shop/entity/ShopTargeting.java | Adds cross-backend “shop under crosshair” resolution (NPC hitbox raycast) |
| src/main/java/me/f0reach/vshop/shop/entity/ShopEntityService.java | Facade/dispatcher resolving which backend owns a shop |
| src/main/java/me/f0reach/vshop/shop/entity/ShopEntityIntegration.java | Defines lifecycle contract for optional third-party render integrations |
| src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java | Defines backend strategy interface for shop representation |
| src/main/java/me/f0reach/vshop/shop/entity/ShopDisplayName.java | Centralizes display-name rendering for both Bukkit and FancyNpcs |
| src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceService.java | Implements appearance write path: mutate → persist → registry → render |
| src/main/java/me/f0reach/vshop/shop/entity/ShopAppearanceRegistry.java | Adds in-memory mirror of shop_appearance for fast backend resolution |
| src/main/java/me/f0reach/vshop/shop/edit/ShopActionMenu.java | Hides villager-only profession UI when shop is NPC-backed; uses entity facade for refresh |
| src/main/java/me/f0reach/vshop/shop/coowner/CoOwnerFlow.java | Uses entity facade for display-name refresh on ownership transfer |
| src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java | Wires new services, optional FancyNpcs integration, startup reconciliation, and shutdown cleanup |
| src/main/java/me/f0reach/vshop/model/SkinVariant.java | Adds skin variant enum for FancyNpcs skin handling |
| src/main/java/me/f0reach/vshop/model/ShopEntityKind.java | Adds enum for persisted backend kind (villager vs FancyNpcs NPC) |
| src/main/java/me/f0reach/vshop/model/ShopAppearance.java | Adds appearance model (backend + NPC-related optional fields) |
| src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java | Owns FancyNpcs boot/start readiness and bulk NPC spawning |
| src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcListener.java | Maps FancyNpcs right-click events into shared shop interaction router |
| src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java | Implements FancyNpcs NPC backend rendering + skin warmup |
| src/main/java/me/f0reach/vshop/config/PluginConfig.java | Adds typed FancyNpcsConfig (limits + allowlist) |
| src/main/java/me/f0reach/vshop/command/VShopCommand.java | Registers new /vshop appearance command |
| src/main/java/me/f0reach/vshop/command/sub/ReloadCommand.java | Refreshes NPC-backed shops on reload so baked-in config changes apply |
| src/main/java/me/f0reach/vshop/command/sub/HelpCommand.java | Adds appearance to help ordering |
| src/main/java/me/f0reach/vshop/command/sub/AppearanceCommand.java | Implements /vshop appearance subcommands and completions |
| src/main/java/me/f0reach/vshop/command/ShopIdSuggestions.java | Adds nearby-only <shopId> suggestions with tooltip rendering |
| src/main/java/me/f0reach/vshop/command/CommandSupport.java | Switches line-of-sight shop targeting to ShopTargeting |
| spec.md | Documents FancyNpcs behavior, constraints, and /vshop appearance surface |
| README.md | Documents FancyNpcs optional dependency and /vshop appearance usage |
| docs/guide/user-advanced.md | Updates JA user guide to include appearance controls |
| docs/guide/en/user-advanced.md | Updates EN user guide to include appearance controls |
| docs/guide/en/admin.md | Adds EN admin docs for FancyNpcs config + permissions |
| docs/guide/admin.md | Adds JA admin docs for FancyNpcs config + permissions |
| CLAUDE.md | Updates architecture notes to include new appearance/entity layers |
| build.gradle | Adds FancyInnovations repo + FancyNpcs compileOnly dependency pinning rationale |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+57
to
+60
| for (Map.Entry<String, org.bukkit.inventory.ItemStack> entry : appearance.equipment().entrySet()) { | ||
| to.sendMessage(messages.get("command.appearance.line-equipment", | ||
| Placeholder.parsed("slot", entry.getKey()), | ||
| Placeholder.component("item", Displays.item(entry.getValue())))); |
Comment on lines
+63
to
+67
| for (Map.Entry<String, String> entry : appearance.attributes().entrySet()) { | ||
| to.sendMessage(messages.get("command.appearance.line-attribute", | ||
| Placeholder.parsed("name", entry.getKey()), | ||
| Placeholder.parsed("value", entry.getValue()))); | ||
| } |
Comment on lines
+216
to
+220
| private int setGlow(CommandContext<CommandSourceStack> ctx, String shopIdPrefix, | ||
| boolean enabled, String colorName) { | ||
| Target target = resolve(ctx, shopIdPrefix); | ||
| if (target == null) return 0; | ||
|
|
Comment on lines
+238
to
+241
| private int setScale(CommandContext<CommandSourceStack> ctx, String shopIdPrefix, float scale) { | ||
| Target target = resolve(ctx, shopIdPrefix); | ||
| if (target == null) return 0; | ||
|
|
Comment on lines
+206
to
+208
| private void applyAttributes(NpcData data, ShopAppearance a, Shop shop) { | ||
| if (a.attributes().isEmpty()) return; | ||
| var manager = FancyNpcsPlugin.get().getAttributeManager(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ショップの見た目を村人以外にもできるようにする。FancyNpcs が入っているサーバーでは、村人の代わりにパケット NPC としてショップを描画し、スキン・エンティティ種別・発光・スケール・装備・属性をショップ単位で設定できる。設定は
shop_appearanceテーブルに保存し、DB を正とする(/vshop migrateでも引き継がれる)。主な変更点:
ShopEntityBackendを strategy として切り出し、VillagerBackend(従来の村人)とFancyNpcBackend(NPC)を実装。呼び出し側は facade のShopEntityServiceにのみ依存するshop_appearance、SQLite / MySQL 両実装)とShopAppearanceRegistry/ShopAppearanceService/vshop appearance <shopId> <npc|villager|type|skin|glow|scale|equip|attribute|show|reset>を追加。Dialog UI ではなくコマンド面として提供している(設定項目が多く、頻度の低い操作のため)ShopTargetingを追加し、既存フロー(操作メニュー、共同オーナー、スロット入出力など)を両バックエンドで正しく動くようにした<shopId>補完をShopIdSuggestionsに切り出し、64 ブロック以内のショップのみを近い順に最大 20 件提示し、ショップ名・種別・オーナー・距離のツールチップを付けるDetails
integration/fancynpcsに閉じ込め、de.oliver.fancynpcs.*を参照するのはこのパッケージのみ。composition root はisPluginEnabled("FancyNpcs")ガードの外でこのパッケージのクラス名を出さず、保持はShopEntityIntegration経由で行うNpcInteractEventは同期 Bukkit イベントなのでスケジューラホップは不要。NpcData#setSkinはキャッシュミス時に呼び出しスレッドをブロックするため、スポーン前に非同期でスキンを温めているsaveToFile(false)で生成し、起動時にshop_appearanceから再構築する。FancyNpcs 側のファイルではなく本プラグインの DB が正modernvillagershop.edit.appearance.url権限で分離した。スケールとエンティティ種別は config のホワイトリスト / 上限に従い、modernvillagershop.admin.appearanceで上書きできるmessages_en.yml/messages_ja.ymlの両方に入れ、日英ガイド(docs/guide/、docs/guide/en/)と spec.md / README.md / CLAUDE.md も更新済みTest plan
./gradlew build(コンパイル + SQLite テスト + shadowJar)が通ることVSHOP_TEST_MYSQL_URLを設定して./gradlew test(CI では両バックエンドを実行)ShopAppearanceRepositoryContract(SQLite / MySQL)、ShopTargetingTest(NPC ヒットボックスと視線判定)、ShopIdSuggestionsTest(補完の範囲外 / 別ワールド / 件数上限 / コンソール / prefix)./gradlew runServer+ FancyNpcs):/vshop appearance <id> npc→ NPC 化とスキン反映、showの表示、クリックで購入フローが開くこと、villagerで職業を保った状態に戻ること、/vshop appearanceの Tab 補完に近くのショップのみが並びツールチップが出ること、FancyNpcs 未導入時に NPC 専用の操作が拒否されること🤖 Generated with Claude Code