package com.tiedup.remake.events.restriction; import com.tiedup.remake.core.SettingsAccessor; import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.entities.EntityKidnapper; import com.tiedup.remake.minigame.StruggleSessionManager; import com.tiedup.remake.v2.bondage.CollarHelper; import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.personality.PacketSlaveBeingFreed; import com.tiedup.remake.state.IBondageState; import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.tasks.ForceFeedingTask; import com.tiedup.remake.tasks.TimedInteractTask; import com.tiedup.remake.tasks.UntyingPlayerTask; import com.tiedup.remake.tasks.UntyingTask; import com.tiedup.remake.util.GameConstants; import com.tiedup.remake.util.KidnappedHelper; import com.tiedup.remake.v2.BodyRegionV2; import java.util.List; import java.util.UUID; import net.minecraft.network.chat.Component; 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.LivingEntity; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.entity.player.PlayerInteractEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; /** * Tick handler for restraint-related tasks (untying, tying, force feeding). * * Manages progress-based interaction tasks that span multiple ticks: * - Untying mechanic (empty hand right-click on tied entity) * - Tying mechanic (tick progression) * - Force feeding mechanic (food right-click on gagged entity) * - Auto-shock collar checks * - Struggle auto-stop (legacy QTE fallback) * * @see BondageItemRestrictionHandler for movement, interaction, and eating restrictions */ @Mod.EventBusSubscriber( modid = TiedUpMod.MOD_ID, bus = Mod.EventBusSubscriber.Bus.FORGE ) public class RestraintTaskTickHandler { // ========== PLAYER-SPECIFIC TICK ========== /** * Handle player tick event for player-specific features. * - Auto-shock collar check (throttled to every N ticks) */ @SubscribeEvent public static void onPlayerTick(TickEvent.PlayerTickEvent event) { if (event.side.isClient() || event.phase != TickEvent.Phase.END) { return; } Player player = event.player; PlayerBindState playerState = PlayerBindState.getInstance(player); // Check if struggle animation should stop // For continuous struggle: animation is managed by MiniGameSessionManager // Only auto-stop if NO active continuous session (legacy QTE fallback) if (playerState != null && playerState.isStruggling()) { // Don't auto-stop if there's an active continuous struggle session StruggleSessionManager mgr = StruggleSessionManager.getInstance(); if (mgr.getContinuousStruggleSession(player.getUUID()) == null) { // Legacy behavior: stop after 80 ticks (no active continuous session) if ( playerState.shouldStopStruggling( player.level().getGameTime() ) ) { playerState.setStruggling(false, 0); com.tiedup.remake.network.sync.SyncManager.syncStruggleState( player ); } } } // Process untying task tick (progress-based system) // tick() increments/decrements progress based on whether update() was called this tick // sendProgressPackets() updates the UI for both players if (playerState != null) { com.tiedup.remake.tasks.UntyingTask currentUntyingTask = playerState.getCurrentUntyingTask(); if (currentUntyingTask != null && !currentUntyingTask.isStopped()) { // AUTO-UPDATE: Check if player is still targeting the same entity // This allows "hold click" behavior without needing repeated interactLivingEntity calls if ( currentUntyingTask instanceof com.tiedup.remake.tasks.UntyingPlayerTask untyingPlayerTask ) { net.minecraft.world.entity.LivingEntity target = untyingPlayerTask.getTargetEntity(); if (target != null && target.isAlive()) { // Check if player is looking at target and close enough double distance = player.distanceTo(target); boolean isLookingAtTarget = isPlayerLookingAtEntity( player, target, 4.0 ); if ( distance <= 4.0 && isLookingAtTarget && player.hasLineOfSight(target) ) { // Player is still targeting - auto-update the task currentUntyingTask.update(); } } } // Process tick (increment if active, decrement if not) currentUntyingTask.tick(); // Send progress packets to update UI currentUntyingTask.sendProgressPackets(); // Check if task stopped (completed or cancelled due to no progress) if (currentUntyingTask.isStopped()) { playerState.setCurrentUntyingTask(null); TiedUpMod.LOGGER.debug( "[RESTRAINT] {} untying task ended (tick update)", player.getName().getString() ); } } // Process tying task tick (same progress-based system) com.tiedup.remake.tasks.TyingTask currentTyingTask = playerState.getCurrentTyingTask(); if (currentTyingTask != null && !currentTyingTask.isStopped()) { // AUTO-UPDATE: Check if player is still targeting the same entity // This allows "hold click" behavior without needing repeated interactLivingEntity calls if ( currentTyingTask instanceof com.tiedup.remake.tasks.TyingPlayerTask tyingPlayerTask ) { net.minecraft.world.entity.LivingEntity target = tyingPlayerTask.getTargetEntity(); boolean isSelfTying = target != null && target.equals(player); if (isSelfTying) { // Self-tying: skip look-at/distance checks (player can't raycast to own hitbox) // Progress is driven by continuous PacketSelfBondage packets from client currentTyingTask.update(); } else if (target != null && target.isAlive()) { // Tying another player: check distance + line of sight double distance = player.distanceTo(target); boolean isLookingAtTarget = isPlayerLookingAtEntity( player, target, 4.0 ); if ( distance <= 4.0 && isLookingAtTarget && player.hasLineOfSight(target) ) { currentTyingTask.update(); } } } // Process tick (increment if active, decrement if not) currentTyingTask.tick(); // Send progress packets to update UI currentTyingTask.sendProgressPackets(); // Check if task stopped (completed or cancelled due to no progress) if (currentTyingTask.isStopped()) { playerState.setCurrentTyingTask(null); TiedUpMod.LOGGER.debug( "[RESTRAINT] {} tying task ended (tick update)", player.getName().getString() ); } } // Process force feeding task tick TimedInteractTask feedingTask = playerState.getCurrentFeedingTask(); if (feedingTask != null && !feedingTask.isStopped()) { LivingEntity target = feedingTask.getTargetEntity(); if (target != null && target.isAlive()) { double distance = player.distanceTo(target); boolean isLookingAtTarget = isPlayerLookingAtEntity( player, target, 4.0 ); if ( distance <= 4.0 && isLookingAtTarget && player.hasLineOfSight(target) ) { feedingTask.update(); } } feedingTask.tick(); feedingTask.sendProgressPackets(); if (feedingTask.isStopped()) { playerState.setCurrentFeedingTask(null); TiedUpMod.LOGGER.debug( "[RESTRAINT] {} feeding task ended (tick update)", player.getName().getString() ); } } } // Throttle: only check every N ticks (configurable via GameConstants) - per-player timing if (player.tickCount % GameConstants.SHOCK_COLLAR_CHECK_INTERVAL != 0) { return; } if (playerState != null) { playerState.checkAutoShockCollar(); } } // ========== UNTYING MECHANIC ========== /** * Handle untying a tied entity (right-click with empty hand). * * Based on original PlayerKidnapActionsHandler.onUntyingTarget() (1.12.2) * * When a player right-clicks a tied entity (player or NPC) with an empty hand, * starts or continues an untying task to free them. */ @SubscribeEvent public static void onUntyingTarget( PlayerInteractEvent.EntityInteract event ) { // Only run on server side if (event.getLevel().isClientSide) { return; } Entity target = event.getTarget(); Player helper = event.getEntity(); // Must be targeting a LivingEntity, using main hand, and have empty hand if ( !(target instanceof LivingEntity targetEntity) || event.getHand() != InteractionHand.MAIN_HAND || !helper.getMainHandItem().isEmpty() ) { return; } // MCA villagers require Shift+click to untie (prevents conflict with MCA menu) if ( com.tiedup.remake.compat.mca.MCACompat.isMCALoaded() && com.tiedup.remake.compat.mca.MCACompat.isMCAVillager( targetEntity ) && !helper.isShiftKeyDown() ) { return; } // Check if target is tied using IBondageState interface IBondageState targetState = KidnappedHelper.getKidnappedState( targetEntity ); if (targetState == null || !targetState.isTiedUp()) { return; } // SECURITY: Distance and line-of-sight validation double maxUntieDistance = 4.0; // Max distance to untie (blocks) double distance = helper.distanceTo(targetEntity); if (distance > maxUntieDistance) { TiedUpMod.LOGGER.warn( "[RESTRAINT] {} tried to untie {} from too far away ({} blocks)", helper.getName().getString(), targetEntity.getName().getString(), String.format("%.1f", distance) ); return; } // Check line-of-sight (helper must be able to see target) if (!helper.hasLineOfSight(targetEntity)) { TiedUpMod.LOGGER.warn( "[RESTRAINT] {} tried to untie {} without line of sight", helper.getName().getString(), targetEntity.getName().getString() ); return; } // Check for Kidnapper fight back - block untying if Kidnapper is nearby if ( targetEntity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc ) { if ( npc.getCaptor() instanceof EntityKidnapper kidnapper && kidnapper.isAlive() ) { double distanceToKidnapper = helper.distanceTo(kidnapper); double fightBackRange = 16.0; // Kidnapper notices within 16 blocks if (distanceToKidnapper <= fightBackRange) { // Trigger Kidnapper fight back by setting helper as "attacker" // This activates KidnapperFightBackGoal which handles pursuit and attack kidnapper.setLastAttacker(helper); TiedUpMod.LOGGER.debug( "[RESTRAINT] {} tried to untie {}, {} fights back!", helper.getName().getString(), npc.getName().getString(), kidnapper.getName().getString() ); // Block untying - send message to player helper.displayClientMessage( Component.translatable( "tiedup.message.kidnapper_guards_captive" ), true ); return; } } } // Check for Kidnapper fight back - block untying if player is captive or job worker if (targetEntity instanceof Player targetPlayer) { List nearbyKidnappers = helper .level() .getEntitiesOfClass( EntityKidnapper.class, helper.getBoundingBox().inflate(16.0) ); for (EntityKidnapper kidnapper : nearbyKidnappers) { if (!kidnapper.isAlive()) continue; // Check if player is kidnapper's current captive (held by leash) IBondageState captive = kidnapper.getCaptive(); boolean isCaptive = captive != null && captive.asLivingEntity() == targetPlayer; // Check if player is kidnapper's job worker UUID workerUUID = kidnapper.getJobWorkerUUID(); boolean isJobWorker = workerUUID != null && workerUUID.equals(targetPlayer.getUUID()); if (isCaptive || isJobWorker) { // Trigger Kidnapper fight back kidnapper.setLastAttacker(helper); TiedUpMod.LOGGER.debug( "[RESTRAINT] {} tried to untie {} (captive={}, worker={}), {} fights back!", helper.getName().getString(), targetPlayer.getName().getString(), isCaptive, isJobWorker, kidnapper.getNpcName() ); // Block untying - send message to player helper.displayClientMessage( Component.translatable( "tiedup.message.kidnapper_guards_captive" ), true ); return; } } } // Check if helper is tied using IBondageState interface IBondageState helperKidnappedState = KidnappedHelper.getKidnappedState( helper ); if (helperKidnappedState == null || helperKidnappedState.isTiedUp()) { return; } // Get PlayerBindState for task management (helper only) PlayerBindState helperState = PlayerBindState.getInstance(helper); if (helperState == null) { return; } // Block untying while force feeding TimedInteractTask activeFeedTask = helperState.getCurrentFeedingTask(); if (activeFeedTask != null && !activeFeedTask.isStopped()) { return; } // Get untying duration (default: 10 seconds) int untyingSeconds = getUntyingDuration(helper); // Non-owners take 3x longer and trigger alert to owners if ( targetEntity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc ) { if (!npc.isCollarOwner(helper)) { // Non-owner: triple the untying time untyingSeconds *= 3; // Alert all collar owners alertCollarOwners(npc, helper); TiedUpMod.LOGGER.debug( "[RESTRAINT] Non-owner {} trying to free {} ({}s)", helper.getName().getString(), npc.getNpcName(), untyingSeconds ); } } // Get current untying task (if any) UntyingTask currentTask = helperState.getCurrentUntyingTask(); // Check if we should start a new task or continue existing one if ( currentTask == null || !currentTask.isSameTarget(targetEntity) || currentTask.isStopped() ) { // Create new untying task (unified for Players and NPCs) UntyingPlayerTask newTask = new UntyingPlayerTask( targetState, targetEntity, untyingSeconds, helper.level(), helper ); // Start new task helperState.setCurrentUntyingTask(newTask); newTask.setUpTargetState(); // Initialize target's restraint state newTask.start(); currentTask = newTask; TiedUpMod.LOGGER.debug( "[RESTRAINT] {} started untying {} ({} seconds)", helper.getName().getString(), targetEntity.getName().getString(), untyingSeconds ); } else { // Continue existing task - ensure helper is set if (currentTask instanceof UntyingPlayerTask playerTask) { playerTask.setHelper(helper); } } // Mark this tick as active (progress will increase in onPlayerTick) // The tick() method in onPlayerTick handles progress increment/decrement currentTask.update(); } /** * Check if a player is looking at a specific entity (raycast). * * @param player The player * @param target The target entity * @param maxDistance Maximum distance to check * @return true if player is looking at the target entity */ private static boolean isPlayerLookingAtEntity( Player player, net.minecraft.world.entity.LivingEntity target, double maxDistance ) { // Get player's look vector net.minecraft.world.phys.Vec3 eyePos = player.getEyePosition(1.0F); net.minecraft.world.phys.Vec3 lookVec = player.getLookAngle(); net.minecraft.world.phys.Vec3 endPos = eyePos.add( lookVec.x * maxDistance, lookVec.y * maxDistance, lookVec.z * maxDistance ); // Check if raycast hits the target entity net.minecraft.world.phys.AABB targetBounds = target .getBoundingBox() .inflate(0.3); java.util.Optional hit = targetBounds.clip(eyePos, endPos); return hit.isPresent(); } /** * Get the untying duration in seconds from GameRule. * * @param player The player (for accessing world/GameRules) * @return Duration in seconds (default: 10) */ private static int getUntyingDuration(Player player) { return SettingsAccessor.getUntyingPlayerTime( player.level().getGameRules() ); } // ========== FORCE FEEDING MECHANIC ========== /** * Handle force feeding a gagged entity (right-click with food). * * When a player right-clicks a gagged entity (player or NPC) while holding food, * starts or continues a force feeding task. */ @SubscribeEvent public static void onForceFeedingTarget( PlayerInteractEvent.EntityInteract event ) { if (event.getLevel().isClientSide) { return; } Entity target = event.getTarget(); Player feeder = event.getEntity(); if ( !(target instanceof LivingEntity targetEntity) || event.getHand() != InteractionHand.MAIN_HAND ) { return; } ItemStack heldItem = feeder.getMainHandItem(); if (heldItem.isEmpty() || !heldItem.getItem().isEdible()) { return; } // Target must have IBondageState state and be gagged IBondageState targetState = KidnappedHelper.getKidnappedState( targetEntity ); if (targetState == null || !targetState.isGagged()) { return; } // Feeder must not be tied up IBondageState feederState = KidnappedHelper.getKidnappedState(feeder); if (feederState != null && feederState.isTiedUp()) { return; } // Distance and line-of-sight validation double distance = feeder.distanceTo(targetEntity); if (distance > 4.0) { return; } if (!feeder.hasLineOfSight(targetEntity)) { return; } // Get feeder's PlayerBindState for task management PlayerBindState feederBindState = PlayerBindState.getInstance(feeder); if (feederBindState == null) { return; } // Block feeding while untying UntyingTask activeUntieTask = feederBindState.getCurrentUntyingTask(); if (activeUntieTask != null && !activeUntieTask.isStopped()) { return; } // Get current feeding task (if any) TimedInteractTask currentTask = feederBindState.getCurrentFeedingTask(); if ( currentTask == null || !currentTask.isSameTarget(targetEntity) || currentTask.isStopped() ) { // Create new force feeding task (5 seconds) ForceFeedingTask newTask = new ForceFeedingTask( targetState, targetEntity, 5, feeder.level(), feeder, heldItem, feeder.getInventory().selected ); feederBindState.setCurrentFeedingTask(newTask); newTask.setUpTargetState(); newTask.start(); currentTask = newTask; TiedUpMod.LOGGER.debug( "[RESTRAINT] {} started force feeding {} (5 seconds)", feeder.getName().getString(), targetEntity.getName().getString() ); } else { // Continue existing task - ensure feeder is set if (currentTask instanceof ForceFeedingTask feedTask) { feedTask.setFeeder(feeder); } } currentTask.update(); // Cancel to prevent mobInteract (avoids instant NPC feed) event.setCancellationResult(InteractionResult.SUCCESS); event.setCanceled(true); } /** * Alert all collar owners that someone is trying to free their slave. * * @param slave The slave being freed * @param liberator The player trying to free them */ private static void alertCollarOwners( com.tiedup.remake.entities.AbstractTiedUpNpc slave, Player liberator ) { if (!(slave.level() instanceof ServerLevel serverLevel)) return; ItemStack collar = slave.getEquipment(BodyRegionV2.NECK); if (collar.isEmpty() || !CollarHelper.isCollar(collar)) { return; } List owners = CollarHelper.getOwners(collar); if (owners.isEmpty()) return; // Create alert packet PacketSlaveBeingFreed alertPacket = new PacketSlaveBeingFreed( slave.getNpcName(), liberator.getName().getString(), slave.blockPosition().getX(), slave.blockPosition().getY(), slave.blockPosition().getZ() ); // Send to all online owners for (UUID ownerUUID : owners) { ServerPlayer owner = serverLevel .getServer() .getPlayerList() .getPlayer(ownerUUID); if (owner != null && owner != liberator) { ModNetwork.sendToPlayer(alertPacket, owner); } } } }