package com.tiedup.remake.entities; import static com.tiedup.remake.util.GameConstants.*; 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 DATA_MERCHANT_STATE = SynchedEntityData.defineId( EntityKidnapperMerchant.class, EntityDataSerializers.INT ); // STATE (Server-side only) private MerchantState currentState = MerchantState.MERCHANT; private List 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 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 playerToMerchant = new ConcurrentHashMap<>(); // CONSTRUCTOR public EntityKidnapperMerchant( EntityType 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 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 = SettingsAccessor.getMerchantHostileDuration(); 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 = SettingsAccessor.getMerchantMinTrades(); int max = SettingsAccessor.getMerchantMaxTrades(); int count = min + this.random.nextInt(Math.max(1, max - min + 1)); // Collect all mod items List 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 collectAllModItems() { List 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 = SettingsAccessor.getTier4PriceMin(); maxPrice = SettingsAccessor.getTier4PriceMax(); break; case 3: minPrice = SettingsAccessor.getTier3PriceMin(); maxPrice = SettingsAccessor.getTier3PriceMax(); break; case 2: minPrice = SettingsAccessor.getTier2PriceMin(); maxPrice = SettingsAccessor.getTier2PriceMax(); break; case 1: default: minPrice = SettingsAccessor.getTier1PriceMin(); maxPrice = SettingsAccessor.getTier1PriceMax(); 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 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); } }