Volver al índice

src/main/java/ar/com/companeros/horror/ParasiteEntity.java

package ar.com.companeros.horror;

import java.util.*;
import ar.com.companeros.horror.client.ParasiteRig;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.syncher.*;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.ai.navigation.*;
import net.minecraft.world.entity.ai.control.JumpControl;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
import net.minecraft.util.Mth;

/** Cuerpo físico original. Luna puede elegir la intención; la locomoción se ejecuta localmente. */
public final class ParasiteEntity extends Monster {
    public enum Intent { STALK, RETREAT, AMBUSH, SCARE, CLIMB }
    // Se conservan los ordinales históricos 0/1; las preferencias nuevas no cambian saves antiguos.
    public enum BodyPosture { HUNCHED, CRAWL, AUTO, ALL_FOURS, WALL_BRACE }
    public static final int MAX_POSTURE_TICKS = 1200, DEFAULT_POSTURE_TICKS = 600;
    private static final EntityDataAccessor<Integer> STAGE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.INT);
    private static final EntityDataAccessor<Optional<UUID>> HOST = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.OPTIONAL_UUID);
    private static final EntityDataAccessor<String> HOST_NAME = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.STRING);
    private static final EntityDataAccessor<Byte> FLAGS = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.BYTE);
    private static final EntityDataAccessor<Long> ENCOUNTER_START = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.LONG);
    private static final EntityDataAccessor<Long> ATTACK_START = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.LONG);
    private static final EntityDataAccessor<Byte> POSTURE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.BYTE);
    private static final EntityDataAccessor<Float> COMPACT_PROGRESS = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.FLOAT);
    private static final EntityDataAccessor<String> HOST_SKIN_SOURCE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.STRING);
    private static final EntityDataAccessor<Integer> VISUAL_UPGRADES = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.INT);
    public static final int ENCOUNTER_DURATION_TICKS = 1220;
    public static final int ENCOUNTER_DURATION = ENCOUNTER_DURATION_TICKS;
    public static final int ENCOUNTER_INTRO = 140;
    public static final int ENCOUNTER_COOLDOWN = 6000;
    private Intent intent = Intent.STALK;
    private long intentUntil;
    private long nextEncounterAt;
    private long toolPauseUntil;
    private UUID encounterAudience;
    private int attackCooldown;
    private long nextStepSoundAt;
    private long successfulAttacks, deliveredScares;
    private float previousCompactProgress;
    private float clientCompactProgress;
    private int compactHoldTicks;
    private float heldCompactTarget;
    private long bodyPostureUntil;

    public ParasiteEntity(EntityType<? extends Monster> type, Level level) {
        super(type, level); setPersistenceRequired(); setCanPickUpLoot(false); xpReward = 0;
        jumpControl = new JumpControl(this) {
            @Override public void tick() {
                super.tick();
                Vec3 next = getNavigation() instanceof ParasiteNavigation navigation ? navigation.nextWaypoint() : null;
                // Vanilla intenta saltar dentro de la celda de una escotilla abierta. Su placa lateral
                // no exige un salto; el cuerpo plegado conserva el contacto con el suelo plano.
                if (isCompact() && !isInWater() && !isInLava() && (next == null || next.y <= getY() + .15)) setJumping(false);
            }
        };
    }
    public static AttributeSupplier.Builder attributes() {
        return Monster.createMonsterAttributes().add(Attributes.MAX_HEALTH, 40).add(Attributes.MOVEMENT_SPEED, .27)
            .add(Attributes.ATTACK_DAMAGE, 4).add(Attributes.FOLLOW_RANGE, 40).add(Attributes.KNOCKBACK_RESISTANCE, .35);
    }
    @Override protected void defineSynchedData() {
        super.defineSynchedData(); entityData.define(STAGE, 0); entityData.define(HOST, Optional.empty());
        entityData.define(HOST_NAME, ""); entityData.define(FLAGS, (byte) 0);
        entityData.define(ENCOUNTER_START, -1L); entityData.define(ATTACK_START, -1L);
        entityData.define(POSTURE, (byte) 0); entityData.define(COMPACT_PROGRESS, 0F);
        entityData.define(HOST_SKIN_SOURCE, "");
        entityData.define(VISUAL_UPGRADES, 0);
    }
    @Override protected PathNavigation createNavigation(Level level) { return new ParasiteNavigation(this, level); }
    @Override protected void registerGoals() { }
    public int getMutationStage() { return entityData.get(STAGE); }
    public Optional<UUID> hostId() { return entityData.get(HOST); }
    public String getHostName() { return entityData.get(HOST_NAME); }
    public void setHost(UUID id, String name) { entityData.set(HOST, Optional.of(id)); entityData.set(HOST_NAME, name); }
    public String getHostSkinSource() { return entityData.get(HOST_SKIN_SOURCE); }
    public int getVisualUpgrades() { return entityData.get(VISUAL_UPGRADES); }
    public void setVisualUpgrades(int mask) { if (!level().isClientSide) entityData.set(VISUAL_UPGRADES, mask & 511); }
    public void setHostSkinSource(String source) {
        if (source == null || source.length() > 256 || (!source.isEmpty()
                && !source.matches("[A-Za-z0-9_]{1,16}|[a-fA-F0-9-]{32,36}|https://textures\\.minecraft\\.net/texture/[a-fA-F0-9]{64}")))
            throw new IllegalArgumentException("Origen de skin inválido");
        if (!level().isClientSide) entityData.set(HOST_SKIN_SOURCE, source);
    }
    public void setMutationStage(int stage) {
        if (stage < 0 || stage > 4) throw new IllegalArgumentException("Etapa inválida");
        float nextHeight = Mth.lerp(entityData.get(COMPACT_PROGRESS), 1.8F + stage * .45F, ParasiteClearance.COMPACT_HEIGHT);
        if (stage != getMutationStage() && !level().isClientSide && !ParasiteClearance.fits(level(), this, position(), nextHeight)) {
            float standing = 1.8F + stage * .45F;
            float retainedHeight = Math.min(getBbHeight(), standing);
            float retainedProgress = Mth.clamp((standing - retainedHeight) / (standing - ParasiteClearance.COMPACT_HEIGHT),
                entityData.get(COMPACT_PROGRESS), 1);
            entityData.set(COMPACT_PROGRESS, ParasiteClearance.fits(level(), this, position(), retainedHeight) ? retainedProgress : 1F);
        }
        entityData.set(STAGE, stage);
        if (!level().isClientSide) {
            getAttribute(Attributes.ATTACK_DAMAGE).setBaseValue(4 + stage * 1.5);
            getAttribute(Attributes.MOVEMENT_SPEED).setBaseValue(.27 + stage * .015);
        }
    }
    public boolean isClimbing() { return (entityData.get(FLAGS) & 1) != 0; }
    public boolean isAmbushing() { return (entityData.get(FLAGS) & 2) != 0; }
    public boolean isPursuing() { return (entityData.get(FLAGS) & 4) != 0; }
    public long successfulAttackCount() { return successfulAttacks; }
    public long deliveredScareCount() { return deliveredScares; }
    public boolean isCrawling() { return entityData.get(POSTURE) == 1; }
    public BodyPosture getBodyPosture() {
        int ordinal = entityData.get(POSTURE);
        return ordinal >= 0 && ordinal < BodyPosture.values().length ? BodyPosture.values()[ordinal] : BodyPosture.AUTO;
    }
    public boolean isCompact() { return entityData.get(COMPACT_PROGRESS) > .001F; }
    public float getStandingHeight() { return 1.8F + getMutationStage() * .45F; }
    public float getCompactProgress(float partialTick) {
        return Mth.lerp(Mth.clamp(partialTick, 0, 1), previousCompactProgress,
            level().isClientSide ? clientCompactProgress : entityData.get(COMPACT_PROGRESS));
    }
    @Override public EntityDimensions getDimensions(Pose pose) {
        return EntityDimensions.scalable(ParasiteClearance.WIDTH,
            Mth.lerp(entityData.get(COMPACT_PROGRESS), getStandingHeight(), ParasiteClearance.COMPACT_HEIGHT));
    }
    @Override protected float getStandingEyeHeight(Pose pose, EntityDimensions dimensions) { return dimensions.height * .85F; }
    @Override public void refreshDimensions() {
        // La expansión ya se valida aquí antes de aplicarse. Se conservan los pies en su lugar:
        // refreshDimensions vanilla busca otra posición al crecer y produciría saltos del cuerpo.
        fixupDimensions(); reapplyPosition();
    }
    @Override public void onSyncedDataUpdated(EntityDataAccessor<?> data) {
        super.onSyncedDataUpdated(data);
        if (COMPACT_PROGRESS.equals(data) && level().isClientSide && tickCount == 0)
            previousCompactProgress = clientCompactProgress = entityData.get(COMPACT_PROGRESS);
        if (STAGE.equals(data) || COMPACT_PROGRESS.equals(data)) refreshDimensions();
    }
    @Override public void tick() {
        previousCompactProgress = level().isClientSide ? clientCompactProgress : entityData.get(COMPACT_PROGRESS);
        if (level().isClientSide) clientCompactProgress = Mth.approach(clientCompactProgress, entityData.get(COMPACT_PROGRESS), 1F / ParasiteClearance.TRANSITION_TICKS);
        else updateBodyClearance();
        super.tick();
    }

    // Se pliega antes del túnel y sólo se incorpora si el volumen ampliado está libre.
    private void updateBodyClearance() {
        // Una preferencia de Luna caduca y pierde el apoyo si el bloque desapareció.
        // La física puede exigir un plegado mayor que el preferido, nunca uno menor.
        if ((bodyPostureUntil > 0 && level().getGameTime() >= bodyPostureUntil)
                || (getBodyPosture() == BodyPosture.WALL_BRACE && !bodyPoseEnvironment().wallAvailable())) {
            setBodyPosture(BodyPosture.AUTO);
        }
        Vec3 waypoint = getNavigation() instanceof ParasiteNavigation navigation ? navigation.nextWaypoint() : null;
        Vec3 ahead = clearanceLookAhead(waypoint);
        float preferred = switch (getBodyPosture()) {
            case CRAWL -> 1;
            case ALL_FOURS -> .63636F;
            case WALL_BRACE -> .27272F;
            default -> 0;
        };
        float desired = Math.max(preferred, requiredCompactProgress(ahead));
        // La postura cambia también el volumen físico. No hay una animación baja escondida
        // dentro de un collider de pie; crecer sigue requiriendo espacio libre en el mundo.
        if (!isCrawling() && !isClimbing() && getMutationStage() >= 2
                && getTarget() instanceof ServerPlayer human
                && level() instanceof ServerLevel serverLevel && HorrorModule.mayTarget(serverLevel.getServer(), human)) {
            int encounter = getEncounterTicks();
            float presentation = encounter >= 0 ? ParasiteRig.encounterCompact(encounter, getMutationStage())
                : bodyPostureUntil > 0 ? 0
                : isPursuing() && intent == Intent.AMBUSH ? ParasiteRig.chaseCompact(tickCount, getMutationStage())
                : intent == Intent.STALK || intent == Intent.SCARE ? ParasiteRig.stalkCompact(tickCount, getMutationStage()) : 0;
            desired = Math.max(desired, presentation);
        }
        if (desired > 0) { compactHoldTicks = 8; heldCompactTarget = desired; }
        else if (compactHoldTicks > 0) { compactHoldTicks--; desired = heldCompactTarget; }
        float old = entityData.get(COMPACT_PROGRESS);
        float next = Mth.approach(old, desired, 1F / ParasiteClearance.TRANSITION_TICKS);
        float nextHeight = Mth.lerp(next, getStandingHeight(), ParasiteClearance.COMPACT_HEIGHT);
        if (next >= old || ParasiteClearance.fits(level(), this, position(), nextHeight)) entityData.set(COMPACT_PROGRESS, next);
    }
    private float requiredCompactProgress(Vec3 ahead) {
        for (float height : new float[]{getStandingHeight(), Math.min(getStandingHeight(), 2.85F),
                Math.min(getStandingHeight(), 1.85F), ParasiteClearance.COMPACT_HEIGHT}) {
            if (ParasiteClearance.fits(level(), this, position(), height)
                    && (ahead == null || ParasiteClearance.clearSegment(level(), this, position(), ahead, height)))
                return Mth.clamp((getStandingHeight() - height) / (getStandingHeight() - ParasiteClearance.COMPACT_HEIGHT), 0, 1);
        }
        return 1;
    }

    private Vec3 clearanceLookAhead(Vec3 waypoint) {
        if (waypoint == null) return null;
        Vec3 offset = new Vec3(waypoint.x - getX(), 0, waypoint.z - getZ());
        if (offset.lengthSqr() < .0025) return null;
        Vec3 horizontal = position().add(offset.normalize().scale(Math.min(1.15, offset.length())));
        return new Vec3(horizontal.x, Math.max(getY(), waypoint.y), horizontal.z);
    }

    boolean needsClearancePause(Vec3 waypoint) {
        Vec3 ahead = clearanceLookAhead(waypoint);
        return ahead != null && getBbHeight() > ParasiteClearance.COMPACT_HEIGHT + .001F
            && !ParasiteClearance.clearSegment(level(), this, position(), ahead, getBbHeight())
            && ParasiteClearance.clearSegment(level(), this, position(), ahead, ParasiteClearance.COMPACT_HEIGHT);
    }
    public void setBodyPosture(BodyPosture posture) {
        if (posture == null) throw new IllegalArgumentException("Postura inválida");
        if (!level().isClientSide) { entityData.set(POSTURE, (byte) posture.ordinal()); bodyPostureUntil = 0; }
    }

    // REPERTORIO: la IA pide una postura, los apoyos y el espacio se comprueban localmente.
    public static BodyPosture parseBodyPosture(String mode) {
        if (mode == null) throw new IllegalArgumentException("Postura inválida");
        return switch (mode) {
            case "auto" -> BodyPosture.AUTO;
            case "hunched" -> BodyPosture.HUNCHED;
            case "crawl" -> BodyPosture.CRAWL;
            case "all_fours" -> BodyPosture.ALL_FOURS;
            case "wall_brace" -> BodyPosture.WALL_BRACE;
            default -> throw new IllegalArgumentException("Postura fuera del repertorio");
        };
    }
    public boolean requestBodyPosture(String mode, int durationTicks) {
        if (durationTicks < 20 || durationTicks > MAX_POSTURE_TICKS) throw new IllegalArgumentException("Duración de postura fuera de límite");
        if (level().isClientSide) return false;
        BodyPosture posture = parseBodyPosture(mode);
        BodyPoseEnvironment environment = bodyPoseEnvironment();
        if ((posture == BodyPosture.WALL_BRACE && (getMutationStage() < 2 || !environment.grounded() || !environment.wallAvailable()))
                || (posture == BodyPosture.ALL_FOURS && (getMutationStage() < 2 || !environment.grounded()))
                || (posture == BodyPosture.CRAWL && !environment.grounded())) return false;
        setBodyPosture(posture);
        if (posture != BodyPosture.AUTO) bodyPostureUntil = level().getGameTime() + durationTicks;
        return true;
    }
    public record BodyPoseEnvironment(boolean grounded, float leftWall, float rightWall, boolean standingClear) {
        public boolean wallAvailable() { return leftWall > 0 || rightWall > 0; }
        public String compact() {
            return "ground=" + grounded + " left_wall=" + distance(leftWall) + " right_wall=" + distance(rightWall)
                + " standing_clear=" + standingClear;
        }
        private static String distance(float value) { return value > 0 ? String.format(Locale.ROOT, "%.2f", value) : "none"; }
    }
    public BodyPoseEnvironment bodyPoseEnvironment() {
        Vec3 floorStart = position().add(0, .08, 0), floorEnd = position().add(0, -.18, 0);
        BlockHitResult floor = supportRay(floorStart, floorEnd);
        boolean grounded = floor != null && floor.getDirection() == net.minecraft.core.Direction.UP;
        float yaw = yBodyRot * Mth.DEG_TO_RAD;
        Vec3 origin = position().add(0, 1.65, 0);
        Vec3 right = new Vec3(Mth.cos(yaw), 0, Mth.sin(yaw));
        return new BodyPoseEnvironment(grounded, wallSupportDistance(origin, right), wallSupportDistance(origin, right.scale(-1)),
            ParasiteClearance.fits(level(), this, position(), getStandingHeight()));
    }
    private float wallSupportDistance(Vec3 start, Vec3 direction) {
        BlockHitResult hit = supportRay(start, start.add(direction.scale(.9)));
        if (hit == null || hit.getDirection().getAxis() == net.minecraft.core.Direction.Axis.Y) return 0;
        float distance = (float) hit.getLocation().distanceTo(start);
        return distance >= .3F && distance <= .9F ? distance : 0;
    }
    private BlockHitResult supportRay(Vec3 start, Vec3 end) {
        if (!level().hasChunkAt(BlockPos.containing(start)) || !level().hasChunkAt(BlockPos.containing(end))) return null;
        BlockHitResult hit = level().clip(new ClipContext(start, end, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this));
        return hit.getType() == HitResult.Type.BLOCK ? hit : null;
    }
    public void pauseForTool(int ticks) {
        if (ticks < 0 || ticks > 1200) throw new IllegalArgumentException("Pausa fuera de límite");
        if (!level().isClientSide) {
            toolPauseUntil = level().getGameTime() + ticks; getNavigation().stop();
            setDeltaMovement(0, getDeltaMovement().y, 0);
        }
    }
    public int getEncounterTicks() {
        long started = entityData.get(ENCOUNTER_START), elapsed = level().getGameTime() - started;
        return started < 0 || elapsed < 0 || elapsed >= ENCOUNTER_DURATION ? -1 : (int) elapsed;
    }
    public int getAttackAnimationTicks() {
        long started = entityData.get(ATTACK_START), elapsed = level().getGameTime() - started;
        return started < 0 || elapsed < 0 || elapsed >= 10 ? 0 : 10 - (int) elapsed;
    }
    public Intent intent() { return intent; }
    public void setIntent(Intent intent, int durationTicks) {
        if (intent == null || durationTicks < 20 || durationTicks > 12_000) throw new IllegalArgumentException("Intención inválida");
        this.intent = intent; intentUntil = level().getGameTime() + durationTicks;
    }
    @Override public boolean onClimbable() { return isClimbing(); }
    @Override public AABB getBoundingBoxForCulling() { return getBoundingBox().inflate(2, 3, 2); }

    // El encuentro requiere una mirada real. Nunca gira la cámara ni modifica controles humanos.
    public boolean tryBeginGazeEncounter(ServerPlayer player) {
        if (!canBeginEncounter(player)) return false;
        double distance = distanceToSqr(player);
        if (distance < 3 * 3 || distance > 24 * 24 || !hasLineOfSight(player)) return false;
        Vec3 toCreature = getEyePosition().subtract(player.getEyePosition()).normalize();
        return player.getLookAngle().dot(toCreature) >= Math.cos(Math.toRadians(15)) && beginEncounter(player);
    }
    private boolean canBeginEncounter(ServerPlayer player) {
        return level() instanceof ServerLevel level && player.level() == level && getMutationStage() >= 1
            && HorrorModule.mayTarget(level.getServer(), player) && level.getGameTime() >= nextEncounterAt
            && HorrorPacing.get(level.getServer()).available(player.getUUID(), level.getGameTime())
            && HorrorPacing.get(level.getServer()).soundReady(player.getUUID(), level.getGameTime())
            && getEncounterTicks() < 0;
    }
    public boolean beginEncounter(ServerPlayer player) {
        if (!canBeginEncounter(player) || distanceToSqr(player) > 32 * 32 || !hasLineOfSight(player)) return false;
        long time = level().getGameTime();
        entityData.set(ENCOUNTER_START, time); nextEncounterAt = time + ENCOUNTER_COOLDOWN;
        encounterAudience = player.getUUID(); setTarget(player); getNavigation().stop();
        setIntent(Intent.STALK, ENCOUNTER_DURATION);
        HorrorModule.playForEligibleHumans(this, HorrorModule.PARASITE_LORE.get(), 1, 1);
        return true;
    }
    public void excludeSensitivePlayer(ServerPlayer player) {
        if (player.getUUID().equals(encounterAudience)) endEncounter(false);
        if (getTarget() == player) { setTarget(null); getNavigation().stop(); }
    }
    private void endEncounter(boolean pursue) {
        entityData.set(ENCOUNTER_START, -1L); encounterAudience = null;
        if (pursue && getMutationStage() >= 2) setIntent(Intent.AMBUSH, 1200);
    }
    private boolean advanceEncounter(ServerLevel level) {
        if (entityData.get(ENCOUNTER_START) < 0) return false;
        ServerPlayer audience = encounterAudience == null ? null : level.getServer().getPlayerList().getPlayer(encounterAudience);
        if (audience == null || audience.level() != level || !HorrorModule.mayTarget(level.getServer(), audience)) {
            endEncounter(false); getNavigation().stop(); return false;
        }
        long elapsed = level.getGameTime() - entityData.get(ENCOUNTER_START);
        if (elapsed >= ENCOUNTER_DURATION) { endEncounter(true); return false; }
        setTarget(audience);
        getLookControl().setLookAt(audience, 30, 30);
        if (elapsed < ENCOUNTER_INTRO) {
            getNavigation().stop(); setDeltaMovement(0, getDeltaMovement().y, 0); return true;
        }
        return false;
    }

    // Selección humana y sensibilidad se revisan antes de navegar, mirar, intimidar o causar daño.
    @Override public void aiStep() {
        super.aiStep();
        if (!(level() instanceof ServerLevel level) || !isAlive()) return;
        HorrorState state = HorrorState.get(level.getServer());
        if (tickCount % 20 == 0) ParasiteUpgrades.get(level.getServer()).applyAttributes(this);
        if (hostId().isEmpty() || !state.host().filter(hostId().get()::equals).isPresent()
                || !state.entityId().filter(getUUID()::equals).isPresent()) { discard(); return; }
        if (attackCooldown > 0) attackCooldown--;
        HorrorPacing pacing = HorrorPacing.get(level.getServer());
        if (tickCount % 20 == 0) {
            for (ServerPlayer human : level.players()) if (HorrorModule.mayTarget(level.getServer(), human))
                pacing.observe(human, level.getGameTime());
            if (getTarget() instanceof ServerPlayer human
                && (!human.isAlive() || pacing.exhausted(human.getUUID(), level.getGameTime()))) releaseAudience();
        }
        if (level.getGameTime() >= intentUntil) intent = getTarget() instanceof ServerPlayer human
            ? pacing.baseline(human.getUUID()) : Intent.STALK;
        if (getTarget() != null && !HorrorModule.mayTarget(level.getServer(), getTarget())) { setTarget(null); getNavigation().stop(); }
        if (getEncounterTicks() < 0 && level.getGameTime() >= nextEncounterAt && tickCount % 5 == 0) {
            level.players().stream().filter(player -> HorrorModule.mayTarget(level.getServer(), player))
                .sorted(Comparator.comparingDouble(this::distanceToSqr)).anyMatch(this::tryBeginGazeEncounter);
        }
        boolean encounterIntro = advanceEncounter(level) || level.getGameTime() < toolPauseUntil;
        if (encounterIntro) { getNavigation().stop(); setDeltaMovement(0, getDeltaMovement().y, 0); }
        if (tickCount % 20 == 0 && getTarget() == null && pacing.intensity() > 0) {
            level.players().stream().filter(player -> HorrorModule.mayTarget(level.getServer(), player))
                .filter(player -> pacing.available(player.getUUID(), level.getGameTime()))
                .filter(player -> distanceToSqr(player) < Math.pow(getAttributeValue(Attributes.FOLLOW_RANGE), 2))
                .max(Comparator.comparingDouble(player -> pacing.priority(player.getUUID(), level.getGameTime())
                    - distanceTo(player) * .2)).ifPresent(this::setTarget);
        }
        LivingEntity target = getTarget();
        boolean climbing = !encounterIntro && target != null && getMutationStage() >= 2 && horizontalCollision
            && (intent == Intent.CLIMB || target.getY() > getY() + 1);
        if (climbing) setDeltaMovement(getDeltaMovement().x, Math.max(.18, getDeltaMovement().y), getDeltaMovement().z);
        if (tickCount % 20 == 0) state.location(level.dimension().location().toString(), blockPosition());
        if (target == null) {
            entityData.set(FLAGS, (byte) 0); return;
        }
        if (!encounterIntro && tickCount % 10 == 0) executeLocomotion(target);
        boolean pursuing = !encounterIntro && !climbing && intent != Intent.RETREAT && getMutationStage() >= 2
            && !getNavigation().isDone() && HorrorModule.mayTarget(level.getServer(), target);
        entityData.set(FLAGS, (byte) ((climbing ? 1 : 0) | (intent == Intent.AMBUSH ? 2 : 0) | (pursuing ? 4 : 0)));
        if (intent == Intent.AMBUSH && target instanceof ServerPlayer human && pacing.mayAttack(human.getUUID(), level.getGameTime(), getMutationStage()))
            pacing.hunting(human.getUUID(), level.getGameTime());
        if (!encounterIntro && intent == Intent.AMBUSH && getMutationStage() >= 2 && distanceToSqr(target) < 2.8 * 2.8 && hasLineOfSight(target) && attackCooldown == 0) {
            doHurtTarget(target); attackCooldown = 30;
        }
    }
    private void executeLocomotion(LivingEntity target) {
        if (!HorrorModule.mayTarget(((ServerLevel) level()).getServer(), target)) { setTarget(null); getNavigation().stop(); return; }
        getLookControl().setLookAt(target, 25, 25);
        Vec3 away = position().subtract(target.position());
        if (intent == Intent.RETREAT || (getMutationStage() < 2 && distanceToSqr(target) < 7 * 7)) {
            Vec3 direction = away.lengthSqr() < .01 ? new Vec3(1, 0, 1) : away.normalize();
            Vec3 destination = position().add(direction.scale(9));
            getNavigation().moveTo(destination.x, destination.y, destination.z, 1.1);
        } else if (intent == Intent.AMBUSH || intent == Intent.CLIMB || distanceToSqr(target) > 16 * 16) {
            boolean hunting = target instanceof ServerPlayer human && HorrorPacing.get(((ServerLevel)level()).getServer())
                .mayAttack(human.getUUID(), level().getGameTime(), getMutationStage());
            getNavigation().moveTo(target, intent == Intent.AMBUSH && hunting ? 1.45 : .55);
        } else if (distanceToSqr(target) < 9 * 9 && intent != Intent.AMBUSH) {
            Vec3 direction = away.lengthSqr() < .01 ? new Vec3(1, 0, 1) : away.normalize();
            Vec3 destination = position().add(direction.scale(5));
            getNavigation().moveTo(destination.x, destination.y, destination.z, .7);
        }
        else getNavigation().stop();
    }
    @Override public void setTarget(LivingEntity target) {
        if (target != null && level() instanceof ServerLevel level && !HorrorModule.mayTarget(level.getServer(), target)) target = null;
        if (target instanceof ServerPlayer human && level() instanceof ServerLevel level
            && !HorrorPacing.get(level.getServer()).available(human.getUUID(), level.getGameTime())) target = null;
        if (getTarget() instanceof ServerPlayer previous && previous != target && level() instanceof ServerLevel level)
            HorrorPacing.get(level.getServer()).release(previous.getUUID(), level.getGameTime());
        super.setTarget(target);
        if (target instanceof ServerPlayer human && level() instanceof ServerLevel level)
            HorrorPacing.get(level.getServer()).begin(human.getUUID(), level.getGameTime());
        if (target == null) entityData.set(FLAGS, (byte) (entityData.get(FLAGS) & ~4));
    }
    @Override public boolean doHurtTarget(Entity target) {
        if (!(level() instanceof ServerLevel level) || !HorrorModule.mayTarget(level.getServer(), target)
            || (getEncounterTicks() >= 0 && getEncounterTicks() < ENCOUNTER_INTRO)) return false;
        if (level.getGameTime() < toolPauseUntil) return false;
        if (!(target instanceof ServerPlayer human) || !HorrorPacing.get(level.getServer())
                .mayAttack(human.getUUID(), level.getGameTime(), getMutationStage())) return false;
        boolean damaged = super.doHurtTarget(target);
        if (damaged) {
            entityData.set(ATTACK_START, level.getGameTime()); successfulAttacks++;
            HorrorPacing.get(level.getServer()).hit(human.getUUID());
            if (!human.isAlive()) { HorrorPacing.get(level.getServer()).killed(human.getUUID(), level.getGameTime()); releaseAudience(); }
            else if (HorrorPacing.get(level.getServer()).exhausted(human.getUUID(), level.getGameTime())) releaseAudience();
        }
        return damaged;
    }
    @Override public boolean isPushable() { return false; }
    @Override public void push(Entity entity) {
        if (!(level() instanceof ServerLevel level) || !HorrorModule.mayTarget(level.getServer(), entity)) return;
        super.push(entity);
    }
    @Override protected void playStepSound(BlockPos pos, BlockState state) {
        if (level().isClientSide || state.isAir() || isClimbing() || level().getGameTime() < nextStepSoundAt) return;
        var material = state.getSoundType(level(), pos, this);
        float weight = isPursuing() ? .24F : .11F;
        HorrorModule.playPhysicalCue(this, material.getStepSound(), material.getVolume() * weight, material.getPitch() * .86F);
        nextStepSoundAt = level().getGameTime() + (isPursuing() ? 6 : 11);
    }
    @Override public void die(DamageSource source) {
        if (level() instanceof ServerLevel level) {
            HorrorState.get(level.getServer()).died(); ParasiteUpgrades.get(level.getServer()).combatResult(true);
        }
        super.die(source);
    }
    @Override public boolean hurt(DamageSource source, float amount) {
        boolean changed = super.hurt(source, amount);
        if (changed && amount > 0 && level() instanceof ServerLevel level)
            ParasiteUpgrades.get(level.getServer()).damageObserved(source.getMsgId());
        if (changed && source.getEntity() instanceof ServerPlayer human && level() instanceof ServerLevel level
                && HorrorModule.mayTarget(level.getServer(), human)) HorrorPacing.get(level.getServer()).provoked(human.getUUID(), level.getGameTime());
        return changed;
    }
    public void releaseAudience() {
        Vec3 destination = getTarget() == null ? null : position().add(position().subtract(getTarget().position()).normalize().scale(9));
        endEncounter(false); setTarget(null); getNavigation().stop(); setIntent(Intent.RETREAT, 200);
        if (destination != null) getNavigation().moveTo(destination.x, destination.y, destination.z, .8);
    }
    @Override public boolean removeWhenFarAway(double distance) { return false; }
    @Override protected boolean shouldDespawnInPeaceful() { return false; }
    @Override public void addAdditionalSaveData(CompoundTag tag) {
        super.addAdditionalSaveData(tag); tag.putInt("MutationStage", getMutationStage());
        hostId().ifPresent(id -> tag.putUUID("Host", id)); tag.putString("HostName", getHostName());
        tag.putString("Intent", intent.name()); tag.putLong("IntentUntil", intentUntil);
        tag.putLong("NextEncounterAt", nextEncounterAt); tag.putLong("EncounterStart", entityData.get(ENCOUNTER_START));
        tag.putString("BodyPosture", getBodyPosture().name()); tag.putLong("BodyPostureUntil", bodyPostureUntil);
        tag.putFloat("CompactProgress", entityData.get(COMPACT_PROGRESS));
        tag.putString("HostSkinSource", getHostSkinSource());
        if (encounterAudience != null) tag.putUUID("EncounterAudience", encounterAudience);
    }
    @Override public void readAdditionalSaveData(CompoundTag tag) {
        super.readAdditionalSaveData(tag); setMutationStage(Math.max(0, Math.min(4, tag.getInt("MutationStage"))));
        if (tag.hasUUID("Host")) setHost(tag.getUUID("Host"), tag.getString("HostName"));
        try { intent = Intent.valueOf(tag.getString("Intent")); } catch (IllegalArgumentException ignored) { intent = Intent.STALK; }
        intentUntil = tag.getLong("IntentUntil");
        nextEncounterAt = Math.max(0, tag.getLong("NextEncounterAt"));
        entityData.set(ENCOUNTER_START, tag.contains("EncounterStart", net.minecraft.nbt.Tag.TAG_LONG) ? tag.getLong("EncounterStart") : -1L);
        encounterAudience = tag.hasUUID("EncounterAudience") ? tag.getUUID("EncounterAudience") : null;
        try { setBodyPosture(BodyPosture.valueOf(tag.getString("BodyPosture"))); }
        catch (IllegalArgumentException ignored) { setBodyPosture(BodyPosture.AUTO); }
        bodyPostureUntil = Math.max(0, Math.min(tag.getLong("BodyPostureUntil"), level().getGameTime() + MAX_POSTURE_TICKS));
        float progress = tag.getFloat("CompactProgress");
        entityData.set(COMPACT_PROGRESS, Float.isFinite(progress) ? Mth.clamp(progress, 0, 1) : 1F);
        try { setHostSkinSource(tag.getString("HostSkinSource")); } catch (IllegalArgumentException ignored) { setHostSkinSource(""); }
    }
}