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 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 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; } }