Files
TiedUp-/src/main/java/com/tiedup/remake/entities/EntityLaborGuard.java
NotEvil f4aa5ffdc5 split PrisonerService + decompose EntityKidnapper
PrisonerService 1057L -> 474L lifecycle + 616L EscapeMonitorService
EntityKidnapper 2035L -> 1727L via LootManager, Dialogue, CaptivePriority extraction
2026-04-16 14:08:52 +02:00

662 lines
22 KiB
Java

package com.tiedup.remake.entities;
import com.tiedup.remake.cells.CellDataV2;
import com.tiedup.remake.cells.CellRegistryV2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.SpeakerType;
import com.tiedup.remake.entities.ai.guard.GuardFightBackGoal;
import com.tiedup.remake.entities.ai.guard.GuardFollowPrisonerGoal;
import com.tiedup.remake.entities.ai.guard.GuardHuntMonstersGoal;
import com.tiedup.remake.entities.ai.guard.GuardMonitorGoal;
import com.tiedup.remake.entities.skins.LaborGuardSkinManager;
import com.tiedup.remake.labor.LaborTask;
import com.tiedup.remake.prison.LaborRecord;
import com.tiedup.remake.prison.PrisonerManager;
import com.tiedup.remake.prison.service.EscapeMonitorService;
import com.tiedup.remake.state.IRestrainable;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.util.MessageDispatcher;
import com.tiedup.remake.util.RestraintApplicator;
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.resources.ResourceLocation;
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.damagesource.DamageSource;
import net.minecraft.world.entity.EntityType;
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.FloatGoal;
import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal;
import net.minecraft.world.entity.ai.goal.MeleeAttackGoal;
import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
/**
* Labor Guard entity - Physical guard that follows and monitors a prisoner during labor.
*
* Extends EntityDamsel for the player model, skin system, and IRestrainable interface
* (the guard itself can be captured/tied up as an escape mechanic).
*
* Spawned by MaidExtractGoal after extraction, despawned by MaidReturnGoal when
* the prisoner returns to cell.
*/
public class EntityLaborGuard extends EntityDamsel {
// ==================== CONSTANTS ====================
/** Distance at which prisoner gets warning + escape countdown starts */
public static final double WARNING_RADIUS = 20.0;
/** Ticks before escape after leaving warning radius (15 seconds) */
public static final int ESCAPE_COUNTDOWN_TICKS = 300;
/** Distance at which guard teleports to prisoner */
public static final double TELEPORT_DISTANCE = 32.0;
/** Guard name color — steel blue */
public static final int GUARD_NAME_COLOR = 0x4682B4;
// ==================== SERVER FIELDS ====================
@Nullable
private UUID prisonerUUID;
@Nullable
private UUID campId;
@Nullable
private UUID spawnerMaidId;
/** Prevents registerGoals from being called twice by parent constructor */
private boolean goalsRegistered = false;
/** Prevents triggerEscapeOnIncapacitated from firing multiple times */
private boolean escapeTriggered = false;
/** Cooldown to prevent double-speak from Forge's dual interaction packets */
private long lastInteractTick = -100;
/** Set by GuardMonitorGoal when prisoner needs physical punishment */
private boolean needsWhip = false;
// ==================== CONSTRUCTOR ====================
public EntityLaborGuard(
EntityType<? extends EntityLaborGuard> type,
Level level
) {
super(type, level);
}
// ==================== ATTRIBUTES ====================
public static AttributeSupplier.Builder createAttributes() {
return Mob.createMobAttributes()
.add(Attributes.MAX_HEALTH, 30.0)
.add(Attributes.MOVEMENT_SPEED, 0.30)
.add(Attributes.ATTACK_DAMAGE, 6.0)
.add(Attributes.FOLLOW_RANGE, 40.0)
.add(Attributes.KNOCKBACK_RESISTANCE, 0.5);
}
// ==================== AI GOALS ====================
/**
* Override registerGoals to set up guard-specific AI.
* Complete override - does not use DamselAIController goals.
*
* IMPORTANT: This is called twice during construction:
* 1. By Mob() constructor (via super chain)
* 2. By EntityDamsel() constructor at line 455
* We use a flag to ensure goals are only registered once.
*/
@Override
protected void registerGoals() {
if (this.goalsRegistered) {
return;
}
// Also skip if called during Mob's constructor before goalSelector is set
// (shouldn't happen, but safety check)
if (this.goalSelector == null) {
return;
}
this.goalSelector.addGoal(0, new FloatGoal(this));
this.goalSelector.addGoal(1, new GuardHuntMonstersGoal(this));
this.goalSelector.addGoal(2, new GuardFightBackGoal(this));
this.goalSelector.addGoal(3, new MeleeAttackGoal(this, 1.2, false));
this.goalSelector.addGoal(4, new GuardFollowPrisonerGoal(this));
this.goalSelector.addGoal(
5,
new com.tiedup.remake.entities.ai.guard.GuardWhipGoal(this)
);
this.goalSelector.addGoal(6, new GuardMonitorGoal(this));
this.goalSelector.addGoal(
8,
new LookAtPlayerGoal(this, Player.class, 8.0F)
);
this.goalSelector.addGoal(9, new RandomLookAroundGoal(this));
// Register command goals so the guard responds to player commands
com.tiedup.remake.entities.damsel.components.DamselAIController.registerCommandGoals(
this.goalSelector,
this,
7
);
this.goalsRegistered = true;
}
// ==================== SKIN TEXTURE ====================
/**
* Override to use guard/ texture folder instead of damsel/.
* DamselAppearance.getSkinTexture() hardcodes "textures/entity/damsel/",
* so we must override to point to "textures/entity/guard/".
*/
@Override
public ResourceLocation getSkinTexture() {
String variantId = this.getVariantId();
if (!variantId.isEmpty()) {
return ResourceLocation.fromNamespaceAndPath(
"tiedup",
"textures/entity/guard/" + variantId + ".png"
);
}
// Fallback to first registered guard skin
DamselVariant fallback = LaborGuardSkinManager.CORE.getVariantForEntity(
this.getUUID()
);
if (fallback != null) {
return fallback.texture();
}
return ResourceLocation.fromNamespaceAndPath(
"tiedup",
"textures/entity/guard/feifei.png"
);
}
// ==================== VARIANT SYSTEM ====================
/**
* Override to use LaborGuardSkinManager instead of DamselSkinManager.
*/
@Override
public void onAddedToWorld() {
super.onAddedToWorld();
// Override variant with guard skin (server-side only)
if (
!this.level().isClientSide &&
(this.getVariantId().isEmpty() ||
LaborGuardSkinManager.CORE.getVariant(this.getVariantId()) ==
null)
) {
DamselVariant variant =
LaborGuardSkinManager.CORE.getVariantForEntity(this.getUUID());
if (variant != null) {
this.setVariant(variant);
}
}
// Safety: ensure guard always has a name (belt-and-suspenders for variant loading failures)
if (!this.level().isClientSide && this.getNpcName().isEmpty()) {
this.setNpcName("Guard");
}
}
// ==================== DISPLAY ====================
@Override
public Component getDisplayName() {
return Component.literal(this.getNpcName()).withStyle(
Style.EMPTY.withColor(GUARD_NAME_COLOR)
);
}
// ==================== DIALOGUE (IDialogueSpeaker overrides) ====================
@Override
public SpeakerType getSpeakerType() {
return SpeakerType.GUARD;
}
@Override
@Nullable
public com.tiedup.remake.personality.PersonalityType getSpeakerPersonality() {
return com.tiedup.remake.personality.PersonalityType.FIERCE;
}
@Override
public int getSpeakerMood() {
return 50; // Neutral
}
@Override
@Nullable
public String getTargetRelation(Player player) {
if (prisonerUUID != null && player.getUUID().equals(prisonerUUID)) {
return "prisoner";
}
return null;
}
// ==================== TICK ====================
@Override
public void tick() {
super.tick();
if (
!this.level().isClientSide &&
this.level() instanceof ServerLevel level
) {
// Auto-cleanup: if tied up, trigger escape (guard is incapacitated)
if (this.isTiedUp() && !escapeTriggered) {
triggerEscapeOnIncapacitated(level);
return;
}
// Orphan/duplication check every 5 seconds
if (prisonerUUID != null && tickCount % 100 == 0) {
PrisonerManager manager = PrisonerManager.get(level);
LaborRecord labor = manager.getLaborRecord(prisonerUUID);
UUID assignedGuard = labor.getGuardId();
if (assignedGuard == null) {
// Guard reference was cleared (escape, reset, etc.) - orphan, discard self
TiedUpMod.LOGGER.info(
"[EntityLaborGuard] Orphan guard detected (guardId=null), discarding self {}",
this.getUUID().toString().substring(0, 8)
);
this.discard();
return;
}
if (!assignedGuard.equals(this.getUUID())) {
// Different guard is assigned - duplicate, discard self
TiedUpMod.LOGGER.warn(
"[EntityLaborGuard] Duplicate guard detected, discarding self (assigned={}, self={})",
assignedGuard.toString().substring(0, 8),
this.getUUID().toString().substring(0, 8)
);
this.discard();
return;
}
}
}
}
// ==================== DAMAGE HANDLING ====================
/**
* Override hurt to:
* 1. Reduce monster damage by 50%
* 2. Punish prisoner if they attack the guard
*/
@Override
public boolean hurt(DamageSource source, float amount) {
if (!this.level().isClientSide) {
// 50% damage reduction from monsters
if (
source.getEntity() instanceof
net.minecraft.world.entity.monster.Monster
) {
amount *= 0.5f;
}
// If attacked by the prisoner, punish them (don't fight back)
if (
source.getEntity() instanceof ServerPlayer player &&
prisonerUUID != null &&
player.getUUID().equals(prisonerUUID)
) {
punishPrisonerAttack(player);
return false; // Guard ignores prisoner damage
}
}
return super.hurt(source, amount);
}
/**
* Punish prisoner for attacking the guard.
*/
private void punishPrisonerAttack(ServerPlayer prisoner) {
IRestrainable cap = KidnappedHelper.getKidnappedState(prisoner);
if (cap != null) {
cap.shockKidnapped("Don't touch your guard!", 2.0f);
RestraintApplicator.tightenBind(cap, prisoner);
}
prisoner.sendSystemMessage(
Component.translatable("entity.tiedup.guard.attack_punished").withStyle(
ChatFormatting.DARK_RED
)
);
}
// ==================== INTERACTION ====================
@Override
protected InteractionResult mobInteract(
Player player,
InteractionHand hand
) {
if (hand != InteractionHand.MAIN_HAND) {
return super.mobInteract(player, hand);
}
if (
!this.level().isClientSide &&
player instanceof ServerPlayer serverPlayer &&
prisonerUUID != null &&
player.getUUID().equals(prisonerUUID) &&
this.level() instanceof ServerLevel serverLevel
) {
// Cooldown: Forge sends INTERACT_AT + INTERACT per right-click
long currentTick = this.tickCount;
if (currentTick - lastInteractTick < 5) {
return InteractionResult.SUCCESS;
}
lastInteractTick = currentTick;
PrisonerManager manager = PrisonerManager.get(serverLevel);
LaborRecord labor = manager.getLaborRecord(prisonerUUID);
LaborTask task = labor.getTask();
LaborRecord.WorkPhase phase = labor.getPhase();
if (phase == LaborRecord.WorkPhase.WORKING) {
if (task != null) {
// Refresh progress before showing
task.checkProgress(serverPlayer, serverLevel);
int progress = task.getProgress();
int quota = task.getQuota();
int percent = task.getProgressPercent();
guardSay(
serverPlayer,
"Task: " +
task.getDescription() +
"" +
progress +
"/" +
quota +
" (" +
percent +
"%)"
);
} else {
guardSay(serverPlayer, "Get to work!");
}
showCampDirection(serverPlayer, serverLevel);
return InteractionResult.SUCCESS;
}
if (phase == LaborRecord.WorkPhase.PENDING_RETURN) {
guardSay(
serverPlayer,
"guard.labor.pending_return",
"Walk back to camp. Follow me."
);
showCampDirection(serverPlayer, serverLevel);
return InteractionResult.SUCCESS;
}
// Catch-all for other phases (EXTRACTING, RETURNING, etc.)
guardSay(serverPlayer, "Follow me.");
return InteractionResult.SUCCESS;
}
return super.mobInteract(player, hand);
}
// ==================== HELPERS ====================
/**
* Send a formatted guard speech message to a player.
* Tries JSON dialogue first, falls back to provided message.
*/
public void guardSay(
ServerPlayer player,
String dialogueId,
String fallback
) {
String text = com.tiedup.remake.dialogue.DialogueBridge.getDialogue(
this,
player,
dialogueId
);
if (text == null) {
text = fallback;
}
MessageDispatcher.talkTo(this, player, text);
}
/**
* Send a formatted guard speech message (no dialogue ID, direct message).
*/
public void guardSay(ServerPlayer player, String message) {
MessageDispatcher.talkTo(this, player, message);
}
/**
* Show camp direction to a player.
*/
private void showCampDirection(ServerPlayer player, ServerLevel level) {
if (campId == null) return;
List<CellDataV2> campCells = CellRegistryV2.get(level).getCellsByCamp(
campId
);
if (campCells.isEmpty()) return;
BlockPos campCenter = campCells.get(0).getCorePos();
String direction = getCardinalDirection(
player.blockPosition(),
campCenter
);
int distance = (int) Math.sqrt(
player.blockPosition().distSqr(campCenter)
);
guardSay(
player,
"Camp is " + distance + " blocks to the " + direction + "."
);
}
/**
* Get cardinal direction from one position to another.
*/
private static String getCardinalDirection(BlockPos from, BlockPos to) {
int dx = to.getX() - from.getX();
int dz = to.getZ() - from.getZ();
// In Minecraft: -Z = north, +Z = south, +X = east, -X = west
String ns = "";
String ew = "";
if (Math.abs(dz) > 2) ns = dz < 0 ? "north" : "south";
if (Math.abs(dx) > 2) ew = dx > 0 ? "east" : "west";
if (!ns.isEmpty() && !ew.isEmpty()) return ns + "-" + ew;
if (!ns.isEmpty()) return ns;
if (!ew.isEmpty()) return ew;
return "nearby";
}
// ==================== DEATH / REMOVAL ====================
/**
* Shared cleanup: clear guard reference, trigger prisoner escape, notify.
* Guarded by escapeTriggered to prevent double-fire from die() + remove().
*/
private void performPrisonerCleanup(String reason) {
if (escapeTriggered) return;
if (this.level().isClientSide) return;
if (!(this.level() instanceof ServerLevel level)) return;
if (prisonerUUID == null) return;
escapeTriggered = true;
TiedUpMod.LOGGER.info(
"[EntityLaborGuard] Guard {} - triggering escape for prisoner {}",
reason,
prisonerUUID.toString().substring(0, 8)
);
// Clear guard reference
PrisonerManager manager = PrisonerManager.get(level);
LaborRecord labor = manager.getLaborRecord(prisonerUUID);
labor.setGuardId(null);
// Trigger escape
EscapeMonitorService.get().escape(level, prisonerUUID, reason);
// Notify prisoner
ServerPlayer prisoner = level
.getServer()
.getPlayerList()
.getPlayer(prisonerUUID);
if (prisoner != null) {
prisoner.sendSystemMessage(
Component.translatable(
"entity.tiedup.guard.eliminated_free"
).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD)
);
}
}
/**
* When the guard dies, trigger IMMEDIATE escape for the prisoner.
*/
@Override
public void die(DamageSource source) {
performPrisonerCleanup("guard eliminated");
super.die(source);
}
/**
* Handle non-death removal (e.g. /kill, chunk unload, dimension change).
* DISCARDED is intentional (orphan self-cleanup) — already handled by tick check.
*/
@Override
public void remove(RemovalReason reason) {
if (reason != RemovalReason.DISCARDED) {
performPrisonerCleanup("guard removed (" + reason.name() + ")");
}
super.remove(reason);
}
/**
* Trigger escape when the guard is incapacitated (tied up / captured).
*/
private void triggerEscapeOnIncapacitated(ServerLevel level) {
performPrisonerCleanup("guard captured");
// Discard the guard after triggering escape
this.discard();
}
// ==================== GETTERS/SETTERS ====================
@Nullable
public UUID getPrisonerUUID() {
return prisonerUUID;
}
public void setPrisonerUUID(@Nullable UUID prisonerUUID) {
this.prisonerUUID = prisonerUUID;
}
@Nullable
public UUID getCampId() {
return campId;
}
public void setCampId(@Nullable UUID campId) {
this.campId = campId;
}
@Nullable
public UUID getSpawnerMaidId() {
return spawnerMaidId;
}
public void setSpawnerMaidId(@Nullable UUID spawnerMaidId) {
this.spawnerMaidId = spawnerMaidId;
}
public boolean needsWhip() {
return needsWhip;
}
public void setNeedsWhip(boolean needsWhip) {
this.needsWhip = needsWhip;
}
/**
* Get the prisoner player entity (server-side).
*
* @return The prisoner player, or null if offline or no prisoner assigned
*/
@Nullable
public ServerPlayer getPrisoner() {
if (prisonerUUID == null) return null;
if (!(this.level() instanceof ServerLevel level)) return null;
return level.getServer().getPlayerList().getPlayer(prisonerUUID);
}
// ==================== NBT PERSISTENCE ====================
@Override
public void addAdditionalSaveData(CompoundTag tag) {
super.addAdditionalSaveData(tag);
if (prisonerUUID != null) {
tag.putUUID("PrisonerUUID", prisonerUUID);
}
if (campId != null) {
tag.putUUID("CampId", campId);
}
if (spawnerMaidId != null) {
tag.putUUID("SpawnerMaidId", spawnerMaidId);
}
}
@Override
public void readAdditionalSaveData(CompoundTag tag) {
super.readAdditionalSaveData(tag);
if (tag.contains("PrisonerUUID")) {
this.prisonerUUID = tag.getUUID("PrisonerUUID");
}
if (tag.contains("CampId")) {
this.campId = tag.getUUID("CampId");
}
if (tag.contains("SpawnerMaidId")) {
this.spawnerMaidId = tag.getUUID("SpawnerMaidId");
}
}
// ==================== DESPAWN PROTECTION ====================
@Override
public boolean removeWhenFarAway(double distanceToClosestPlayer) {
return false;
}
@Override
public boolean isPersistenceRequired() {
return true;
}
}