Clean repo for open source release

Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
This commit is contained in:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,548 @@
package com.tiedup.remake.minigame;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Random;
import java.util.UUID;
import org.jetbrains.annotations.Nullable;
/**
* Server-side state for the continuous Struggle mini-game session.
*
* New system: Player holds a direction key to continuously reduce resistance.
* - Progression: 1 resistance per second when holding correct direction
* - Direction changes randomly every 3-5 seconds
* - Shock collar check: 10% chance every 5 seconds (if locked collar equipped)
* - No cooldown, no penalty for wrong key (just no progress)
*/
public class ContinuousStruggleMiniGameState {
/**
* Possible directions for struggling.
*/
public enum Direction {
UP(0), // W / Forward
LEFT(1), // A / Strafe Left
DOWN(2), // S / Back
RIGHT(3); // D / Strafe Right
private final int index;
Direction(int index) {
this.index = index;
}
public int getIndex() {
return index;
}
public static Direction fromIndex(int index) {
return switch (index) {
case 0 -> UP;
case 1 -> LEFT;
case 2 -> DOWN;
case 3 -> RIGHT;
default -> UP;
};
}
public static Direction random(Random random) {
return fromIndex(random.nextInt(4));
}
}
/**
* State of the continuous struggle session.
*/
public enum State {
/** Actively struggling */
ACTIVE,
/** Temporarily interrupted by shock collar */
SHOCKED,
/** Successfully escaped (resistance reached 0) */
ESCAPED,
/** Cancelled by player or damage */
CANCELLED,
}
/**
* Type of state update sent to client.
*/
public enum UpdateType {
START,
DIRECTION_CHANGE,
RESISTANCE_UPDATE,
SHOCK,
ESCAPE,
END,
}
// ==================== CONSTANTS ====================
/** Minimum interval for direction change (3 seconds = 60 ticks) */
private static final int DIRECTION_CHANGE_MIN_TICKS = 60;
/** Maximum interval for direction change (5 seconds = 100 ticks) */
private static final int DIRECTION_CHANGE_MAX_TICKS = 100;
/** Interval for shock collar check (5 seconds = 100 ticks) */
private static final int SHOCK_CHECK_INTERVAL_TICKS = 100;
/** Shock probability (10% = 10 out of 100) */
private static final int SHOCK_PROBABILITY = 10;
/** Default ticks per 1 resistance point (20 ticks = 1 second per resistance) */
private static final int DEFAULT_TICKS_PER_RESISTANCE = 20;
/** Shock stun duration in ticks (1 second) */
private static final int SHOCK_STUN_TICKS = 20;
/** Kidnapper notification interval (2 seconds = 40 ticks) */
private static final int KIDNAPPER_NOTIFY_INTERVAL_TICKS = 40;
/** Struggle sound interval (1.5 seconds = 30 ticks) */
private static final int STRUGGLE_SOUND_INTERVAL_TICKS = 30;
/** Session timeout in milliseconds (10 minutes) */
private static final long SESSION_TIMEOUT_MS = 10L * 60L * 1000L;
// ==================== FIELDS ====================
private final UUID sessionId;
private final UUID playerId;
private final long createdAt;
/** Current required direction (0-3 for W/A/S/D) */
private Direction currentDirection;
/** Current resistance remaining */
private int currentResistance;
/** Maximum resistance (initial value) for display percentage */
private final int maxResistance;
/** Accumulated progress toward next resistance point (0.0 to 1.0) */
private float accumulatedProgress;
/** Tick when direction last changed */
private long lastDirectionChangeTick;
/** Tick when shock was last checked */
private long lastShockCheckTick;
/** Tick when kidnappers were last notified */
private long lastKidnapperNotifyTick;
/** Tick when struggle sound was last played */
private long lastStruggleSoundTick;
/** Ticks until direction change */
private int ticksUntilDirectionChange;
/** Current session state */
private State state;
/** Ticks remaining in shock stun */
private int shockStunTicksRemaining;
/** Whether player is currently holding the correct key */
private boolean isHoldingCorrectKey;
/** Direction player is currently holding (-1 if none) */
private int heldDirection;
/** Whether the bind is locked (affects display only, locks add +250 resistance) */
private boolean isLocked;
/** Target accessory slot (null = bind, otherwise = accessory) */
private Integer targetSlot;
/** Target V2 body region (null = V1 session, non-null = V2 session) */
@Nullable
private BodyRegionV2 targetRegion;
/** Target furniture entity ID (non-zero = furniture struggle session) */
private int furnitureEntityId;
/** Target seat ID for furniture struggles (null = not a furniture session) */
@Nullable
private String furnitureSeatId;
/** Resistance reduction per tick (computed from ticksPerResistance) */
private final float resistancePerTick;
private final Random random = new Random();
// ==================== CONSTRUCTOR ====================
public ContinuousStruggleMiniGameState(
UUID playerId,
int targetResistance,
boolean isLocked
) {
this(
playerId,
targetResistance,
isLocked,
null,
DEFAULT_TICKS_PER_RESISTANCE
);
}
public ContinuousStruggleMiniGameState(
UUID playerId,
int targetResistance,
boolean isLocked,
Integer targetSlot
) {
this(
playerId,
targetResistance,
isLocked,
targetSlot,
DEFAULT_TICKS_PER_RESISTANCE
);
}
/**
* Constructor with target slot and configurable rate.
*
* @param playerId Player UUID
* @param targetResistance Current resistance
* @param isLocked Whether the target is locked
* @param targetSlot Target accessory slot (null = bind)
* @param ticksPerResistance Ticks per 1 resistance point (from game rule)
*/
public ContinuousStruggleMiniGameState(
UUID playerId,
int targetResistance,
boolean isLocked,
Integer targetSlot,
int ticksPerResistance
) {
this.sessionId = UUID.randomUUID();
this.playerId = playerId;
this.createdAt = System.currentTimeMillis();
this.resistancePerTick = 1.0f / Math.max(1, ticksPerResistance);
this.currentResistance = targetResistance;
this.maxResistance = targetResistance;
this.isLocked = isLocked;
this.targetSlot = targetSlot;
// Initialize direction
this.currentDirection = Direction.random(random);
this.accumulatedProgress = 0.0f;
// Initialize timers (will be set properly on first tick)
this.lastDirectionChangeTick = 0;
this.lastShockCheckTick = 0;
this.lastKidnapperNotifyTick = 0;
this.ticksUntilDirectionChange = randomDirectionChangeInterval();
// Initial state
this.state = State.ACTIVE;
this.shockStunTicksRemaining = 0;
this.isHoldingCorrectKey = false;
this.heldDirection = -1;
}
// ==================== GETTERS ====================
public UUID getSessionId() {
return sessionId;
}
public UUID getPlayerId() {
return playerId;
}
public Direction getCurrentDirection() {
return currentDirection;
}
public int getCurrentDirectionIndex() {
return currentDirection.getIndex();
}
public int getCurrentResistance() {
return currentResistance;
}
public int getMaxResistance() {
return maxResistance;
}
public float getProgressPercentage() {
if (maxResistance == 0) return 0.0f;
return 1.0f - ((float) currentResistance / (float) maxResistance);
}
public State getState() {
return state;
}
public boolean isLocked() {
return isLocked;
}
public Integer getTargetSlot() {
return targetSlot;
}
public boolean isAccessoryStruggle() {
return targetSlot != null;
}
/** Get the V2 target region (null for V1 sessions). */
@Nullable
public BodyRegionV2 getTargetRegion() {
return targetRegion;
}
/** Set the V2 target region. */
public void setTargetRegion(@Nullable BodyRegionV2 region) {
this.targetRegion = region;
}
/** Whether this is a V2 region-based struggle session. */
public boolean isV2Struggle() {
return targetRegion != null;
}
/** Get the furniture entity ID (0 = not a furniture session). */
public int getFurnitureEntityId() {
return furnitureEntityId;
}
/** Get the furniture seat ID (null = not a furniture session). */
@Nullable
public String getFurnitureSeatId() {
return furnitureSeatId;
}
/** Set furniture struggle context. */
public void setFurnitureContext(int entityId, String seatId) {
this.furnitureEntityId = entityId;
this.furnitureSeatId = seatId;
}
/** Whether this is a furniture escape struggle session. */
public boolean isFurnitureStruggle() {
return furnitureEntityId != 0 && furnitureSeatId != null;
}
public boolean isActive() {
return state == State.ACTIVE;
}
public boolean isShocked() {
return state == State.SHOCKED;
}
public boolean isComplete() {
return state == State.ESCAPED || state == State.CANCELLED;
}
public boolean isExpired() {
return System.currentTimeMillis() - createdAt > SESSION_TIMEOUT_MS;
}
public boolean isHoldingCorrectKey() {
return isHoldingCorrectKey;
}
public int getHeldDirection() {
return heldDirection;
}
// ==================== STATE UPDATES ====================
/**
* Update the held direction from client input.
*
* @param direction Direction index (-1 if not holding any key)
* @param isHolding True if player is actively holding the key
*/
public void updateHeldDirection(int direction, boolean isHolding) {
this.heldDirection = direction;
this.isHoldingCorrectKey =
isHolding && (direction == currentDirection.getIndex());
}
/**
* Process a server tick.
*
* @param currentTick Current game tick
* @return True if state changed significantly (needs client update)
*/
public TickResult tick(long currentTick) {
if (isComplete()) {
return TickResult.NO_CHANGE;
}
// Handle shock stun
if (state == State.SHOCKED) {
shockStunTicksRemaining--;
if (shockStunTicksRemaining <= 0) {
state = State.ACTIVE;
return TickResult.SHOCK_END;
}
return TickResult.NO_CHANGE;
}
boolean needsUpdate = false;
TickResult result = TickResult.NO_CHANGE;
// Direction change check
ticksUntilDirectionChange--;
if (ticksUntilDirectionChange <= 0) {
changeDirection(currentTick);
result = TickResult.DIRECTION_CHANGE;
}
// Progress resistance if holding correct key
if (isHoldingCorrectKey) {
accumulatedProgress += resistancePerTick;
if (accumulatedProgress >= 1.0f) {
int resistanceReduced = (int) accumulatedProgress;
accumulatedProgress -= resistanceReduced;
currentResistance = Math.max(
0,
currentResistance - resistanceReduced
);
if (result == TickResult.NO_CHANGE) {
result = TickResult.RESISTANCE_UPDATE;
}
// Check for escape
if (currentResistance <= 0) {
state = State.ESCAPED;
return TickResult.ESCAPED;
}
}
}
return result;
}
/**
* Result of a tick update.
*/
public enum TickResult {
NO_CHANGE,
DIRECTION_CHANGE,
RESISTANCE_UPDATE,
SHOCK_START,
SHOCK_END,
ESCAPED,
KIDNAPPER_NOTIFY,
}
/**
* Check if shock collar should trigger.
*
* @param currentTick Current game tick
* @return True if shock should be triggered
*/
public boolean shouldTriggerShock(long currentTick) {
if (state != State.ACTIVE) return false;
if (
currentTick - lastShockCheckTick < SHOCK_CHECK_INTERVAL_TICKS
) return false;
lastShockCheckTick = currentTick;
return random.nextInt(100) < SHOCK_PROBABILITY;
}
/**
* Trigger a shock event.
*/
public void triggerShock() {
if (state != State.ACTIVE) return;
state = State.SHOCKED;
shockStunTicksRemaining = SHOCK_STUN_TICKS;
isHoldingCorrectKey = false;
}
/**
* Check if kidnappers should be notified.
*
* @param currentTick Current game tick
* @return True if kidnappers should be notified
*/
public boolean shouldNotifyKidnappers(long currentTick) {
if (state != State.ACTIVE || !isHoldingCorrectKey) return false;
if (
currentTick - lastKidnapperNotifyTick <
KIDNAPPER_NOTIFY_INTERVAL_TICKS
) return false;
lastKidnapperNotifyTick = currentTick;
return true;
}
/**
* Check if struggle sound should play.
*
* @param currentTick Current game tick
* @return True if struggle sound should be played
*/
public boolean shouldPlayStruggleSound(long currentTick) {
if (state != State.ACTIVE || !isHoldingCorrectKey) return false;
if (
currentTick - lastStruggleSoundTick < STRUGGLE_SOUND_INTERVAL_TICKS
) return false;
lastStruggleSoundTick = currentTick;
return true;
}
/**
* Cancel the session.
*/
public void cancel() {
state = State.CANCELLED;
}
// ==================== PRIVATE HELPERS ====================
private void changeDirection(long currentTick) {
Direction newDirection;
do {
newDirection = Direction.random(random);
} while (newDirection == currentDirection);
currentDirection = newDirection;
lastDirectionChangeTick = currentTick;
ticksUntilDirectionChange = randomDirectionChangeInterval();
// LOW FIX: Always reset held key status to prevent lucky guess exploit
// Player must release and re-press the key to react to direction change
// even if they were already holding the new correct key
isHoldingCorrectKey = false;
}
private int randomDirectionChangeInterval() {
return (
DIRECTION_CHANGE_MIN_TICKS +
random.nextInt(
DIRECTION_CHANGE_MAX_TICKS - DIRECTION_CHANGE_MIN_TICKS + 1
)
);
}
@Override
public String toString() {
return String.format(
"ContinuousStruggleMiniGameState{session=%s, player=%s, dir=%s, resistance=%d/%d, state=%s}",
sessionId.toString().substring(0, 8),
playerId.toString().substring(0, 8),
currentDirection,
currentResistance,
maxResistance,
state
);
}
}

View File

@@ -0,0 +1,98 @@
package com.tiedup.remake.minigame;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.entities.EntityMaid;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
/**
* Helper for notifying nearby guards (Kidnappers, Maids, Traders) about
* escape-related noise (struggle, lockpick, knife cutting).
*
* <p>Extracted from {@link MiniGameSessionManager} (M15 split) so that
* any system can trigger guard alerts without depending on a session manager.
*/
public final class GuardNotificationHelper {
/**
* Radius in blocks for struggle noise to alert nearby NPCs.
* 32 blocks is the standard hearing range for mobs in Minecraft.
* NPCs within this range will be notified of struggle attempts.
*/
private static final double STRUGGLE_NOISE_RADIUS = 32.0;
private GuardNotificationHelper() {}
/**
* Notify nearby guards about escape noise (struggle, lockpick, knife cutting).
* Public entry point for external systems (e.g. GenericKnife).
*
* @param player The player who is making noise
*/
public static void notifyNearbyGuards(ServerPlayer player) {
notifyNearbyKidnappersOfStruggle(player);
}
/**
* Notify nearby guards (Kidnappers, Maids, Traders) when a player starts struggling.
* This creates "noise" that guards can detect.
*
* Guards will only react if they have LINE OF SIGHT to the struggling prisoner.
* If they see the prisoner, they will:
* 1. Shock the prisoner (punishment)
* 2. Approach to tighten their binds (reset resistance)
*
* @param player The player who started struggling
*/
public static void notifyNearbyKidnappersOfStruggle(ServerPlayer player) {
if (player == null) return;
ServerLevel level = player.serverLevel();
if (level == null) return;
int notifiedCount = 0;
// MEDIUM FIX: Performance optimization - use single entity search instead of 3
// Old code did 3 separate searches for EntityKidnapper, EntityMaid, EntitySlaveTrader
// with the SAME bounding box, causing 3x iterations over entities in the area.
// New code does 1 search for LivingEntity and filters by instanceof.
java.util.List<net.minecraft.world.entity.LivingEntity> nearbyEntities =
level.getEntitiesOfClass(
net.minecraft.world.entity.LivingEntity.class,
player.getBoundingBox().inflate(STRUGGLE_NOISE_RADIUS),
e -> e.isAlive()
);
for (net.minecraft.world.entity.LivingEntity entity : nearbyEntities) {
if (
entity instanceof EntityKidnapper kidnapper &&
!kidnapper.isTiedUp()
) {
kidnapper.onStruggleDetected(player);
notifiedCount++;
} else if (
entity instanceof EntityMaid maid &&
!maid.isTiedUp() &&
!maid.isFreed()
) {
maid.onStruggleDetected(player);
notifiedCount++;
} else if (
entity instanceof
com.tiedup.remake.entities.EntitySlaveTrader trader
) {
trader.onStruggleDetected(player);
notifiedCount++;
}
}
if (notifiedCount > 0) {
TiedUpMod.LOGGER.debug(
"[GuardNotificationHelper] Notified {} guards about struggle from {}",
notifiedCount,
player.getName().getString()
);
}
}
}

View File

@@ -0,0 +1,283 @@
package com.tiedup.remake.minigame;
import java.util.Random;
import java.util.UUID;
/**
* Phase 2: Server-side state for a Lockpick mini-game session.
*
* Tracks:
* - Session UUID (anti-cheat)
* - Sweet spot position and width
* - Current lockpick position
* - Remaining lockpick uses
*/
public class LockpickMiniGameState {
private final UUID sessionId;
private final UUID playerId;
private final long createdAt;
private final int targetSlot;
/**
* Session timeout in milliseconds (2 minutes)
*/
private static final long SESSION_TIMEOUT_MS = 2L * 60L * 1000L;
/**
* Position of the sweet spot center (0.0 to 1.0)
* BUG FIX: Removed final to allow regeneration after failed attempts.
*/
private float sweetSpotCenter;
/**
* Width of the sweet spot (0.0 to 1.0, e.g. 0.15 = 15%)
*/
private final float sweetSpotWidth;
/**
* Current lockpick position (0.0 to 1.0)
*/
private float currentPosition;
/**
* Number of remaining lockpick uses
*/
private int remainingUses;
/**
* Whether the session is complete
*/
private boolean complete;
/**
* Whether the lock was successfully picked
*/
private boolean success;
private final Random random = new Random();
public LockpickMiniGameState(
UUID playerId,
int targetSlot,
float sweetSpotWidth
) {
this.sessionId = UUID.randomUUID();
this.playerId = playerId;
this.targetSlot = targetSlot;
this.createdAt = System.currentTimeMillis();
// Generate random sweet spot position
// Keep it away from edges (at least width/2 from 0 and 1)
float minCenter = sweetSpotWidth / 2;
float maxCenter = 1.0f - sweetSpotWidth / 2;
this.sweetSpotCenter =
minCenter + random.nextFloat() * (maxCenter - minCenter);
this.sweetSpotWidth = sweetSpotWidth;
// Start position at random location
this.currentPosition = random.nextFloat();
this.remainingUses = 5; // Default, will be updated by caller
this.complete = false;
this.success = false;
}
// ==================== GETTERS ====================
public UUID getSessionId() {
return sessionId;
}
public UUID getPlayerId() {
return playerId;
}
public int getTargetSlot() {
return targetSlot;
}
public float getSweetSpotCenter() {
return sweetSpotCenter;
}
public float getSweetSpotWidth() {
return sweetSpotWidth;
}
public float getCurrentPosition() {
return currentPosition;
}
public int getRemainingUses() {
return remainingUses;
}
public boolean isComplete() {
return complete;
}
public boolean isSuccess() {
return success;
}
public boolean isExpired() {
return System.currentTimeMillis() - createdAt > SESSION_TIMEOUT_MS;
}
// ==================== SETTERS ====================
public void setRemainingUses(int uses) {
this.remainingUses = uses;
}
public void setCurrentPosition(float position) {
this.currentPosition = Math.max(0.0f, Math.min(1.0f, position));
}
// ==================== GAME LOGIC ====================
/**
* Move the lockpick position.
*
* @param delta Amount to move (-1.0 to 1.0, scaled by sensitivity)
*/
public void move(float delta) {
currentPosition = Math.max(
0.0f,
Math.min(1.0f, currentPosition + delta)
);
}
/**
* Check if current position is within the sweet spot.
*/
public boolean isInSweetSpot() {
float minPos = sweetSpotCenter - sweetSpotWidth / 2;
float maxPos = sweetSpotCenter + sweetSpotWidth / 2;
return currentPosition >= minPos && currentPosition <= maxPos;
}
/**
* Attempt to pick the lock at current position.
*
* @return Result of the attempt
*/
public PickAttemptResult attemptPick() {
if (complete) {
return PickAttemptResult.SESSION_COMPLETE;
}
if (isInSweetSpot()) {
// Success!
complete = true;
success = true;
return PickAttemptResult.SUCCESS;
}
// Failed - use up a lockpick
remainingUses--;
if (remainingUses <= 0) {
// Out of lockpicks
complete = true;
return PickAttemptResult.OUT_OF_PICKS;
}
// Generate new sweet spot position for next attempt
regenerateSweetSpot();
return PickAttemptResult.MISSED;
}
/**
* Regenerate sweet spot position (called after failed attempt).
* BUG FIX: Actually regenerate the sweet spot to prevent players from memorizing position.
* Problem: Players could memorize position and lockpicking became trivial.
*/
private void regenerateSweetSpot() {
// Generate new random sweet spot position
// Keep it away from edges (at least width/2 from 0 and 1)
float minCenter = sweetSpotWidth / 2;
float maxCenter = 1.0f - sweetSpotWidth / 2;
this.sweetSpotCenter =
minCenter + random.nextFloat() * (maxCenter - minCenter);
}
/**
* Get distance from current position to sweet spot center.
* Used for feedback (closer = warmer).
*
* @return Distance as 0.0 (exact) to 1.0 (max distance)
*/
public float getDistanceToSweetSpot() {
return Math.abs(currentPosition - sweetSpotCenter);
}
/**
* Get warmth feedback (how close to sweet spot).
*
* @return Warmth level: COLD, WARM, HOT, IN_SPOT
*/
public WarmthLevel getWarmthLevel() {
if (isInSweetSpot()) {
return WarmthLevel.IN_SPOT;
}
float distance = getDistanceToSweetSpot();
if (distance < sweetSpotWidth) {
return WarmthLevel.HOT;
} else if (distance < sweetSpotWidth * 2) {
return WarmthLevel.WARM;
}
return WarmthLevel.COLD;
}
/**
* Result of a pick attempt.
*/
public enum PickAttemptResult {
/**
* Successfully picked the lock!
*/
SUCCESS,
/**
* Missed the sweet spot, lockpick used
*/
MISSED,
/**
* Ran out of lockpicks
*/
OUT_OF_PICKS,
/**
* Session already complete
*/
SESSION_COMPLETE,
}
/**
* Warmth feedback level.
*/
public enum WarmthLevel {
COLD,
WARM,
HOT,
IN_SPOT,
}
@Override
public String toString() {
return String.format(
"LockpickMiniGameState{session=%s, player=%s, slot=%d, pos=%.2f, sweet=%.2f(w=%.2f), uses=%d}",
sessionId.toString().substring(0, 8),
playerId.toString().substring(0, 8),
targetSlot,
currentPosition,
sweetSpotCenter,
sweetSpotWidth,
remainingUses
);
}
}

View File

@@ -0,0 +1,186 @@
package com.tiedup.remake.minigame;
import com.tiedup.remake.core.TiedUpMod;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import org.jetbrains.annotations.Nullable;
/**
* Manages lockpick mini-game sessions.
*
* <p>Extracted from {@link MiniGameSessionManager} (M15 split) to give lockpick
* sessions their own focused manager. Singleton, thread-safe via ConcurrentHashMap.
*/
public class LockpickSessionManager {
private static final LockpickSessionManager INSTANCE =
new LockpickSessionManager();
/**
* Active lockpick mini-game sessions by player UUID
*/
private final Map<UUID, LockpickMiniGameState> lockpickSessions =
new ConcurrentHashMap<>();
private LockpickSessionManager() {}
public static LockpickSessionManager getInstance() {
return INSTANCE;
}
/**
* Start a new lockpick session for a player.
* If player already has an active session, it will be replaced (handles ESC cancel case).
*
* @param player The server player
* @param targetSlot The bondage slot being picked
* @param sweetSpotWidth The width of the sweet spot (based on tool)
* @return The new session
*/
public LockpickMiniGameState startLockpickSession(
ServerPlayer player,
int targetSlot,
float sweetSpotWidth
) {
UUID playerId = player.getUUID();
// Check for existing session - remove it (handles ESC cancel case)
LockpickMiniGameState existing = lockpickSessions.get(playerId);
if (existing != null) {
TiedUpMod.LOGGER.debug(
"[LockpickSessionManager] Replacing existing lockpick session for {}",
player.getName().getString()
);
lockpickSessions.remove(playerId);
}
// Create new session
LockpickMiniGameState session = new LockpickMiniGameState(
playerId,
targetSlot,
sweetSpotWidth
);
lockpickSessions.put(playerId, session);
TiedUpMod.LOGGER.info(
"[LockpickSessionManager] Started lockpick session {} for {} (slot: {}, width: {}%)",
session.getSessionId().toString().substring(0, 8),
player.getName().getString(),
targetSlot,
(int) (sweetSpotWidth * 100)
);
// Notify nearby guards about lockpicking attempt
GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player);
return session;
}
/**
* Play lockpick attempt sound and notify guards.
* Called when player attempts to pick (tests position).
*
* @param player The player attempting to pick
*/
public void onLockpickAttempt(ServerPlayer player) {
// Play metallic clicking sound
player
.serverLevel()
.playSound(
null,
player.getX(),
player.getY(),
player.getZ(),
SoundEvents.CHAIN_HIT,
SoundSource.PLAYERS,
0.6f,
1.2f + player.getRandom().nextFloat() * 0.3f
);
// Notify guards
GuardNotificationHelper.notifyNearbyKidnappersOfStruggle(player);
}
/**
* Get active lockpick session for a player.
*
* @param playerId The player UUID
* @return The session, or null if none active
*/
@Nullable
public LockpickMiniGameState getLockpickSession(UUID playerId) {
LockpickMiniGameState session = lockpickSessions.get(playerId);
if (session != null && session.isExpired()) {
lockpickSessions.remove(playerId);
return null;
}
return session;
}
/**
* Validate a lockpick session.
*
* @param playerId The player UUID
* @param sessionId The session UUID to validate
* @return true if session is valid and active
*/
public boolean validateLockpickSession(UUID playerId, UUID sessionId) {
LockpickMiniGameState session = getLockpickSession(playerId);
if (session == null) {
return false;
}
return session.getSessionId().equals(sessionId);
}
/**
* End a lockpick session.
*
* @param playerId The player UUID
* @param success Whether the session ended in success
*/
public void endLockpickSession(UUID playerId, boolean success) {
LockpickMiniGameState session = lockpickSessions.remove(playerId);
if (session != null) {
TiedUpMod.LOGGER.info(
"[LockpickSessionManager] Ended lockpick session for player {} (success: {})",
playerId.toString().substring(0, 8),
success
);
}
}
/**
* Clean up all lockpick sessions for a player (called on disconnect).
*
* @param playerId The player UUID
*/
public void cleanupPlayer(UUID playerId) {
lockpickSessions.remove(playerId);
}
/**
* Periodic cleanup of expired lockpick 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;
}
lockpickSessions
.entrySet()
.removeIf(entry -> entry.getValue().isExpired());
}
/**
* Get count of active lockpick sessions (for debugging).
*/
public int getActiveSessionCount() {
return lockpickSessions.size();
}
}

View File

@@ -0,0 +1,47 @@
package com.tiedup.remake.minigame;
import com.tiedup.remake.core.TiedUpMod;
import java.util.UUID;
/**
* Lightweight facade over {@link LockpickSessionManager} and
* {@link StruggleSessionManager}.
*
* <p>After the M15 split, this class only delegates cross-cutting concerns
* (cleanup on disconnect, aggregate session count). Callers that need a
* specific session type should use the sub-manager directly.
*/
public class MiniGameSessionManager {
private static final MiniGameSessionManager INSTANCE =
new MiniGameSessionManager();
private MiniGameSessionManager() {}
public static MiniGameSessionManager getInstance() {
return INSTANCE;
}
/**
* Clean up all sessions for a player (called on disconnect).
* Delegates to both sub-managers.
*
* @param playerId The player UUID
*/
public void cleanupPlayer(UUID playerId) {
LockpickSessionManager.getInstance().cleanupPlayer(playerId);
StruggleSessionManager.getInstance().cleanupPlayer(playerId);
TiedUpMod.LOGGER.debug(
"[MiniGameSessionManager] Cleaned up all sessions for player {}",
playerId.toString().substring(0, 8)
);
}
/**
* Get count of active sessions across both sub-managers (for debugging).
*/
public int getActiveSessionCount() {
return LockpickSessionManager.getInstance().getActiveSessionCount()
+ StruggleSessionManager.getInstance().getActiveSessionCount();
}
}

View File

@@ -0,0 +1,842 @@
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.
*
* <p>Extracted from {@link MiniGameSessionManager} (M15 split). Handles all
* struggle variants: bind struggle, accessory struggle, V2 region struggle,
* and furniture escape struggle.
*
* <p>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.
*
* <p>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.</p>
*
* @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<Map.Entry<UUID, ContinuousStruggleMiniGameState>> 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();
}
}