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)
908 lines
28 KiB
Java
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);
|
|
}
|
|
}
|