Files
TiedUp-/src/main/java/com/tiedup/remake/entities/EntityKidnapperMerchant.java
NotEvil d6bb030ad7 feat(D-01/E): resistance & lock system rework (E1-E7)
E1: Initialize currentResistance in NBT at equip time from
    ResistanceComponent — eliminates MAX-scan fallback bug

E2: BuiltInLockComponent for organic items (already committed)

E3: canStruggle refactor — new model:
    - ARMS: always struggle-able (no lock gating)
    - Non-ARMS: only if locked OR built-in lock
    - Removed dead isItemLocked() from StruggleState + overrides

E4: canUnequip already handled by BuiltInLockComponent.blocksUnequip()
    via ComponentHolder delegation

E5: Help/assist mechanic deferred (needs UI design)

E6: Removed lock resistance from ILockable (5 methods + NBT key deleted)
    - GenericKnife: new knifeCutProgress NBT for cutting locks
    - StruggleAccessory: accessoryStruggleResistance NBT replaces lock resistance
    - PacketV2StruggleStart: uses config-based padlock resistance
    - All lock/unlock packets cleaned of initializeLockResistance/clearLockResistance

E7: Fixed 3 pre-existing bugs:
    - B2: DataDrivenItemRegistry.clear() synchronized on RELOAD_LOCK
    - B3: V2TyingPlayerTask validates heldStack before equip (prevents duplication)
    - B5: EntityKidnapperMerchant.remove() cleans playerToMerchant map (memory leak)
2026-04-15 03:23:49 +02:00

908 lines
28 KiB
Java

package com.tiedup.remake.entities;
import static com.tiedup.remake.util.GameConstants.*;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.SettingsAccessor;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.SpeakerType;
import com.tiedup.remake.entities.ai.ConditionalGoal;
import com.tiedup.remake.entities.ai.kidnapper.*;
import com.tiedup.remake.entities.skins.Gender;
import com.tiedup.remake.entities.skins.MerchantKidnapperSkinManager;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.*;
import com.tiedup.remake.items.clothes.GenericClothes;
import com.tiedup.remake.personality.PersonalityType;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.ICaptor;
import com.tiedup.remake.util.MessageDispatcher;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.Nullable;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.ai.attributes.AttributeSupplier;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.ai.goal.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
/**
* Kidnapper Merchant - Elite kidnapper who trades mod items for gold.
*
* Behavior:
* - MERCHANT mode (default): Neutral, wanders peacefully, accepts trades
* - HOSTILE mode (when attacked): Full elite kidnapper AI, faster capture
* - Reverts to MERCHANT after 5 minutes OR when attacker is captured/sold
*
* Trading:
* - Sells 8-12 random mod items for gold ingots/nuggets
* - Prices are tier-based (1-2 gold for basic binds, 10-20 for GPS collar)
* - Trades are fixed once generated (persist in NBT)
*/
public class EntityKidnapperMerchant extends EntityKidnapperElite {
// MERCHANT STATE
public enum MerchantState {
MERCHANT(0),
HOSTILE(1);
private final int id;
MerchantState(int id) {
this.id = id;
}
public int getId() {
return id;
}
public static MerchantState fromId(int id) {
return id == 1 ? HOSTILE : MERCHANT;
}
}
// DATA SYNC
private static final EntityDataAccessor<Integer> DATA_MERCHANT_STATE =
SynchedEntityData.defineId(
EntityKidnapperMerchant.class,
EntityDataSerializers.INT
);
// STATE (Server-side only)
private MerchantState currentState = MerchantState.MERCHANT;
private List<MerchantTrade> trades = new ArrayList<>();
private int hostileCooldownTicks = 0;
@Nullable
private UUID attackerUUID = null;
@Nullable
private UUID lastSoldCaptiveUUID = null;
// Track players currently trading with this merchant
private final Set<UUID> tradingPlayers = new HashSet<>();
// Static reverse-lookup: player UUID -> merchant entity UUID
// Used by PlayerDisconnectHandler for O(1) cleanup instead of scanning all entities
private static final ConcurrentHashMap<UUID, UUID> playerToMerchant =
new ConcurrentHashMap<>();
// CONSTRUCTOR
public EntityKidnapperMerchant(
EntityType<? extends EntityKidnapperMerchant> type,
Level level
) {
super(type, level);
}
// ATTRIBUTES (Same as Elite)
public static AttributeSupplier.Builder createAttributes() {
return Mob.createMobAttributes()
.add(Attributes.MAX_HEALTH, 40.0D)
.add(Attributes.MOVEMENT_SPEED, 0.35D)
.add(Attributes.KNOCKBACK_RESISTANCE, 0.9D)
.add(Attributes.FOLLOW_RANGE, 60.0D)
.add(Attributes.ATTACK_DAMAGE, 8.0D); // Same as Elite
}
// DATA SYNC
@Override
protected void defineSynchedData() {
super.defineSynchedData();
this.entityData.define(
DATA_MERCHANT_STATE,
MerchantState.MERCHANT.getId()
);
}
// INITIALIZATION
@Override
public void onAddedToWorld() {
super.onAddedToWorld();
// Server-side initialization
if (!this.level().isClientSide) {
// Generate trades on first spawn
if (trades.isEmpty()) {
generateRandomTrades();
TiedUpMod.LOGGER.info(
"[EntityKidnapperMerchant] Generated {} trades for merchant at {}",
trades.size(),
this.blockPosition()
);
}
}
// Set custom name with gold color when in merchant mode
updateCustomName();
}
/**
* Update the custom name to show merchant status.
*/
private void updateCustomName() {
if (currentState == MerchantState.MERCHANT) {
// Gold-colored name with symbols using ChatFormatting.GOLD
this.setCustomName(
Component.literal("" + this.getNpcName() + "")
.withStyle(net.minecraft.ChatFormatting.GOLD)
.withStyle(net.minecraft.ChatFormatting.BOLD)
);
this.setCustomNameVisible(true);
} else {
// In hostile mode, show name in dark red (same as elite)
this.setCustomName(
Component.literal(this.getNpcName()).withStyle(
net.minecraft.network.chat.Style.EMPTY.withColor(0xAA0000)
)
);
this.setCustomNameVisible(true);
}
}
// AI GOALS (Conditional)
@Override
protected void registerGoals() {
// DON'T call super - we need full control over goals
// Priority 0: Always swim
this.goalSelector.addGoal(0, new FloatGoal(this));
// Priority 1-10: Aggressive goals (ONLY when hostile)
this.goalSelector.addGoal(
1,
new ConditionalGoal(
new KidnapperFightBackGoal(this),
this::isHostile
)
);
this.goalSelector.addGoal(
2,
new ConditionalGoal(
new KidnapperFindTargetGoal(this, 25),
this::isHostile
)
);
this.goalSelector.addGoal(
3,
new ConditionalGoal(new KidnapperCaptureGoal(this), this::isHostile)
);
this.goalSelector.addGoal(
4,
new ConditionalGoal(
new KidnapperBringToCellGoal(this),
this::isHostile
)
);
// Priority 5: DecideNextAction - Active in BOTH modes
// This goal decides if the merchant should sell or do a job with the captive
// Must be active when reverting to MERCHANT mode after capturing attacker
this.goalSelector.addGoal(5, new KidnapperDecideNextActionGoal(this));
this.goalSelector.addGoal(
6,
new ConditionalGoal(new KidnapperPatrolGoal(this), this::isHostile)
);
this.goalSelector.addGoal(
7,
new ConditionalGoal(
new KidnapperFleeWithCaptiveGoal(this),
this::isHostile
)
);
this.goalSelector.addGoal(
8,
new ConditionalGoal(
new KidnapperFleeSafeGoal(this),
this::isHostile
)
);
// Priority 9-10: Sale and Job goals (active in MERCHANT mode)
// These should work when merchant has a captive, regardless of hostile state
this.goalSelector.addGoal(
9,
new ConditionalGoal(new KidnapperWaitForBuyerGoal(this), () ->
!isHostile()
)
);
this.goalSelector.addGoal(
10,
new ConditionalGoal(new KidnapperWaitForJobGoal(this), () ->
!isHostile()
)
);
// Priority 11-14: Peaceful goals (only when not trading)
this.goalSelector.addGoal(
11,
new ConditionalGoal(
new WaterAvoidingRandomStrollGoal(this, 1.0D),
() -> !isTrading()
)
);
this.goalSelector.addGoal(
12,
new LookAtPlayerGoal(this, Player.class, 8.0F)
);
this.goalSelector.addGoal(
13,
new ConditionalGoal(new RandomLookAroundGoal(this), () ->
!isTrading()
)
);
this.goalSelector.addGoal(14, new OpenDoorGoal(this, false));
}
// STATE TRANSITIONS
@Override
public boolean hurt(DamageSource source, float amount) {
boolean result = super.hurt(source, amount);
// Transition to hostile when attacked
if (
!level().isClientSide &&
source.getEntity() instanceof LivingEntity attacker
) {
if (currentState == MerchantState.MERCHANT) {
transitionToHostile(attacker);
}
}
return result;
}
/**
* Override equip to detect restraint attempts and become hostile.
* Consolidates former putBindOn/putGagOn/putBlindfoldOn overrides.
*/
@Override
public void equip(BodyRegionV2 region, ItemStack stack) {
if (
region == BodyRegionV2.ARMS ||
region == BodyRegionV2.MOUTH ||
region == BodyRegionV2.EYES
) {
if (
!level().isClientSide &&
currentState == MerchantState.MERCHANT &&
!stack.isEmpty()
) {
LivingEntity attacker = findNearbyAttacker();
if (attacker != null) {
transitionToHostile(attacker);
}
}
}
super.equip(region, stack);
}
/**
* Find nearby player who might be the one trying to restrain us.
*/
private LivingEntity findNearbyAttacker() {
// Look for players within 5 blocks
List<Player> nearbyPlayers = this.level().getEntitiesOfClass(
Player.class,
this.getBoundingBox().inflate(5.0),
player -> !player.isSpectator()
);
// Return closest player
if (!nearbyPlayers.isEmpty()) {
return nearbyPlayers.get(0);
}
return null;
}
private void transitionToHostile(LivingEntity attacker) {
currentState = MerchantState.HOSTILE;
attackerUUID = attacker.getUUID();
hostileCooldownTicks = ModConfig.SERVER.merchantHostileDuration.get();
entityData.set(DATA_MERCHANT_STATE, MerchantState.HOSTILE.getId());
// Equip kidnapper items
setUpHeldItems();
// Update name (hide merchant title)
updateCustomName();
// Talk to nearby players
talkToPlayersInRadius("You'll regret that!", 20);
TiedUpMod.LOGGER.info(
"[EntityKidnapperMerchant] {} transitioned to HOSTILE (attacked by {})",
this.getName().getString(),
attacker.getName().getString()
);
}
@Override
public void tick() {
super.tick();
// Handle hostile cooldown
if (!level().isClientSide && currentState == MerchantState.HOSTILE) {
hostileCooldownTicks--;
if (hostileCooldownTicks <= 0 || wasAttackerCaptured()) {
revertToMerchant();
}
}
// Spawn golden particles when in merchant mode (client-side)
if (
level().isClientSide &&
currentState == MerchantState.MERCHANT &&
this.random.nextFloat() < MERCHANT_SPARKLE_PARTICLE_CHANCE
) {
// Golden sparkle particles around the merchant using DustParticleOptions
double x =
this.getX() +
(this.random.nextDouble() - 0.5) * MERCHANT_PARTICLE_SPREAD_XZ;
double y =
this.getY() +
this.random.nextDouble() * MERCHANT_PARTICLE_SPREAD_Y;
double z =
this.getZ() +
(this.random.nextDouble() - 0.5) * MERCHANT_PARTICLE_SPREAD_XZ;
// Gold color (R=1.0, G=0.843, B=0.0) with size 1.0
net.minecraft.core.particles.DustParticleOptions goldDust =
new net.minecraft.core.particles.DustParticleOptions(
new org.joml.Vector3f(1.0F, 0.843F, 0.0F),
1.0F
);
this.level().addParticle(goldDust, x, y, z, 0.0, 0.02, 0.0);
}
}
private boolean wasAttackerCaptured() {
if (attackerUUID == null) return false;
// Check if current captive is the attacker
IBondageState captive = getCaptive();
if (
captive != null &&
captive.getKidnappedUniqueId().equals(attackerUUID)
) {
return true;
}
// Check if we sold the attacker
if (
lastSoldCaptiveUUID != null &&
lastSoldCaptiveUUID.equals(attackerUUID)
) {
return true;
}
return false;
}
private void revertToMerchant() {
currentState = MerchantState.MERCHANT;
attackerUUID = null;
lastSoldCaptiveUUID = null;
hostileCooldownTicks = 0;
entityData.set(DATA_MERCHANT_STATE, MerchantState.MERCHANT.getId());
// Clear aggressive behavior
setTarget(null);
clearHeldItems();
// Update name (show merchant title)
updateCustomName();
// Talk to nearby players
talkToPlayersInRadius("Alright... business as usual.", 20);
TiedUpMod.LOGGER.info(
"[EntityKidnapperMerchant] {} reverted to MERCHANT",
this.getName().getString()
);
}
// VARIANT SYSTEM - Override virtual methods
@Override
public KidnapperVariant lookupVariantById(String variantId) {
return MerchantKidnapperSkinManager.CORE.getVariant(variantId);
}
@Override
public KidnapperVariant computeVariantForEntity(UUID entityUUID) {
Gender preferredGender = SettingsAccessor.getPreferredSpawnGender(
this.level() != null ? this.level().getGameRules() : null
);
return MerchantKidnapperSkinManager.CORE.getVariantForEntity(
entityUUID,
preferredGender
);
}
@Override
public String getVariantTextureFolder() {
return "textures/entity/kidnapper/merchant/";
}
@Override
public String getDefaultVariantId() {
return "goldy";
}
@Override
public void applyVariantName(KidnapperVariant variant) {
// Merchant variants always use their default name
this.setNpcName(variant.defaultName());
}
@Override
public String getVariantNBTKey() {
// Use different key for backward compatibility with existing saves
return "MerchantVariantName";
}
// DISPLAY
/**
* Override getDisplayName to use customName when in merchant mode.
* This ensures the gold-colored "⚜ Merchant ⚜" is displayed.
*/
@Override
public Component getDisplayName() {
// In merchant mode, use the custom name (which has gold color)
if (currentState == MerchantState.MERCHANT && this.hasCustomName()) {
return this.getCustomName();
}
// In hostile mode, use parent's display name logic
return super.getDisplayName();
}
// SALE OVERRIDE (Track sold attacker)
@Override
public boolean completeSale(ICaptor buyer) {
IBondageState captive = getCaptive();
if (captive != null) {
lastSoldCaptiveUUID = captive.getKidnappedUniqueId();
}
return super.completeSale(buyer);
}
// TRADE GENERATION
private void generateRandomTrades() {
// GUARANTEED UTILITIES (always available)
addGuaranteedUtilities();
// RANDOM TRADES
int min = ModConfig.SERVER.merchantMinTrades.get();
int max = ModConfig.SERVER.merchantMaxTrades.get();
int count = min + this.random.nextInt(Math.max(1, max - min + 1));
// Collect all mod items
List<ItemStack> items = collectAllModItems();
// Shuffle manually (Collections.shuffle doesn't accept RandomSource)
for (int i = items.size() - 1; i > 0; i--) {
int j = this.random.nextInt(i + 1);
ItemStack temp = items.get(i);
items.set(i, items.get(j));
items.set(j, temp);
}
// Generate trades
for (int i = 0; i < Math.min(count, items.size()); i++) {
MerchantTrade trade = generateRandomPriceForItem(items.get(i));
trades.add(trade);
}
TiedUpMod.LOGGER.debug(
"[EntityKidnapperMerchant] Generated {} trades ({} utilities + {} random)",
trades.size(),
getUtilityCount(),
trades.size() - getUtilityCount()
);
}
/**
* Add guaranteed utility items that are ALWAYS available.
* These are essential tools that players need access to.
*/
private void addGuaranteedUtilities() {
// Collar Key - needed to link to collared entities (Tier 2 pricing: 3-6 gold)
trades.add(
new MerchantTrade(
new ItemStack(ModItems.COLLAR_KEY.get()),
4,
0 // 4 gold ingots
)
);
// Command Wand - needed to give commands to NPCs (Tier 3 pricing: 5-10 gold)
trades.add(
new MerchantTrade(
new ItemStack(ModItems.COMMAND_WAND.get()),
6,
0 // 6 gold ingots
)
);
// Lockpick - useful utility (Tier 2 pricing)
trades.add(
new MerchantTrade(
new ItemStack(ModItems.LOCKPICK.get()),
3,
0 // 3 gold ingots
)
);
// Cell Core - essential for building cells (expensive, Tier 4 pricing)
trades.add(
new MerchantTrade(
new ItemStack(
com.tiedup.remake.blocks.ModBlocks.CELL_CORE.get().asItem()
),
12,
0 // 12 gold ingots
)
);
}
private int getUtilityCount() {
return 4; // Number of guaranteed utilities
}
private List<ItemStack> collectAllModItems() {
List<ItemStack> items = new ArrayList<>();
// All data-driven bondage items (binds, gags, blindfolds, earplugs, mittens, collars, etc.)
for (com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition def :
com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry.getAll()) {
items.add(DataDrivenBondageItem.createStack(def.id()));
}
// Knives - no color support
for (KnifeVariant variant : KnifeVariant.values()) {
items.add(new ItemStack(ModItems.getKnife(variant)));
}
// Tools
items.add(new ItemStack(ModItems.WHIP.get()));
// BLACKLIST: TASER (too powerful)
// BLACKLIST: LOCKPICK (now in guaranteed utilities)
// BLACKLIST: MASTER_KEY (too OP - unlocks everything)
items.add(new ItemStack(ModItems.CLOTHES.get()));
return items;
}
private MerchantTrade generateRandomPriceForItem(ItemStack item) {
int tier = getItemTier(item);
// Calculate base price in nuggets
int minPrice, maxPrice;
switch (tier) {
case 4:
minPrice = ModConfig.SERVER.tier4PriceMin.get();
maxPrice = ModConfig.SERVER.tier4PriceMax.get();
break;
case 3:
minPrice = ModConfig.SERVER.tier3PriceMin.get();
maxPrice = ModConfig.SERVER.tier3PriceMax.get();
break;
case 2:
minPrice = ModConfig.SERVER.tier2PriceMin.get();
maxPrice = ModConfig.SERVER.tier2PriceMax.get();
break;
case 1:
default:
minPrice = ModConfig.SERVER.tier1PriceMin.get();
maxPrice = ModConfig.SERVER.tier1PriceMax.get();
break;
}
int baseNuggets =
minPrice +
this.random.nextInt(Math.max(1, maxPrice - minPrice + 1));
// Randomly split into ingots + nuggets
int ingots = this.random.nextBoolean() ? baseNuggets / 9 : 0;
int nuggets = baseNuggets - (ingots * 9);
return new MerchantTrade(item.copy(), ingots, nuggets);
}
private int getItemTier(ItemStack item) {
Item i = item.getItem();
// Tier 4: GPS collar
if (com.tiedup.remake.v2.bondage.CollarHelper.hasGPS(item)) {
return 4;
}
// Tier 3: Shock collar, taser, master key
if (
com.tiedup.remake.v2.bondage.CollarHelper.canShock(item) ||
i == ModItems.TASER.get() ||
i == ModItems.MASTER_KEY.get()
) {
return 3;
}
// Tier 2: Collars, whip, tools, complex items, clothes
if (
com.tiedup.remake.v2.bondage.CollarHelper.isCollar(item) ||
i == ModItems.WHIP.get() ||
i == ModItems.LOCKPICK.get() ||
i instanceof GenericClothes
) {
return 2;
}
// Tier 1: All other items (binds, gags, blindfolds, knives, etc.)
return 1;
}
// NBT PERSISTENCE
@Override
public void addAdditionalSaveData(CompoundTag tag) {
super.addAdditionalSaveData(tag);
tag.putInt("MerchantState", currentState.getId());
tag.putInt("HostileCooldown", hostileCooldownTicks);
if (attackerUUID != null) {
tag.putUUID("AttackerUUID", attackerUUID);
}
if (lastSoldCaptiveUUID != null) {
tag.putUUID("LastSoldCaptiveUUID", lastSoldCaptiveUUID);
}
// Variant is saved by parent via getVariantNBTKey()
// Save trades
ListTag tradesTag = new ListTag();
for (MerchantTrade trade : trades) {
tradesTag.add(trade.save());
}
tag.put("Trades", tradesTag);
}
@Override
public void readAdditionalSaveData(CompoundTag tag) {
super.readAdditionalSaveData(tag);
if (tag.contains("MerchantState")) {
currentState = MerchantState.fromId(tag.getInt("MerchantState"));
entityData.set(DATA_MERCHANT_STATE, currentState.getId());
}
hostileCooldownTicks = tag.getInt("HostileCooldown");
if (tag.contains("AttackerUUID")) {
attackerUUID = tag.getUUID("AttackerUUID");
}
if (tag.contains("LastSoldCaptiveUUID")) {
lastSoldCaptiveUUID = tag.getUUID("LastSoldCaptiveUUID");
}
// Variant is restored by parent via getVariantNBTKey() and lookupVariantById()
// Restore trades
trades.clear();
if (tag.contains("Trades")) {
ListTag tradesTag = tag.getList("Trades", 10); // 10 = CompoundTag
for (int i = 0; i < tradesTag.size(); i++) {
trades.add(MerchantTrade.load(tradesTag.getCompound(i)));
}
}
}
// PUBLIC ACCESSORS
public boolean isHostile() {
return currentState == MerchantState.HOSTILE;
}
public boolean isMerchant() {
return currentState == MerchantState.MERCHANT;
}
public List<MerchantTrade> getTrades() {
return new ArrayList<>(trades);
}
/**
* Check if the merchant is currently trading with a player.
*/
public boolean isTrading() {
return !tradingPlayers.isEmpty();
}
/**
* Mark that a player has opened the trading screen.
*/
public void startTrading(UUID playerUUID) {
tradingPlayers.add(playerUUID);
playerToMerchant.put(playerUUID, this.getUUID());
TiedUpMod.LOGGER.debug(
"[EntityKidnapperMerchant] Player {} started trading",
playerUUID
);
}
/**
* Mark that a player has closed the trading screen.
*/
public void stopTrading(UUID playerUUID) {
tradingPlayers.remove(playerUUID);
playerToMerchant.remove(playerUUID);
TiedUpMod.LOGGER.debug(
"[EntityKidnapperMerchant] Player {} stopped trading",
playerUUID
);
}
/**
* BUG FIX: Clean up trading player on disconnect to prevent memory leak.
* Called from PlayerDisconnectHandler.
*/
public void cleanupTradingPlayer(UUID playerUUID) {
tradingPlayers.remove(playerUUID);
playerToMerchant.remove(playerUUID);
}
/**
* Get the merchant entity UUID for a given trading player.
* Used for O(1) lookup on disconnect instead of scanning all entities.
*/
@Nullable
public static UUID getMerchantForPlayer(UUID playerUUID) {
return playerToMerchant.get(playerUUID);
}
// UTILITY
@Override
public void clearHeldItems() {
this.setItemInHand(
net.minecraft.world.InteractionHand.MAIN_HAND,
ItemStack.EMPTY
);
this.setItemInHand(
net.minecraft.world.InteractionHand.OFF_HAND,
ItemStack.EMPTY
);
}
public void talkToPlayersInRadius(String message, double radius) {
MessageDispatcher.talkToNearby(this, message, radius);
}
// DIALOGUE SPEAKER (Merchant-specific)
@Override
public SpeakerType getSpeakerType() {
return SpeakerType.MERCHANT;
}
@Override
public PersonalityType getSpeakerPersonality() {
// Personality changes based on mode
if (this.isHostile()) {
return PersonalityType.FIERCE; // Vengeful when attacked
}
return PersonalityType.CURIOUS; // Greedy/business-oriented in merchant mode
}
@Override
public int getSpeakerMood() {
if (this.isHostile()) {
return 30; // Angry
}
// In merchant mode, mood depends on having trades
if (!trades.isEmpty()) {
return 75; // Good business
}
return 60; // Waiting for customers
}
@Override
public String getTargetRelation(Player player) {
// If trading with player
if (tradingPlayers.contains(player.getUUID())) {
return "customer";
}
// Fall back to parent's implementation
return super.getTargetRelation(player);
}
// No die() override needed: vanilla die() calls remove(KILLED), so this
// remove() override already handles death cleanup. Parent EntityKidnapper.die()
// handles captive freeing and loot drops.
@Override
public void remove(RemovalReason reason) {
// Clear trading players to prevent dangling references
if (!this.level().isClientSide) {
int count = tradingPlayers.size();
// Clean up reverse-lookup map BEFORE clearing to prevent memory leak
for (UUID playerUuid : tradingPlayers) {
playerToMerchant.remove(playerUuid);
}
this.tradingPlayers.clear();
if (count > 0) {
TiedUpMod.LOGGER.debug(
"[EntityKidnapperMerchant] {} clearing {} trading players on removal",
getNpcName(),
count
);
}
}
super.remove(reason);
}
}