Skip to content

feat(fancynpcs): ショップを FancyNpcs の NPC として描画できるようにする - #3

Open
f0reachARR wants to merge 11 commits into
mainfrom
feat/fancynpcs-appearance
Open

feat(fancynpcs): ショップを FancyNpcs の NPC として描画できるようにする#3
f0reachARR wants to merge 11 commits into
mainfrom
feat/fancynpcs-appearance

Conversation

@f0reachARR

Copy link
Copy Markdown
Owner

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 ではなくコマンド面として提供している(設定項目が多く、頻度の低い操作のため)
  • NPC は Bukkit のエンティティ API から見えないため、視線でショップを解決する ShopTargeting を追加し、既存フロー(操作メニュー、共同オーナー、スロット入出力など)を両バックエンドで正しく動くようにした
  • <shopId> 補完を ShopIdSuggestions に切り出し、64 ブロック以内のショップのみを近い順に最大 20 件提示し、ショップ名・種別・オーナー・距離のツールチップを付ける

Details

  • FancyNpcs 依存は integration/fancynpcs に閉じ込め、de.oliver.fancynpcs.* を参照するのはこのパッケージのみ。composition root は isPluginEnabled("FancyNpcs") ガードの外でこのパッケージのクラス名を出さず、保持は ShopEntityIntegration 経由で行う
  • 実測に基づく設計判断: NPC は Bukkit のエンティティ API から不可視なのでチャンク / エンティティベースの処理は一切適用できない。NpcInteractEvent は同期 Bukkit イベントなのでスケジューラホップは不要。NpcData#setSkin はキャッシュミス時に呼び出しスレッドをブロックするため、スポーン前に非同期でスキンを温めている
  • NPC は saveToFile(false) で生成し、起動時に shop_appearance から再構築する。FancyNpcs 側のファイルではなく本プラグインの DB が正
  • URL スキンは任意の外部画像をサーバー経由で取得するため、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)が通ること
  • MySQL 側: 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

f0reachARR and others added 10 commits July 26, 2026 19:50
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants