package com.tiedup.remake.minigame; import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.items.base.IHasResistance; import com.tiedup.remake.v2.bondage.CollarHelper; 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 com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; 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 slot indices to V2 BodyRegionV2. * Used to convert legacy 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(); // RISK-002 fix: reject if active session exists (prevents direction re-roll exploit) ContinuousStruggleMiniGameState existing = continuousSessions.get( playerId ); if (existing != null) { TiedUpMod.LOGGER.debug( "[StruggleSessionManager] Rejected continuous session: active session already exists for {}", player.getName().getString() ); return null; } // 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(); // RISK-002 fix: reject if active session exists (prevents direction re-roll exploit) ContinuousStruggleMiniGameState existing = continuousSessions.get( playerId ); if (existing != null) { TiedUpMod.LOGGER.debug( "[StruggleSessionManager] Rejected accessory session: active session already exists for {}", player.getName().getString() ); return null; } // 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