119 new translation keys across 3 domains: - Items/Blocks/Misc (46 keys): tooltips, action messages, trap states - Entities/AI Goals (55 keys): NPC speech, maid/master/guard messages - Client GUI (18 keys): widget labels, screen buttons, merchant display Remaining 119 Component.literal() are all intentional: - Debug/Admin/Command wands (47) — dev tools, not player-facing - Entity display names (~25) — dynamic getNpcName() calls - Empty string roots (~15) — .append() chain bases - User-typed text (~10) — /me, /pm, /norp chat content - Runtime data (~12) — StringBuilder, gag muffling, MCA compat
894 lines
29 KiB
Java
894 lines
29 KiB
Java
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<? extends EntitySlaveTrader> 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<CaptiveOfferData> 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<CaptiveOfferData> gatherCaptiveOffers(ServerLevel level) {
|
|
List<CaptiveOfferData> 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<CellDataV2> 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<CellDataV2> 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";
|
|
}
|
|
}
|