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 ); } }