package com.tiedup.remake.entities; import com.tiedup.remake.cells.CampLifecycleManager; import com.tiedup.remake.cells.CampOwnership; import com.tiedup.remake.cells.CellDataV2; import com.tiedup.remake.cells.CellRegistryV2; import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.dialogue.SpeakerType; // Prison system v2 goals import com.tiedup.remake.entities.ai.trader.goals.TraderIdleGoal; import com.tiedup.remake.entities.ai.trader.goals.TraderSellGoal; import com.tiedup.remake.entities.skins.Gender; import com.tiedup.remake.entities.skins.TraderSkinManager; import com.tiedup.remake.items.ModItems; import com.tiedup.remake.items.base.IHasResistance; import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.trader.PacketOpenTraderScreen; import com.tiedup.remake.network.trader.PacketOpenTraderScreen.CaptiveOfferData; import com.tiedup.remake.personality.PersonalityType; import com.tiedup.remake.prison.PrisonerManager; import com.tiedup.remake.prison.PrisonerRecord; import com.tiedup.remake.state.IRestrainable; import com.tiedup.remake.util.KidnappedHelper; import com.tiedup.remake.util.tasks.ItemTask; import java.util.ArrayList; import java.util.List; import java.util.UUID; import javax.annotation.Nullable; import net.minecraft.ChatFormatting; import net.minecraft.core.BlockPos; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; 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.ItemStack; import net.minecraft.world.level.Level; /** * EntitySlaveTrader - Boss of a camp who sells captives. * * Slave Trader & Maid System * * Characteristics: * - Stats like Elite (high HP, fast, resistant) * - Does NOT capture (stays at camp) * - Manages sale of captives in camp cells * - Has a Maid who follows orders * - Token required to interact peacefully * * When killed: * - Camp becomes permanently dead * - Maid becomes neutral */ public class EntitySlaveTrader extends EntityKidnapperElite { // CONSTANTS public static final double TRADER_MAX_HEALTH = 50.0D; public static final double TRADER_MOVEMENT_SPEED = 0.30D; public static final double TRADER_KNOCKBACK_RESISTANCE = 0.95D; public static final double TRADER_FOLLOW_RANGE = 40.0D; public static final double TRADER_ATTACK_DAMAGE = 10.0D; public static final int TRADER_NAME_COLOR = 0xFFD700; // Gold // STATE /** UUID of the maid that serves this trader */ @Nullable private UUID maidUUID; /** UUID of the camp this trader manages */ @Nullable private UUID campUUID; /** Spawn position (marker location) */ @Nullable private net.minecraft.core.BlockPos spawnPos; /** Grace period: warned player UUID and tick */ @Nullable private UUID warnedPlayerUUID; private long warningTick; private static final int GRACE_PERIOD_TICKS = 100; // 5 seconds before warning expires /** Chase limit: tick when target was set, and max chase duration */ private long chaseStartTick; private static final int MAX_CHASE_TICKS = 200; // 10 seconds max chase private static final double MAX_CHASE_DISTANCE_FROM_SPAWN = 30.0; /** Hostility cooldown (ticks remaining before auto-reset) */ private int hostileCooldownTicks = 0; private static final int HOSTILE_DURATION_TICKS = 600; // 30 seconds max hostility // CONSTRUCTOR public EntitySlaveTrader( EntityType type, Level level ) { super(type, level); } // ATTRIBUTES public static AttributeSupplier.Builder createAttributes() { return Mob.createMobAttributes() .add(Attributes.MAX_HEALTH, TRADER_MAX_HEALTH) .add(Attributes.MOVEMENT_SPEED, TRADER_MOVEMENT_SPEED) .add(Attributes.KNOCKBACK_RESISTANCE, TRADER_KNOCKBACK_RESISTANCE) .add(Attributes.FOLLOW_RANGE, TRADER_FOLLOW_RANGE) .add(Attributes.ATTACK_DAMAGE, TRADER_ATTACK_DAMAGE); } // AI GOALS @Override protected void registerGoals() { // Trader has different AI - doesn't hunt, stays at camp // Priority 0: Always swim this.goalSelector.addGoal(0, new FloatGoal(this)); // Priority 1: Melee attack if hostile (trader fights but doesn't carry captives) this.goalSelector.addGoal(1, new MeleeAttackGoal(this, 1.2D, false)); // Priority 4: Sell captives (interact with buyers) this.goalSelector.addGoal(4, new TraderSellGoal(this)); // Priority 5: Idle/patrol behavior this.goalSelector.addGoal(5, new TraderIdleGoal(this)); // These were non-functional before (Traders have no PersonalityState). // Proper fix: Create TraderCommandGoals that use collar owner instead. // DamselAIController.registerCommandGoals(this.goalSelector, this, 6); // Priority 10: Look at players this.goalSelector.addGoal( 10, new LookAtPlayerGoal(this, Player.class, 8.0F) ); // Priority 11: Random look around this.goalSelector.addGoal(11, new RandomLookAroundGoal(this)); // Priority 12: Wander occasionally this.goalSelector.addGoal( 12, new WaterAvoidingRandomStrollGoal(this, 0.6D) ); } // INTERACTION @Override public InteractionResult mobInteract(Player player, InteractionHand hand) { // Enslaved: use base NPC behavior (feeding, conversation via EntityDamsel) if (this.isTiedUp()) { return super.mobInteract(player, hand); } if (this.level().isClientSide) { return InteractionResult.SUCCESS; } // Only process main hand to avoid double-triggering if (hand != InteractionHand.MAIN_HAND) { return InteractionResult.PASS; } // Check if player has token if (hasTokenInInventory(player)) { // Open trading screen if (player instanceof ServerPlayer serverPlayer) { openTradingScreen(serverPlayer); } return InteractionResult.SUCCESS; } else { // No token - grace period before becoming hostile if (player instanceof ServerPlayer serverPlayer) { if ( warnedPlayerUUID == null || !warnedPlayerUUID.equals(player.getUUID()) ) { // First warning — do NOT attack yet warnedPlayerUUID = player.getUUID(); warningTick = this.tickCount; serverPlayer.sendSystemMessage( Component.literal("[" + this.getNpcName() + "] ") .withStyle(Style.EMPTY.withColor(TRADER_NAME_COLOR)) .append( Component.translatable( "entity.tiedup.trader.no_token_warning" ).withStyle(ChatFormatting.RED) ) ); return InteractionResult.SUCCESS; } // Second click → attack directly this.hostileCooldownTicks = HOSTILE_DURATION_TICKS; this.setTarget(player); } return InteractionResult.SUCCESS; } } /** * Open the trading screen for a player. * Gathers available captives and sends packet to client. */ private void openTradingScreen(ServerPlayer player) { if (!(this.level() instanceof ServerLevel serverLevel)) { return; } // Gather captive offers from camp cells List offers = gatherCaptiveOffers(serverLevel); TiedUpMod.LOGGER.info( "[EntitySlaveTrader] {} opening trading screen for {} with {} offers", this.getNpcName(), player.getName().getString(), offers.size() ); // Send packet to open screen ModNetwork.sendToPlayer( new PacketOpenTraderScreen(this.getId(), this.getNpcName(), offers), player ); } /** * Gather captive offers from camp cells. * Supports both Players and NPCs (Damsels) as captives. */ private List gatherCaptiveOffers(ServerLevel level) { List offers = new ArrayList<>(); if (campUUID == null) { return offers; } CampOwnership ownership = CampOwnership.get(level); CampOwnership.CampData campData = ownership.getCamp(campUUID); if (campData == null || campData.getCenter() == null) { return offers; } CellRegistryV2 cellRegistry = CellRegistryV2.get(level); List cells = cellRegistry.findCellsNear( campData.getCenter(), 50.0 ); for (CellDataV2 cell : cells) { if (!cell.isOccupied()) { continue; } // Iterate over ALL prisoners in the cell (not just first) for (UUID captiveId : cell.getPrisonerIds()) { // Try to find the captive - first as Player, then as Entity net.minecraft.world.entity.LivingEntity captiveEntity = null; String captiveName = null; // Try player first ServerPlayer captivePlayer = level .getServer() .getPlayerList() .getPlayer(captiveId); if (captivePlayer != null) { captiveEntity = captivePlayer; captiveName = captivePlayer.getName().getString(); } else { // Try as entity (e.g., Damsel) net.minecraft.world.entity.Entity entity = level.getEntity( captiveId ); if ( entity instanceof net.minecraft.world.entity.LivingEntity living ) { captiveEntity = living; captiveName = living.getName().getString(); } } if (captiveEntity == null) { continue; } IRestrainable kidnappedState = KidnappedHelper.getKidnappedState(captiveEntity); if (kidnappedState == null || !kidnappedState.isForSell()) { continue; } ItemTask price = kidnappedState.getSalePrice(); if (price == null) { continue; } String priceDescription = price.getAmount() + "x " + (price.getItem() != null ? price.getItem().getDescription().getString() : "???"); offers.add( new CaptiveOfferData( captiveId, captiveName, priceDescription, price.getAmount(), price.getItemId() ) ); } } return offers; } // Uses EntityKidnapper.hasTokenInInventory(player) - inherited from parent // TARGETING - Trader doesn't capture @Override public void setTarget(@Nullable LivingEntity target) { super.setTarget(target); if (target != null) { this.chaseStartTick = this.tickCount; } } @Override public boolean isSuitableTarget( net.minecraft.world.entity.LivingEntity entity ) { // Trader doesn't actively hunt - only attacks if: // 1. Entity attacked us (getLastAttacker) // 2. Entity has no token if (entity instanceof Player player) { // Don't target players with tokens if (hasTokenInInventory(player)) { return false; } } // Only target if they attacked us return entity == this.getLastAttacker(); } // MAID & CAMP MANAGEMENT @Nullable public UUID getMaidUUID() { return maidUUID; } public void setMaidUUID(@Nullable UUID maidUUID) { this.maidUUID = maidUUID; } @Nullable public UUID getCampUUID() { return campUUID; } public void setCampUUID(@Nullable UUID campUUID) { this.campUUID = campUUID; } @Nullable public net.minecraft.core.BlockPos getSpawnPos() { return spawnPos; } public void setSpawnPos(@Nullable net.minecraft.core.BlockPos spawnPos) { this.spawnPos = spawnPos; } /** * Clear all hostility state and return to neutral. */ private void clearHostility() { this.setTarget(null); this.hostileCooldownTicks = 0; this.warnedPlayerUUID = null; } /** * Get the maid entity if loaded. */ @Nullable public EntityMaid getMaid() { if ( maidUUID == null || !(this.level() instanceof ServerLevel serverLevel) ) { return null; } var entity = serverLevel.getEntity(maidUUID); return entity instanceof EntityMaid maid ? maid : null; } /** * Order the maid to deliver a captive to a buyer. */ public void orderMaidDeliverCaptive(IRestrainable captive, Player buyer) { EntityMaid maid = getMaid(); if (maid != null) { maid.startDeliverCaptive(captive, buyer); } } /** * Order the maid to collect ransom/items. */ public void orderMaidCollectItems(BlockPos target) { EntityMaid maid = getMaid(); if (maid != null) { maid.startCollectItems(target); } } // DEATH HANDLING /** Guard against double-cleanup (die() triggers remove(KILLED)) */ private boolean cleanedUp = false; private void performCleanup() { if (cleanedUp) return; cleanedUp = true; if ( !this.level().isClientSide && this.level() instanceof ServerLevel serverLevel ) { // Mark camp as dead and perform full cleanup // This will: cancel ransoms, free prisoners, unlock collars, clear labor states if (campUUID != null) { CampLifecycleManager.markCampDead(campUUID, serverLevel); TiedUpMod.LOGGER.info( "[EntitySlaveTrader] {} removed, camp {} marked as dead and prisoners freed", this.getNpcName(), campUUID.toString().substring(0, 8) ); } // Free the maid (becomes neutral/capturable) EntityMaid maid = getMaid(); if (maid != null) { maid.onTraderDeath(); } } } @Override public void die( net.minecraft.world.damagesource.DamageSource damageSource ) { performCleanup(); super.die(damageSource); } @Override public void remove(Entity.RemovalReason reason) { performCleanup(); super.remove(reason); } // VARIANT SYSTEM @Override public KidnapperVariant lookupVariantById(String variantId) { return TraderSkinManager.CORE.getVariant(variantId); } @Override public KidnapperVariant computeVariantForEntity(UUID entityUUID) { return TraderSkinManager.CORE.getVariantForEntity( entityUUID, Gender.FEMALE ); } @Override public String getVariantTextureFolder() { return "textures/entity/kidnapper/trader/"; } @Override public String getDefaultVariantId() { return "trader_default"; } @Override public String getVariantNBTKey() { return "TraderVariantId"; } @Override public void applyVariantName(KidnapperVariant variant) { // Numbered variants (trader_mob_1, trader_mob_2, etc.) get random names // Named variants (trader_default, trader_noble, etc.) use their default name if (variant.id().startsWith("trader_mob_")) { this.setNpcName( com.tiedup.remake.util.NameGenerator.getRandomTraderName() ); } else { this.setNpcName(variant.defaultName()); } } // DISPLAY @Override public Component getDisplayName() { return Component.literal(this.getNpcName()).withStyle( Style.EMPTY.withColor(TRADER_NAME_COLOR) ); } // NBT PERSISTENCE @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); if (maidUUID != null) { tag.putUUID("MaidUUID", maidUUID); } if (campUUID != null) { tag.putUUID("CampUUID", campUUID); } if (spawnPos != null) { tag.putInt("SpawnX", spawnPos.getX()); tag.putInt("SpawnY", spawnPos.getY()); tag.putInt("SpawnZ", spawnPos.getZ()); } if (hostileCooldownTicks > 0) { tag.putInt("HostileCooldown", hostileCooldownTicks); } } @Override public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); if (tag.contains("MaidUUID")) { maidUUID = tag.getUUID("MaidUUID"); } if (tag.contains("CampUUID")) { campUUID = tag.getUUID("CampUUID"); } if ( tag.contains("SpawnX") && tag.contains("SpawnY") && tag.contains("SpawnZ") ) { spawnPos = new net.minecraft.core.BlockPos( tag.getInt("SpawnX"), tag.getInt("SpawnY"), tag.getInt("SpawnZ") ); } if (tag.contains("HostileCooldown")) { hostileCooldownTicks = tag.getInt("HostileCooldown"); } } // CAPTURE DETECTION /** * Check if trader has been captured and removed from camp. * If captured and taken far from camp (>100 blocks), the camp dies. */ @Override public void tick() { super.tick(); // Server-side only if ( !this.level().isClientSide && this.level() instanceof ServerLevel serverLevel ) { // Grace period expiry — just reset warning (player gets a fresh warning next click) if ( warnedPlayerUUID != null && this.tickCount - warningTick > GRACE_PERIOD_TICKS ) { warnedPlayerUUID = null; } // Chase limit — give up if too far from spawn or chasing too long if (this.getTarget() != null) { boolean shouldClearTarget = false; // Timer-based limit (always works, even if spawnPos is null) boolean tooLong = this.tickCount - chaseStartTick > MAX_CHASE_TICKS; if (tooLong) { shouldClearTarget = true; } // Distance-from-spawn limit (when spawnPos is available) if (!shouldClearTarget && spawnPos != null) { double distFromSpawn = this.distanceToSqr( spawnPos.getX() + 0.5, spawnPos.getY(), spawnPos.getZ() + 0.5 ); if ( distFromSpawn > MAX_CHASE_DISTANCE_FROM_SPAWN * MAX_CHASE_DISTANCE_FROM_SPAWN ) { shouldClearTarget = true; } } // Token forgiveness — stop chasing if target acquired a token if ( !shouldClearTarget && this.getTarget() instanceof Player targetPlayer && hasTokenInInventory(targetPlayer) ) { shouldClearTarget = true; } if (shouldClearTarget) { clearHostility(); } } // Hostility cooldown — auto-reset after duration expires if (hostileCooldownTicks > 0) { hostileCooldownTicks--; if (hostileCooldownTicks <= 0 && this.getTarget() != null) { clearHostility(); } } // Check every second (20 ticks) if (this.tickCount % 20 == 0) { checkIfCapturedAndRemoved(serverLevel); } } } private void checkIfCapturedAndRemoved(ServerLevel level) { // Must be tied up if (!isTiedUp()) return; // Must have a camp UUID campId = getCampUUID(); if (campId == null) return; CampOwnership ownership = CampOwnership.get(level); CampOwnership.CampData camp = ownership.getCamp(campId); if (camp == null || camp.getCenter() == null) return; // Check distance from camp center BlockPos campCenter = camp.getCenter(); double distanceSq = this.distanceToSqr( campCenter.getX(), campCenter.getY(), campCenter.getZ() ); // If > 100 blocks away → camp destroyed double CAPTURE_DISTANCE_THRESHOLD = 100.0; if ( distanceSq > CAPTURE_DISTANCE_THRESHOLD * CAPTURE_DISTANCE_THRESHOLD ) { TiedUpMod.LOGGER.warn( "[EntitySlaveTrader] {} captured and removed {} blocks from camp {} - marking camp dead", this.getNpcName(), (int) Math.sqrt(distanceSq), campId.toString().substring(0, 8) ); // Mark camp as dead (frees all prisoners, etc.) CampLifecycleManager.markCampDead(campId, level); // Clear our camp reference this.setCampUUID(null); // Broadcast destruction message broadcastCampDestruction(level, campCenter); } } private void broadcastCampDestruction( ServerLevel level, BlockPos campCenter ) { Component message = Component.translatable( "entity.tiedup.trader.camp_destroyed" ).withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD); // Send to all players within 200 blocks for (ServerPlayer player : level.players()) { double distSq = player.distanceToSqr( campCenter.getX(), campCenter.getY(), campCenter.getZ() ); if (distSq < 200 * 200) { player.sendSystemMessage(message); } } } // STRUGGLE DETECTION /** Target prisoner to approach after catching them struggling */ @Nullable private ServerPlayer strugglePunishmentTarget; /** Maximum distance to HEAR struggle (through bars, no line of sight needed) */ private static final double STRUGGLE_HEARING_RANGE = 6.0; /** Maximum distance to SEE struggle (requires line of sight) */ private static final double STRUGGLE_VISION_RANGE = 15.0; /** * Called when a prisoner is detected struggling nearby. * Uses HYBRID detection: HEARING (close range) + VISION (far range). * * Detection modes: * - Within 6 blocks: Can HEAR through bars/fences (no line of sight needed) * - Within 15 blocks: Can SEE if line of sight is clear * * If detected: * 1. Shock the prisoner (punishment - harder than maid!) * 2. Approach to tighten their binds (reset resistance) * * @param prisoner The player who is struggling */ public void onStruggleDetected(ServerPlayer prisoner) { // HYBRID DETECTION: Hearing (close) + Vision (far) double distance = this.distanceTo(prisoner); // HEARING: Close range - can hear through bars/fences (no LOS needed) boolean canHear = distance <= STRUGGLE_HEARING_RANGE; // VISION: Longer range - requires clear line of sight boolean canSee = distance <= STRUGGLE_VISION_RANGE && this.getSensing().hasLineOfSight(prisoner); if (!canHear && !canSee) { return; // Can't detect the struggle } // Check if this player is a prisoner of our camp if (campUUID == null) { return; } if (!(this.level() instanceof ServerLevel serverLevel)) { return; } PrisonerManager manager = PrisonerManager.get(serverLevel); PrisonerRecord record = manager.getPrisoner(prisoner.getUUID()); if (record == null || !campUUID.equals(record.getCampId())) { return; // Not our prisoner } IRestrainable state = KidnappedHelper.getKidnappedState(prisoner); if (state == null || !state.isTiedUp()) { return; } String detectionMethod = canHear ? "heard" : "saw"; TiedUpMod.LOGGER.info( "[EntitySlaveTrader] {} {} {} struggling! Punishing... (distance: {})", this.getNpcName(), detectionMethod, prisoner.getName().getString(), distance ); // PUNISHMENT: Shock the prisoner state.shockKidnapped(" Don't even think about it.", 3.0f); // Trader shocks harder // TIGHTEN BINDS: Reset resistance to maximum tightenBinds(state, prisoner); // Look at the prisoner menacingly this.getLookControl().setLookAt(prisoner, 30.0f, 30.0f); // Set as target to approach this.strugglePunishmentTarget = prisoner; } /** * Tighten the prisoner's binds by resetting their resistance to maximum. * This happens when a guard catches someone struggling. * * @param state The prisoner's kidnapped state * @param prisoner The prisoner entity */ private void tightenBinds(IRestrainable state, LivingEntity prisoner) { com.tiedup.remake.util.RestraintApplicator.tightenBind(state, prisoner); } /** * Get the current struggle punishment target. * @return The prisoner to approach, or null if none */ @Nullable public ServerPlayer getStrugglePunishmentTarget() { return this.strugglePunishmentTarget; } /** * Clear the struggle punishment target (after approaching or timeout). */ public void clearStrugglePunishmentTarget() { this.strugglePunishmentTarget = null; } // DIALOGUE SPEAKER (Trader-specific) @Override public SpeakerType getSpeakerType() { return SpeakerType.TRADER; } @Override public PersonalityType getSpeakerPersonality() { // Traders are greedy business-oriented return PersonalityType.PROUD; } @Override public int getSpeakerMood() { // Mood based on business (captives to sell) // Count captives in camp cells int captiveCount = 0; if ( campUUID != null && this.level() instanceof ServerLevel serverLevel ) { CampOwnership ownership = CampOwnership.get(serverLevel); CampOwnership.CampData campData = ownership.getCamp(campUUID); if (campData != null && campData.getCenter() != null) { CellRegistryV2 cellRegistry = CellRegistryV2.get(serverLevel); List cells = cellRegistry.findCellsNear( campData.getCenter(), 50.0 ); for (CellDataV2 cell : cells) { if (cell.isOccupied()) { captiveCount += cell.getPrisonerIds().size(); } } } } if (captiveCount > 2) { return 85; // Excellent business! } else if (captiveCount > 0) { return 70; // Good business } return 50; // Waiting for captives } @Override public String getTargetRelation(Player player) { // Check if player has trader token (friendly) // For now, just return customer if interacting return "customer"; } }