Skip to content

Commit 3958ece

Browse files
f0reachARRclaude
andcommitted
feat(command): add /vshop appearance
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>
1 parent 1c80a9b commit 3958ece

16 files changed

Lines changed: 839 additions & 53 deletions

File tree

src/main/java/me/f0reach/vshop/ModernVillagerShopPlugin.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import me.f0reach.vshop.shop.ShopService;
1515
import me.f0reach.vshop.shop.VillagerTeleportGuard;
1616
import me.f0reach.vshop.shop.entity.ShopAppearanceRegistry;
17+
import me.f0reach.vshop.shop.entity.ShopAppearanceService;
1718
import me.f0reach.vshop.shop.entity.ShopDisplayName;
1819
import me.f0reach.vshop.shop.entity.ShopEntityBackend;
1920
import me.f0reach.vshop.shop.entity.ShopEntityIntegration;
@@ -67,6 +68,7 @@ public final class ModernVillagerShopPlugin extends JavaPlugin {
6768
private VillagerBackend villagerBackend;
6869
private ShopEntityService shopEntities;
6970
private ShopTargeting shopTargeting;
71+
private ShopAppearanceService shopAppearanceService;
7072
private ShopInteractionRouter interactionRouter;
7173
private ShopEntityIntegration npcIntegration;
7274
private DialogService dialogService;
@@ -134,6 +136,8 @@ public void onEnable() {
134136
this.shopEntities = new ShopEntityService(villagerBackend, shopAppearances, npcBackend);
135137
this.shopTargeting = new ShopTargeting(registry, shopEntities);
136138
this.shopService = new ShopService(storage, registry, shopEntities, config);
139+
this.shopAppearanceService = new ShopAppearanceService(this, storage.appearance(),
140+
shopAppearances, shopEntities, shopService);
137141
this.eggFactory = new SpawnEggFactory(this, messages);
138142
this.dialogService = new DialogService(this);
139143
this.iconConfig = new IconConfig(messages, config);
@@ -265,6 +269,9 @@ public void reloadConfigInternal() {
265269
public ShopEntityService shopEntities() { return shopEntities; }
266270
public ShopAppearanceRegistry shopAppearances() { return shopAppearances; }
267271
public ShopTargeting shopTargeting() { return shopTargeting; }
272+
public ShopAppearanceService shopAppearanceService() { return shopAppearanceService; }
273+
/** Whether NPC-backed shops can actually be rendered right now. */
274+
public boolean hasNpcIntegration() { return npcIntegration != null; }
268275
public ShopDisplayName shopDisplayName() { return shopDisplayName; }
269276
public DialogService dialogService() { return dialogService; }
270277
public ShopBrowseUi browseUi() { return browseUi; }

src/main/java/me/f0reach/vshop/command/VShopCommand.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import me.f0reach.vshop.ModernVillagerShopPlugin;
77
import me.f0reach.vshop.command.sub.AdminExportSlotsCommand;
88
import me.f0reach.vshop.command.sub.AdminImportSlotsCommand;
9+
import me.f0reach.vshop.command.sub.AppearanceCommand;
910
import me.f0reach.vshop.command.sub.CoOwnerCommand;
1011
import me.f0reach.vshop.command.sub.EditCommand;
1112
import me.f0reach.vshop.command.sub.EggCommand;
@@ -33,6 +34,7 @@ public final class VShopCommand {
3334
private final ListCommand list;
3435
private final OpenCommand open;
3536
private final EditCommand edit;
37+
private final AppearanceCommand appearance;
3638
private final CoOwnerCommand coowner;
3739
private final TransferCommand transfer;
3840
private final StatsCommand stats;
@@ -50,6 +52,7 @@ public VShopCommand(ModernVillagerShopPlugin plugin) {
5052
this.list = new ListCommand(support);
5153
this.open = new OpenCommand(support);
5254
this.edit = new EditCommand(support);
55+
this.appearance = new AppearanceCommand(support);
5356
this.coowner = new CoOwnerCommand(support);
5457
this.transfer = new TransferCommand(support);
5558
this.stats = new StatsCommand(support);
@@ -69,6 +72,7 @@ public LiteralCommandNode<CommandSourceStack> build() {
6972
.then(list.node())
7073
.then(open.node())
7174
.then(edit.node())
75+
.then(appearance.node())
7276
.then(coowner.node())
7377
.then(transfer.node())
7478
.then(stats.node())

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

Lines changed: 462 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public final class HelpCommand {
1515

1616
/** Display order; the text of each line lives in {@code command.help.<key>}. */
1717
private static final List<String> ENTRIES = List.of(
18-
"list", "open", "edit", "coowner", "transfer", "stats",
18+
"list", "open", "edit", "appearance", "coowner", "transfer", "stats",
1919
"search", "history", "egg", "migrate", "reload");
2020

2121
private final CommandSupport support;

src/main/java/me/f0reach/vshop/config/PluginConfig.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ public void reload(FileConfiguration cfg) {
9797
this.fancyNpcs = new FancyNpcsConfig(
9898
cfg.getBoolean("fancynpcs.enabled", true),
9999
cfg.getBoolean("fancynpcs.turnToPlayer", true),
100-
(float) cfg.getDouble("fancynpcs.interactionCooldown", 0.0)
100+
(float) cfg.getDouble("fancynpcs.interactionCooldown", 0.0),
101+
(float) cfg.getDouble("fancynpcs.maxScale", 2.0),
102+
List.copyOf(cfg.getStringList("fancynpcs.allowedTypes"))
101103
);
102104

103105
this.playerCache = new PlayerCacheConfig(
@@ -197,7 +199,18 @@ public record VillagerLookConfig(boolean enabled, double radius) {}
197199
* plugin still has to be installed for NPC-backed shops to render at all.
198200
* {@code turnToPlayer} is the default for shops that have not overridden it.
199201
*/
200-
public record FancyNpcsConfig(boolean enabled, boolean turnToPlayer, float interactionCooldown) {}
202+
public record FancyNpcsConfig(boolean enabled, boolean turnToPlayer, float interactionCooldown,
203+
float maxScale, List<String> allowedTypes) {
204+
205+
/** Empty list = every entity type is allowed. Matching is case-insensitive on the enum name. */
206+
public boolean allowsType(org.bukkit.entity.EntityType type) {
207+
if (allowedTypes.isEmpty()) return true;
208+
for (String allowed : allowedTypes) {
209+
if (allowed.equalsIgnoreCase(type.name())) return true;
210+
}
211+
return false;
212+
}
213+
}
201214

202215
public record PlayerCacheConfig(int maxEntries, PlayerCacheSort defaultSort, Duration textureTtl) {}
203216
}

src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcBackend.java

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,29 @@ void removeById(UUID shopId) {
122122
npc.removeForAll();
123123
}
124124

125+
@Override
126+
public void prepare(ShopAppearance a) {
127+
if (a.skin() == null) return;
128+
EntityType type = a.entityType() == null ? EntityType.PLAYER : a.entityType();
129+
if (type != EntityType.PLAYER) return;
130+
try {
131+
// Populates FancyNpcs' skin cache so the later setSkin() on the main
132+
// thread is a hit rather than a ~0.7s round trip to Mojang.
133+
FancyNpcsPlugin.get().getSkinManager().getByIdentifier(a.skin(), variantOf(a));
134+
} catch (SkinLoadException ex) {
135+
// Reported again with the shop id attached when the NPC is built.
136+
plugin.getLogger().warning("Could not pre-load skin '" + a.skin() + "': " + ex.getReason());
137+
} catch (RuntimeException ex) {
138+
plugin.getLogger().warning("Skin pre-load failed for '" + a.skin() + "': " + ex);
139+
}
140+
}
141+
142+
private static SkinData.SkinVariant variantOf(ShopAppearance a) {
143+
return a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM
144+
? SkinData.SkinVariant.SLIM
145+
: SkinData.SkinVariant.AUTO;
146+
}
147+
125148
Npc find(UUID shopId) {
126149
return FancyNpcsPlugin.get().getNpcManager().getNpc(npcName(shopId));
127150
}
@@ -158,11 +181,8 @@ private void applySkin(NpcData data, ShopAppearance a, Shop shop) {
158181
data.setSkinData(null);
159182
return;
160183
}
161-
SkinData.SkinVariant variant = a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM
162-
? SkinData.SkinVariant.SLIM
163-
: SkinData.SkinVariant.AUTO;
164184
try {
165-
data.setSkin(a.skin(), variant);
185+
data.setSkin(a.skin(), variantOf(a));
166186
} catch (SkinLoadException ex) {
167187
plugin.getLogger().warning("Shop " + shop.id() + ": could not load skin '"
168188
+ a.skin() + "' (" + ex.getReason() + "); rendering without one");

src/main/java/me/f0reach/vshop/integration/fancynpcs/FancyNpcsIntegration.java

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@
22

33
import de.oliver.fancynpcs.api.FancyNpcsPlugin;
44
import de.oliver.fancynpcs.api.events.NpcsLoadedEvent;
5-
import de.oliver.fancynpcs.api.skins.SkinData;
6-
import de.oliver.fancynpcs.api.skins.SkinLoadException;
75
import me.f0reach.vshop.config.PluginConfig;
86
import me.f0reach.vshop.model.Shop;
9-
import me.f0reach.vshop.model.ShopAppearance;
107
import me.f0reach.vshop.model.ShopEntityKind;
118
import me.f0reach.vshop.shop.ShopInteractionRouter;
129
import me.f0reach.vshop.shop.ShopRegistry;
@@ -16,15 +13,12 @@
1613
import me.f0reach.vshop.shop.entity.ShopEntityIntegration;
1714
import org.bukkit.Bukkit;
1815
import org.bukkit.Location;
19-
import org.bukkit.entity.EntityType;
2016
import org.bukkit.event.EventHandler;
2117
import org.bukkit.event.Listener;
2218
import org.bukkit.plugin.Plugin;
2319

2420
import java.util.ArrayList;
25-
import java.util.LinkedHashSet;
2621
import java.util.List;
27-
import java.util.Set;
2822

2923
/**
3024
* Owns the FancyNpcs side of the plugin: the backend, the interaction listener,
@@ -92,45 +86,16 @@ private synchronized void spawnAll() {
9286
List<Shop> targets = npcBackedShops();
9387
if (targets.isEmpty()) return;
9488

95-
// Resolving a skin blocks the calling thread on a cache miss (~0.7s per
96-
// unseen name against Mojang), so warm the cache off-thread first and
97-
// only then build the NPCs, where the same lookups are a cache hit.
98-
Set<SkinRequest> skins = new LinkedHashSet<>();
99-
for (Shop shop : targets) {
100-
ShopAppearance a = appearances.getOrDefault(shop.id());
101-
EntityType type = a.entityType() == null ? EntityType.PLAYER : a.entityType();
102-
if (a.skin() != null && type == EntityType.PLAYER) {
103-
skins.add(new SkinRequest(a.skin(),
104-
a.skinVariant() == me.f0reach.vshop.model.SkinVariant.SLIM
105-
? SkinData.SkinVariant.SLIM : SkinData.SkinVariant.AUTO));
106-
}
107-
}
108-
109-
if (skins.isEmpty()) {
110-
spawnOnMain(targets);
111-
return;
112-
}
89+
// Skin resolution blocks its caller on a cache miss, so warm every
90+
// appearance off-thread and only then build the NPCs on the main thread.
11391
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
114-
warmSkinCache(skins);
92+
for (Shop shop : targets) {
93+
backend.prepare(appearances.getOrDefault(shop.id()));
94+
}
11595
Bukkit.getScheduler().runTask(plugin, () -> spawnOnMain(targets));
11696
});
11797
}
11898

119-
private void warmSkinCache(Set<SkinRequest> skins) {
120-
var manager = FancyNpcsPlugin.get().getSkinManager();
121-
for (SkinRequest request : skins) {
122-
try {
123-
manager.getByIdentifier(request.identifier(), request.variant());
124-
} catch (SkinLoadException ex) {
125-
// Reported again per shop by the backend, with the shop id attached.
126-
plugin.getLogger().warning("Could not pre-load skin '" + request.identifier()
127-
+ "': " + ex.getReason());
128-
} catch (RuntimeException ex) {
129-
plugin.getLogger().warning("Skin pre-load failed for '" + request.identifier() + "': " + ex);
130-
}
131-
}
132-
}
133-
13499
private void spawnOnMain(List<Shop> targets) {
135100
int created = 0;
136101
for (Shop shop : targets) {
@@ -157,5 +122,4 @@ private List<Shop> npcBackedShops() {
157122
return out;
158123
}
159124

160-
private record SkinRequest(String identifier, SkinData.SkinVariant variant) {}
161125
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package me.f0reach.vshop.shop.entity;
2+
3+
import me.f0reach.vshop.model.Shop;
4+
import me.f0reach.vshop.model.ShopAppearance;
5+
import me.f0reach.vshop.model.ShopEntityKind;
6+
import me.f0reach.vshop.shop.ShopService;
7+
import me.f0reach.vshop.storage.repo.ShopAppearanceRepository;
8+
import org.bukkit.Bukkit;
9+
import org.bukkit.plugin.Plugin;
10+
11+
import java.sql.SQLException;
12+
import java.time.Instant;
13+
import java.util.UUID;
14+
import java.util.function.Consumer;
15+
import java.util.logging.Level;
16+
17+
/**
18+
* Write path for a shop's appearance: mutate, persist, re-render.
19+
*
20+
* <p>Callers describe the change and this keeps the three copies in step — the
21+
* {@code shop_appearance} row, the in-memory {@link ShopAppearanceRegistry}, and
22+
* whatever is standing in the world.
23+
*/
24+
public final class ShopAppearanceService {
25+
26+
private final Plugin plugin;
27+
private final ShopAppearanceRepository repository;
28+
private final ShopAppearanceRegistry registry;
29+
private final ShopEntityService entities;
30+
private final ShopService shops;
31+
32+
public ShopAppearanceService(Plugin plugin, ShopAppearanceRepository repository,
33+
ShopAppearanceRegistry registry, ShopEntityService entities,
34+
ShopService shops) {
35+
this.plugin = plugin;
36+
this.repository = repository;
37+
this.registry = registry;
38+
this.entities = entities;
39+
this.shops = shops;
40+
}
41+
42+
/** The shop's stored appearance, or the implicit Villager default. */
43+
public ShopAppearance current(Shop shop) {
44+
return registry.getOrDefault(shop.id());
45+
}
46+
47+
/**
48+
* Applies {@code mutation}, writes it through, and rebuilds the shop's
49+
* representation. {@code onRendered} runs on the main thread once the change
50+
* is actually visible — later than this call returns, because the render may
51+
* need an off-thread skin lookup first.
52+
*
53+
* <p>An appearance mutated back to all-defaults deletes its row instead of
54+
* storing a no-op, keeping "no row means plain Villager" true.
55+
*/
56+
public void apply(Shop shop, Consumer<ShopAppearance> mutation, Runnable onRendered) throws SQLException {
57+
ShopAppearance appearance = registry.find(shop.id())
58+
.orElseGet(() -> ShopAppearance.defaultFor(shop.id()));
59+
ShopEntityKind before = appearance.backend();
60+
61+
mutation.accept(appearance);
62+
appearance.setUpdatedAt(Instant.now());
63+
64+
if (appearance.isDefault()) {
65+
repository.delete(shop.id());
66+
registry.remove(shop.id());
67+
} else {
68+
repository.upsert(appearance);
69+
registry.put(appearance);
70+
}
71+
72+
boolean backendChanged = before != registry.backendOf(shop.id());
73+
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
74+
entities.prepare(shop);
75+
Bukkit.getScheduler().runTask(plugin, () -> {
76+
render(shop, backendChanged);
77+
if (onRendered != null) onRendered.run();
78+
});
79+
});
80+
}
81+
82+
/** Drops every override and returns the shop to a plain Villager. */
83+
public void reset(Shop shop, Runnable onRendered) throws SQLException {
84+
apply(shop, appearance -> {
85+
appearance.setBackend(ShopEntityKind.VILLAGER);
86+
appearance.setEntityType(null);
87+
appearance.setSkin(null);
88+
appearance.setSkinVariant(null);
89+
appearance.setGlowing(false);
90+
appearance.setGlowColor(null);
91+
appearance.setScale(null);
92+
appearance.setTurnToPlayer(null);
93+
appearance.attributes().clear();
94+
appearance.equipment().clear();
95+
}, onRendered);
96+
}
97+
98+
private void render(Shop shop, boolean backendChanged) {
99+
if (!backendChanged) {
100+
entities.refresh(shop);
101+
return;
102+
}
103+
// The old representation belongs to the other backend, so respawn tears
104+
// down both. The new entity id (null for NPCs) has to be persisted.
105+
UUID entityId = entities.respawn(shop);
106+
shop.setVillagerEntityId(entityId);
107+
try {
108+
shops.update(shop);
109+
} catch (SQLException ex) {
110+
plugin.getLogger().log(Level.SEVERE,
111+
"Failed to persist the new entity id for shop " + shop.id(), ex);
112+
}
113+
}
114+
}

src/main/java/me/f0reach/vshop/shop/entity/ShopEntityBackend.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
package me.f0reach.vshop.shop.entity;
22

33
import me.f0reach.vshop.model.Shop;
4+
import me.f0reach.vshop.model.ShopAppearance;
45
import org.bukkit.Location;
56

67
import java.util.UUID;
78

89
/**
910
* Strategy for the in-world representation of a shop — the thing a player walks
10-
* up to and clicks. Today the only implementation is {@link VillagerBackend};
11-
* a FancyNpcs-backed one is planned, which is why callers should depend on
11+
* up to and clicks: a real Villager ({@link VillagerBackend}) or a packet NPC
12+
* ({@code integration.fancynpcs.FancyNpcBackend}). Callers depend on
1213
* {@link ShopEntityService} rather than on a concrete backend.
1314
*
1415
* <p>All methods run on the main thread and are best-effort: if the shop's
@@ -32,4 +33,13 @@ public interface ShopEntityBackend {
3233

3334
/** Despawns the representation. Does not touch persistence. */
3435
void remove(Shop shop);
36+
37+
/**
38+
* Off-main-thread warm-up for an appearance that is about to be spawned or
39+
* refreshed. Backends whose rendering needs a slow lookup — FancyNpcs
40+
* resolves a skin name against Mojang, blocking the caller for the better
41+
* part of a second on a cache miss — do it here so the main thread only
42+
* hits the cache. Must be safe to call from any thread, and safe to skip.
43+
*/
44+
default void prepare(ShopAppearance appearance) {}
3545
}

0 commit comments

Comments
 (0)