Skip to content

Commit 53416ce

Browse files
f0reachARRclaude
andcommitted
fix(shop): keep existing flows correct for NPC-backed shops
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>
1 parent 3958ece commit 53416ce

6 files changed

Lines changed: 86 additions & 19 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ public void onEnable() {
175175

176176
this.interactionRouter = new ShopInteractionRouter(openService, actionMenu, soundService);
177177

178+
// Spawn chunks are already loaded by the time plugins enable, so the
179+
// chunk-load pass never sees them: reconcile those shops here.
180+
for (var shop : registry.all()) {
181+
if (!shopEntities.discardStrayVillager(shop)) continue;
182+
try {
183+
shopService.update(shop);
184+
} catch (SQLException ex) {
185+
getLogger().warning("Failed to clear the stale villager id on shop "
186+
+ shop.id() + ": " + ex.getMessage());
187+
}
188+
}
189+
178190
if (npcIntegration != null) {
179191
npcIntegration.start(interactionRouter);
180192
} else {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.papermc.paper.command.brigadier.CommandSourceStack;
77
import io.papermc.paper.command.brigadier.Commands;
88
import me.f0reach.vshop.command.CommandSupport;
9+
import me.f0reach.vshop.model.Shop;
910

1011
@SuppressWarnings("UnstableApiUsage")
1112
public final class ReloadCommand {
@@ -24,6 +25,15 @@ public LiteralArgumentBuilder<CommandSourceStack> node() {
2425

2526
private int execute(CommandContext<CommandSourceStack> ctx) {
2627
support.plugin().reloadConfigInternal();
28+
// Villager attributes are re-derived on the next chunk load anyway, but a
29+
// packet NPC only changes when we re-send it, so config values it baked in
30+
// (turn-to-player, interaction cooldown, the name format) would otherwise
31+
// stay stale until the next restart.
32+
for (Shop shop : support.plugin().registry().all()) {
33+
if (support.plugin().shopEntities().isNpcBacked(shop)) {
34+
support.plugin().shopEntities().refresh(shop);
35+
}
36+
}
2737
ctx.getSource().getSender().sendMessage(support.messages().get("command.reload.done"));
2838
return Command.SINGLE_SUCCESS;
2939
}

src/main/java/me/f0reach/vshop/shop/ShopRegistry.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ public void loadAll(Collection<Shop> shops) {
2626

2727
public void put(Shop shop) {
2828
byId.put(shop.id(), shop);
29+
// Drop any previous villager mapping for this shop first. It has to happen
30+
// even when the new id is null — that is exactly what a shop switching to
31+
// an NPC backend looks like, and a leftover mapping would keep claiming a
32+
// villager that is no longer this shop's.
33+
villagerToShop.entrySet().removeIf(e -> e.getValue().equals(shop.id()));
2934
UUID v = shop.villagerEntityId();
30-
if (v != null) {
31-
// Remove any old villager mapping that may have been associated with
32-
// this shop, then index the current one.
33-
villagerToShop.entrySet().removeIf(e -> e.getValue().equals(shop.id()));
34-
villagerToShop.put(v, shop.id());
35-
}
35+
if (v != null) villagerToShop.put(v, shop.id());
3636
}
3737

3838
public void remove(UUID shopId) {

src/main/java/me/f0reach/vshop/shop/edit/ShopActionMenu.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ private void openSettingsSubmenu(Player viewer, Shop shop) {
142142
Placeholder.parsed("current", shop.name())),
143143
() -> openRename(viewer, shop)));
144144
}
145-
if (hasAnyPerm(viewer, "modernvillagershop.edit.profession",
145+
// Profession only exists on a villager; an NPC-backed shop has no such knob.
146+
if (!plugin.shopEntities().isNpcBacked(shop)
147+
&& hasAnyPerm(viewer, "modernvillagershop.edit.profession",
146148
"modernvillagershop.edit.others", "modernvillagershop.admin.edit")) {
147149
buttons.add(new DialogService.ButtonSpec(
148150
messages.get("action.profession.button",

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ public void onShopDeleted(Shop shop) {
9797
appearances.remove(shop.id());
9898
}
9999

100+
/**
101+
* Removes a villager still standing for a shop that now renders as an NPC and
102+
* clears the stale id, returning true when the shop record changed.
103+
*
104+
* <p>Only acts when the entity is actually reachable. If its chunk is not
105+
* loaded the id has to stay put — clearing it would orphan the villager with
106+
* nothing left pointing at it. {@code ShopVillagerListener} finishes the job
107+
* when the chunk does load.
108+
*
109+
* <p>Exists because a crash between despawning a villager and persisting that
110+
* leaves the two out of step, and spawn chunks are already loaded before
111+
* plugins enable, so the chunk-load pass alone never sees them.
112+
*/
113+
public boolean discardStrayVillager(Shop shop) {
114+
if (!isNpcBacked(shop) || shop.villagerEntityId() == null) return false;
115+
var stray = villagers.findEntity(shop);
116+
if (stray == null) return false;
117+
stray.remove();
118+
shop.setVillagerEntityId(null);
119+
return true;
120+
}
121+
100122
/**
101123
* Rebuilds the representation from the current appearance, switching backend
102124
* if it changed. Returns the Bukkit entity id to persist on the shop — null

src/main/java/me/f0reach/vshop/shop/listener/ShopVillagerListener.java

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -106,26 +106,47 @@ public void onChunkLoad(ChunkLoadEvent event) {
106106
int sz = (int) Math.floor(shop.location().z()) >> 4;
107107
if (sx != cx || sz != cz) continue;
108108

109-
// NPC-backed shops are packet-based and have no entity id: they are
110-
// spawned once at boot and are unaffected by chunk loading.
111-
if (shops.entities().isNpcBacked(shop)) continue;
112-
113109
UUID villagerId = shop.villagerEntityId();
110+
111+
// NPC-backed shops are packet-based: spawned once at boot, unaffected
112+
// by chunk loading. They should own no villager at all, so a leftover
113+
// id means a crash landed between despawning one and persisting that
114+
// — clean it up now that the chunk is finally loaded.
115+
if (shops.entities().isNpcBacked(shop)) {
116+
if (villagerId != null) discardStrayVillager(event, shop, villagerId);
117+
continue;
118+
}
119+
114120
if (villagerId == null) continue;
115-
Entity entity = event.getWorld().getEntities().stream()
116-
.filter(e -> e.getUniqueId().equals(villagerId))
117-
.findFirst().orElse(null);
121+
Entity entity = findInWorld(event, villagerId);
118122
if (entity == null) {
119123
var at = shop.location().toBukkit();
120124
if (at == null) continue;
121125
UUID newId = shops.entities().spawn(shop, at);
122126
shop.setVillagerEntityId(newId);
123-
try {
124-
shops.update(shop);
125-
} catch (java.sql.SQLException ex) {
126-
// Already logged in the service layer; we don't need to abort the chunk-load.
127-
}
127+
persist(shop);
128128
}
129129
}
130130
}
131+
132+
private void discardStrayVillager(ChunkLoadEvent event, Shop shop, UUID villagerId) {
133+
Entity stray = findInWorld(event, villagerId);
134+
if (stray != null) stray.remove();
135+
shop.setVillagerEntityId(null);
136+
persist(shop);
137+
}
138+
139+
private static Entity findInWorld(ChunkLoadEvent event, UUID entityId) {
140+
return event.getWorld().getEntities().stream()
141+
.filter(e -> e.getUniqueId().equals(entityId))
142+
.findFirst().orElse(null);
143+
}
144+
145+
private void persist(Shop shop) {
146+
try {
147+
shops.update(shop);
148+
} catch (java.sql.SQLException ex) {
149+
// Already logged in the service layer; we don't need to abort the chunk-load.
150+
}
151+
}
131152
}

0 commit comments

Comments
 (0)