package com.tiedup.remake.minigame; import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.items.ItemShockCollar; import com.tiedup.remake.items.base.ItemBind; import com.tiedup.remake.items.base.ItemCollar; import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState.TickResult; import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState.UpdateType; import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.minigame.PacketContinuousStruggleState; import com.tiedup.remake.network.sync.SyncManager; import com.tiedup.remake.state.PlayerBindState; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundSource; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.Nullable; /** * Manages continuous struggle mini-game sessions. * *

Extracted from {@link MiniGameSessionManager} (M15 split). Handles all * struggle variants: bind struggle, accessory struggle, V2 region struggle, * and furniture escape struggle. * *

Singleton, thread-safe via ConcurrentHashMap. */ public class StruggleSessionManager { private static final StruggleSessionManager INSTANCE = new StruggleSessionManager(); /** * Active continuous struggle mini-game sessions by player UUID */ private final Map< UUID, ContinuousStruggleMiniGameState > continuousSessions = new ConcurrentHashMap<>(); /** * Mapping from legacy V1 slot indices to V2 BodyRegionV2. * Used to convert V1 session targetSlot ordinals to V2 regions. * Index: 0=ARMS, 1=MOUTH, 2=EYES, 3=EARS, 4=NECK, 5=TORSO, 6=HANDS. */ private static final BodyRegionV2[] SLOT_TO_REGION = { BodyRegionV2.ARMS, // 0 = BIND BodyRegionV2.MOUTH, // 1 = GAG BodyRegionV2.EYES, // 2 = BLINDFOLD BodyRegionV2.EARS, // 3 = EARPLUGS BodyRegionV2.NECK, // 4 = COLLAR BodyRegionV2.TORSO, // 5 = CLOTHES BodyRegionV2.HANDS, // 6 = MITTENS }; private StruggleSessionManager() {} public static StruggleSessionManager getInstance() { return INSTANCE; } // ==================== SESSION START VARIANTS ==================== /** * Start a new continuous struggle session for a player. * * @param player The server player * @param targetResistance Current bind resistance * @param isLocked Whether the bind is locked * @return The new session */ public ContinuousStruggleMiniGameState startContinuousStruggleSession( ServerPlayer player, int targetResistance, boolean isLocked ) { UUID playerId = player.getUUID(); // Remove any existing continuous session ContinuousStruggleMiniGameState existing = continuousSessions.get( playerId ); if (existing != null) { TiedUpMod.LOGGER.debug( "[StruggleSessionManager] Replacing existing continuous struggle session for {}", player.getName().getString() ); continuousSessions.remove(playerId); } // Create new session with configurable rate int ticksPerResistance = com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( player.level().getGameRules() ); ContinuousStruggleMiniGameState session = new ContinuousStruggleMiniGameState( playerId, targetResistance, isLocked, null, ticksPerResistance ); continuousSessions.put(playerId, session); TiedUpMod.LOGGER.info( "[StruggleSessionManager] Started continuous struggle session {} for {} (resistance: {}, locked: {})", session.getSessionId().toString().substring(0, 8), player.getName().getString(), targetResistance, isLocked ); // Set struggle animation state and sync to client PlayerBindState state = PlayerBindState.getInstance(player); if (state != null) { state.setStruggling(true, player.level().getGameTime()); SyncManager.syncStruggleState(player); } // Notify nearby kidnappers GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); return session; } /** * Start a new continuous struggle session for an accessory. * * @param player The server player * @param targetSlot Target accessory slot ordinal * @param lockResistance Current lock resistance * @return The new session */ public ContinuousStruggleMiniGameState startContinuousAccessoryStruggleSession( ServerPlayer player, int targetSlot, int lockResistance ) { UUID playerId = player.getUUID(); // Remove any existing session ContinuousStruggleMiniGameState existing = continuousSessions.get( playerId ); if (existing != null) { continuousSessions.remove(playerId); } // Create new session with target slot and configurable rate int ticksPerResistance = com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( player.level().getGameRules() ); ContinuousStruggleMiniGameState session = new ContinuousStruggleMiniGameState( playerId, lockResistance, true, targetSlot, ticksPerResistance ); continuousSessions.put(playerId, session); TiedUpMod.LOGGER.info( "[StruggleSessionManager] Started continuous accessory struggle session {} for {} (slot: {}, resistance: {})", session.getSessionId().toString().substring(0, 8), player.getName().getString(), targetSlot, lockResistance ); // Set struggle animation state and sync to client PlayerBindState state = PlayerBindState.getInstance(player); if (state != null) { state.setStruggling(true, player.level().getGameTime()); SyncManager.syncStruggleState(player); } // Notify nearby kidnappers GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); return session; } /** * Start a new continuous struggle session for a V2 bondage item. * * @param player The server player * @param targetRegion V2 body region to struggle against * @param targetResistance Current resistance value * @param isLocked Whether the item is locked * @return The new session, or null if creation failed */ public ContinuousStruggleMiniGameState startV2StruggleSession( ServerPlayer player, com.tiedup.remake.v2.BodyRegionV2 targetRegion, int targetResistance, boolean isLocked ) { UUID playerId = player.getUUID(); // RISK-001 fix: reject if an active session already exists (prevents direction re-roll exploit) ContinuousStruggleMiniGameState existing = continuousSessions.get( playerId ); if (existing != null) { TiedUpMod.LOGGER.debug( "[StruggleSessionManager] Rejected V2 session: active session already exists for {}", player.getName().getString() ); return null; } int ticksPerResistance = com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( player.level().getGameRules() ); ContinuousStruggleMiniGameState session = new ContinuousStruggleMiniGameState( playerId, targetResistance, isLocked, null, ticksPerResistance ); session.setTargetRegion(targetRegion); continuousSessions.put(playerId, session); TiedUpMod.LOGGER.info( "[StruggleSessionManager] Started V2 struggle session {} for {} (region: {}, resistance: {}, locked: {})", session.getSessionId().toString().substring(0, 8), player.getName().getString(), targetRegion.name(), targetResistance, isLocked ); // Set struggle animation state and sync to client PlayerBindState state = PlayerBindState.getInstance(player); if (state != null) { state.setStruggling(true, player.level().getGameTime()); SyncManager.syncStruggleState(player); } GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); return session; } /** * Start a new continuous struggle session for a furniture seat escape. * *

The session behaves identically to a V2 struggle (direction-hold to * reduce resistance) but on completion it unlocks the seat and dismounts * the player instead of removing a bondage item.

* * @param player The server player (must be seated and locked) * @param furnitureEntityId Entity ID of the furniture * @param seatId The locked seat ID * @param totalDifficulty Combined resistance (seat base + item bonus) * @return The new session, or null if creation failed */ public ContinuousStruggleMiniGameState startFurnitureStruggleSession( ServerPlayer player, int furnitureEntityId, String seatId, int totalDifficulty ) { UUID playerId = player.getUUID(); // Reject if an active session already exists ContinuousStruggleMiniGameState existing = continuousSessions.get(playerId); if (existing != null) { TiedUpMod.LOGGER.debug( "[StruggleSessionManager] Rejected furniture session: active session already exists for {}", player.getName().getString() ); return null; } int ticksPerResistance = com.tiedup.remake.core.SettingsAccessor.getStruggleContinuousRate( player.level().getGameRules() ); ContinuousStruggleMiniGameState session = new ContinuousStruggleMiniGameState( playerId, totalDifficulty, true, // furniture seats are always "locked" in context null, ticksPerResistance ); session.setFurnitureContext(furnitureEntityId, seatId); continuousSessions.put(playerId, session); TiedUpMod.LOGGER.info( "[StruggleSessionManager] Started furniture struggle session {} for {} (entity: {}, seat: '{}', difficulty: {})", session.getSessionId().toString().substring(0, 8), player.getName().getString(), furnitureEntityId, seatId, totalDifficulty ); // Set struggle animation state and sync to client PlayerBindState state = PlayerBindState.getInstance(player); if (state != null) { state.setStruggling(true, player.level().getGameTime()); SyncManager.syncStruggleState(player); } // Play struggle loop sound from furniture definition (plays once on start; // true looping sound would require client-side sound management -- future scope) net.minecraft.world.entity.Entity furnitureEntity = player.level().getEntity(furnitureEntityId); if (furnitureEntity instanceof com.tiedup.remake.v2.furniture.EntityFurniture furniture) { com.tiedup.remake.v2.furniture.FurnitureDefinition def = furniture.getDefinition(); if (def != null && def.feedback().struggleLoopSound() != null) { player.level().playSound(null, player.getX(), player.getY(), player.getZ(), net.minecraft.sounds.SoundEvent.createVariableRangeEvent(def.feedback().struggleLoopSound()), SoundSource.PLAYERS, 0.6f, 1.0f); } } GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); return session; } // ==================== SESSION QUERY ==================== /** * Get active continuous struggle session for a player. * * @param playerId The player UUID * @return The session, or null if none active */ @Nullable public ContinuousStruggleMiniGameState getContinuousStruggleSession( UUID playerId ) { ContinuousStruggleMiniGameState session = continuousSessions.get( playerId ); if (session != null && session.isExpired()) { continuousSessions.remove(playerId); return null; } return session; } /** * Validate a continuous struggle session. * * @param playerId The player UUID * @param sessionId The session UUID to validate * @return true if session is valid and active */ public boolean validateContinuousStruggleSession( UUID playerId, UUID sessionId ) { ContinuousStruggleMiniGameState session = getContinuousStruggleSession( playerId ); if (session == null) { return false; } return session.getSessionId().equals(sessionId); } /** * End a continuous struggle session. * * @param playerId The player UUID * @param success Whether the session ended in success (escape) */ public void endContinuousStruggleSession(UUID playerId, boolean success) { ContinuousStruggleMiniGameState session = continuousSessions.remove( playerId ); if (session != null) { TiedUpMod.LOGGER.info( "[StruggleSessionManager] Ended continuous struggle session for player {} (success: {})", playerId.toString().substring(0, 8), success ); // Clear struggle animation state // We need to find the player to clear the animation // This will be handled by the caller if they have access to the player } } // ==================== TICK ==================== /** * Tick all continuous struggle sessions. * Should be called every server tick. * * @param server The server instance for player lookups * @param currentTick Current game tick */ public void tickContinuousSessions( net.minecraft.server.MinecraftServer server, long currentTick ) { if (continuousSessions.isEmpty()) return; // Collect sessions to process (avoid ConcurrentModificationException) List> toProcess = new ArrayList<>(continuousSessions.entrySet()); for (Map.Entry< UUID, ContinuousStruggleMiniGameState > entry : toProcess) { UUID playerId = entry.getKey(); ContinuousStruggleMiniGameState session = entry.getValue(); // Get player ServerPlayer player = server.getPlayerList().getPlayer(playerId); if (player == null) { // Player disconnected, clean up continuousSessions.remove(playerId); continue; } // Check for expired/completed sessions if (session.isExpired() || session.isComplete()) { continuousSessions.remove(playerId); clearStruggleAnimation(player); continue; } // Tick the session TickResult result = session.tick(currentTick); // Handle tick result switch (result) { case DIRECTION_CHANGE -> { sendContinuousStruggleUpdate( player, session, UpdateType.DIRECTION_CHANGE ); } case RESISTANCE_UPDATE -> { // Update actual bind resistance updateBindResistance(player, session); // Send update to client immediately for real-time feedback sendContinuousStruggleUpdate( player, session, UpdateType.RESISTANCE_UPDATE ); } case ESCAPED -> { handleStruggleEscape(player, session); } case NO_CHANGE -> { // No action needed } default -> { } } // Check shock collar (separate from tick result) if ( shouldCheckShockCollar(player) && session.shouldTriggerShock(currentTick) ) { handleShockCollar(player, session); } // Check kidnapper notification if (session.shouldNotifyKidnappers(currentTick)) { GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player); } // Play struggle sound periodically if (session.shouldPlayStruggleSound(currentTick)) { playStruggleSound(player); } } } // ==================== PRIVATE HELPERS ==================== /** * Play struggle sound for player and nearby entities. */ private void playStruggleSound(ServerPlayer player) { // Play leather creaking sound - audible to player and nearby player .serverLevel() .playSound( null, // null = all players can hear player.getX(), player.getY(), player.getZ(), SoundEvents.ARMOR_EQUIP_LEATHER, SoundSource.PLAYERS, 0.8f, // volume 0.9f + player.getRandom().nextFloat() * 0.2f // slight pitch variation ); } /** * Check if shock collar check is applicable for this player. */ private boolean shouldCheckShockCollar(ServerPlayer player) { ItemStack collar = V2EquipmentHelper.getInRegion(player, BodyRegionV2.NECK); if (collar.isEmpty()) return false; // Only shock collars can trigger during struggle if (!(collar.getItem() instanceof ItemShockCollar)) return false; // Must be locked if (collar.getItem() instanceof ItemCollar collarItem) { return collarItem.isLocked(collar); } return false; } /** * Handle shock collar trigger. */ private void handleShockCollar( ServerPlayer player, ContinuousStruggleMiniGameState session ) { PlayerBindState state = PlayerBindState.getInstance(player); if (state != null) { state.shockKidnapped(" (Your struggle was interrupted!)", 2.0f); } session.triggerShock(); sendContinuousStruggleUpdate(player, session, UpdateType.SHOCK); TiedUpMod.LOGGER.debug( "[StruggleSessionManager] Shock collar triggered for {} during struggle", player.getName().getString() ); } /** * Update the actual bind resistance based on session state. */ private void updateBindResistance( ServerPlayer player, ContinuousStruggleMiniGameState session ) { // V2 region-based resistance update if (session.isV2Struggle()) { com.tiedup.remake.v2.BodyRegionV2 region = session.getTargetRegion(); ItemStack stack = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper .getInRegion(player, region); if (stack.isEmpty()) return; if ( stack.getItem() instanceof com.tiedup.remake.items.base.IHasResistance resistanceItem ) { resistanceItem.setCurrentResistance( stack, session.getCurrentResistance() ); com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync( player ); } return; } if (session.isAccessoryStruggle()) { // Handle accessory resistance update (V1 path -- slot index is legacy ordinal) Integer slotIndex = session.getTargetSlot(); if (slotIndex == null || slotIndex < 0 || slotIndex >= SLOT_TO_REGION.length) return; BodyRegionV2 region = SLOT_TO_REGION[slotIndex]; ItemStack accessoryStack = V2EquipmentHelper.getInRegion(player, region); if (accessoryStack.isEmpty()) return; if ( accessoryStack.getItem() instanceof com.tiedup.remake.items.base.ILockable lockable ) { // Update the lock resistance to match session state lockable.setCurrentLockResistance( accessoryStack, session.getCurrentResistance() ); // Sync V2 equipment state V2EquipmentHelper.sync(player); } return; } // Update bind resistance ItemStack bindStack = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); if ( bindStack.isEmpty() || !(bindStack.getItem() instanceof ItemBind bind) ) { return; } bind.setCurrentResistance(bindStack, session.getCurrentResistance()); } /** * Handle successful escape from struggle. */ private void handleStruggleEscape( ServerPlayer player, ContinuousStruggleMiniGameState session ) { TiedUpMod.LOGGER.info( "[StruggleSessionManager] Player {} escaped from struggle!", player.getName().getString() ); // Send escape update to client sendContinuousStruggleUpdate(player, session, UpdateType.ESCAPE); // Clear animation state clearStruggleAnimation(player); // Furniture escape: unlock seat + dismount if (session.isFurnitureStruggle()) { handleFurnitureEscape(player, session); continuousSessions.remove(player.getUUID()); return; } // V2 region-based escape if (session.isV2Struggle()) { com.tiedup.remake.v2.BodyRegionV2 region = session.getTargetRegion(); // BUG-001 fix: break lock before unequip (consistent with V1 accessory escape) ItemStack stackInRegion = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper .getInRegion(player, region); if (!stackInRegion.isEmpty() && stackInRegion.getItem() instanceof com.tiedup.remake.items.base.ILockable lockable) { lockable.breakLock(stackInRegion); } ItemStack removed = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper .unequipFromRegion(player, region, true); if (!removed.isEmpty()) { if (!player.getInventory().add(removed)) { player.drop(removed, false); } } continuousSessions.remove(player.getUUID()); return; } // Remove the bind if (!session.isAccessoryStruggle()) { PlayerBindState state = PlayerBindState.getInstance(player); if (state != null) { ItemStack bind = state.unequip(BodyRegionV2.ARMS); if (!bind.isEmpty()) { state.kidnappedDropItem(bind); } } } else { // Handle accessory escape (V1 path -- slot index is legacy ordinal) Integer slotIndex = session.getTargetSlot(); if (slotIndex != null && slotIndex >= 0 && slotIndex < SLOT_TO_REGION.length) { BodyRegionV2 region = SLOT_TO_REGION[slotIndex]; ItemStack accessoryStack = V2EquipmentHelper.getInRegion(player, region); if (!accessoryStack.isEmpty()) { // Break the lock on the accessory if ( accessoryStack.getItem() instanceof com.tiedup.remake.items.base.ILockable lockable ) { lockable.breakLock(accessoryStack); } // Remove the accessory from the region and drop it ItemStack removed = V2EquipmentHelper.unequipFromRegion( player, region, true ); if (!removed.isEmpty()) { // Drop the item at player's feet player.drop(removed, true); } } } } // Remove session continuousSessions.remove(player.getUUID()); } /** * Handle successful furniture escape: unlock the seat, dismount the player, * play sounds, and broadcast state to tracking clients. */ private void handleFurnitureEscape( ServerPlayer player, ContinuousStruggleMiniGameState session ) { int furnitureEntityId = session.getFurnitureEntityId(); String seatId = session.getFurnitureSeatId(); net.minecraft.world.entity.Entity entity = player.level().getEntity(furnitureEntityId); if (entity == null || entity.isRemoved()) { TiedUpMod.LOGGER.warn( "[StruggleSessionManager] Furniture entity {} no longer exists for escape", furnitureEntityId ); // Player still needs to be freed even if entity is gone player.stopRiding(); return; } if (!(entity instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider)) { TiedUpMod.LOGGER.warn( "[StruggleSessionManager] Entity {} is not an ISeatProvider", furnitureEntityId ); player.stopRiding(); return; } // Unlock the seat provider.setSeatLocked(seatId, false); // Clear persistent data tag (reconnection system) net.minecraft.nbt.CompoundTag persistentData = player.getPersistentData(); persistentData.remove("tiedup_locked_furniture"); // Dismount the player player.stopRiding(); // Play escape sound: prefer furniture-specific sound, fall back to CHAIN_BREAK net.minecraft.sounds.SoundEvent escapeSound = net.minecraft.sounds.SoundEvents.CHAIN_BREAK; if (entity instanceof com.tiedup.remake.v2.furniture.EntityFurniture furniture) { com.tiedup.remake.v2.furniture.FurnitureDefinition def = furniture.getDefinition(); if (def != null && def.feedback().escapeSound() != null) { escapeSound = net.minecraft.sounds.SoundEvent.createVariableRangeEvent( def.feedback().escapeSound() ); } } player.serverLevel().playSound( null, player.getX(), player.getY(), player.getZ(), escapeSound, net.minecraft.sounds.SoundSource.PLAYERS, 1.0f, 1.0f ); // Broadcast updated furniture state to all tracking clients if (entity instanceof com.tiedup.remake.v2.furniture.EntityFurniture furniture) { com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState.sendToTracking(furniture); } TiedUpMod.LOGGER.info( "[StruggleSessionManager] {} escaped furniture {} seat '{}'", player.getName().getString(), furnitureEntityId, seatId ); } /** * Send a continuous struggle state update to the client. */ private void sendContinuousStruggleUpdate( ServerPlayer player, ContinuousStruggleMiniGameState session, UpdateType updateType ) { ModNetwork.sendToPlayer( new PacketContinuousStruggleState( session.getSessionId(), updateType, session.getCurrentDirectionIndex(), session.getCurrentResistance(), session.getMaxResistance(), session.isLocked() ), player ); } /** * Clear struggle animation state for a player and sync to clients. */ private void clearStruggleAnimation(ServerPlayer player) { PlayerBindState state = PlayerBindState.getInstance(player); if (state != null) { state.setStruggling(false, 0); SyncManager.syncStruggleState(player); } } // ==================== CLEANUP ==================== /** * Clean up all struggle sessions for a player (called on disconnect). * * @param playerId The player UUID */ public void cleanupPlayer(UUID playerId) { continuousSessions.remove(playerId); } /** * Periodic cleanup of expired struggle sessions. * Should be called from server tick handler. */ public void tickCleanup(long currentTick) { // Only run every 100 ticks (5 seconds) if (currentTick % 100 != 0) { return; } continuousSessions .entrySet() .removeIf(entry -> entry.getValue().isExpired()); } /** * Get count of active struggle sessions (for debugging). */ public int getActiveSessionCount() { return continuousSessions.size(); } }