diff --git a/src/main/java/com/tiedup/remake/state/PlayerBindState.java b/src/main/java/com/tiedup/remake/state/PlayerBindState.java index a571d9a..2cbd62a 100644 --- a/src/main/java/com/tiedup/remake/state/PlayerBindState.java +++ b/src/main/java/com/tiedup/remake/state/PlayerBindState.java @@ -11,6 +11,7 @@ import com.tiedup.remake.state.components.PlayerClothesPermission; import com.tiedup.remake.state.components.PlayerDataRetrieval; import com.tiedup.remake.state.components.PlayerEquipment; import com.tiedup.remake.state.components.PlayerLifecycle; +import com.tiedup.remake.state.components.PlayerMovement; import com.tiedup.remake.state.components.PlayerSale; import com.tiedup.remake.state.components.PlayerShockCollar; import com.tiedup.remake.state.components.PlayerSpecialActions; @@ -49,8 +50,10 @@ import org.jetbrains.annotations.Nullable; * - Enslavement lifecycle (can be enslaved, act as master) * - Advanced collar features (shocks, GPS tracking) * - * Thread Safety: This class is accessed from both server and client threads. - * Use appropriate synchronization when accessing the instances map. + * Thread Safety: All mutating methods must be called from the server thread + * (via enqueueWork for packet handlers). The only cross-thread access is + * the StruggleSnapshot (atomic volatile record) and the Player reference (volatile). + * The instances map uses ConcurrentHashMap for thread-safe lookup. * * Refactoring: Component-Host pattern */ @@ -138,89 +141,27 @@ public class PlayerBindState implements IRestrainable, IPlayerBindStateHost { private PlayerCaptorManager captorManager; // Note: transport field removed - now using IPlayerLeashAccess mixin - // Struggle animation state - // volatile: accessed from multiple threads (network, tick, render) - private volatile boolean isStruggling = false; - private volatile long struggleStartTick = 0; + // Struggle animation state — atomic snapshot for cross-thread visibility. + // Server thread writes via setStruggling(), render thread reads via isStruggling()/getStruggleStartTick(). + // Single volatile reference guarantees both fields are visible atomically. + private record StruggleSnapshot(boolean struggling, long startTick) {} + private volatile StruggleSnapshot struggleState = new StruggleSnapshot(false, 0); - // ========== Movement Style State (Phase: Movement Styles) ========== - // Managed exclusively by MovementStyleManager. Stored here to piggyback - // on existing lifecycle cleanup hooks (death, logout, dimension change). + // ========== Movement Style State ========== + // Encapsulated in PlayerMovement component. Managed exclusively by MovementStyleManager. + private final PlayerMovement movement; - /** Currently active movement style, or null if none. */ - @Nullable - private com.tiedup.remake.v2.bondage.movement.MovementStyle activeMovementStyle; - - /** Resolved speed multiplier for the active style (0.0-1.0). */ - private float resolvedMovementSpeed = 1.0f; - - /** Whether jumping is currently disabled by the active style. */ - private boolean resolvedJumpDisabled = false; - - @Nullable - public com.tiedup.remake.v2.bondage.movement.MovementStyle getActiveMovementStyle() { - return activeMovementStyle; + /** Access movement style state (hop, crawl, position tracking, etc.). */ + public PlayerMovement getMovement() { + return movement; } - public void setActiveMovementStyle( - @Nullable com.tiedup.remake.v2.bondage.movement.MovementStyle style - ) { - this.activeMovementStyle = style; - } - - public float getResolvedMovementSpeed() { - return resolvedMovementSpeed; - } - - public void setResolvedMovementSpeed(float speed) { - this.resolvedMovementSpeed = speed; - } - - public boolean isResolvedJumpDisabled() { - return resolvedJumpDisabled; - } - - public void setResolvedJumpDisabled(boolean disabled) { - this.resolvedJumpDisabled = disabled; - } - - /** Ticks until next hop is allowed (HOP style). */ - public int hopCooldown = 0; - - /** True during the 4-tick startup delay before the first hop. */ - public boolean hopStartupPending = false; - - /** Countdown ticks for the hop startup delay. */ - public int hopStartupTicks = 0; - - /** True if crawl deactivated but player can't stand yet (space blocked). */ - public boolean pendingPoseRestore = false; - - /** Last known X position for movement detection (position delta). */ - public double lastX; - - /** Last known Y position for movement detection (position delta). */ - public double lastY; - - /** Last known Z position for movement detection (position delta). */ - public double lastZ; - - /** Consecutive non-moving ticks counter for hop startup reset. */ - public int hopNotMovingTicks = 0; - /** * Resets all movement style state to defaults. * Called on death, logout, and dimension change to ensure clean re-activation. */ public void clearMovementState() { - this.activeMovementStyle = null; - this.resolvedMovementSpeed = 1.0f; - this.resolvedJumpDisabled = false; - this.hopCooldown = 0; - this.hopStartupPending = false; - this.hopStartupTicks = 0; - this.pendingPoseRestore = false; - this.hopNotMovingTicks = 0; + movement.clear(); } // ========== Constructor ========== @@ -249,6 +190,9 @@ public class PlayerBindState implements IRestrainable, IPlayerBindStateHost { this.lifecycle = new PlayerLifecycle(this); this.captivity = new PlayerCaptivity(this); + // Initialize movement component + this.movement = new PlayerMovement(this); + this.captor = null; this.captorManager = new PlayerCaptorManager(player); } @@ -578,47 +522,42 @@ public class PlayerBindState implements IRestrainable, IPlayerBindStateHost { /** * Check if player is currently playing struggle animation. - * Thread-safe (volatile field). - * IPlayerBindStateHost implementation. + * Thread-safe: reads from atomic StruggleSnapshot. */ @Override public boolean isStruggling() { - return isStruggling; + return struggleState.struggling(); } /** * Get the tick when struggle animation started. - * Thread-safe (volatile field). - * IPlayerBindStateHost implementation. + * Thread-safe: reads from atomic StruggleSnapshot. */ @Override public long getStruggleStartTick() { - return struggleStartTick; + return struggleState.startTick(); } /** * Set struggle animation state (server-side). - * IPlayerBindStateHost implementation. - * @param struggling True to start struggle animation, false to stop - * @param currentTick Current game time tick (for timer) + * Single volatile write ensures both fields are visible atomically to render thread. */ @Override public void setStruggling(boolean struggling, long currentTick) { - this.isStruggling = struggling; - if (struggling) { - this.struggleStartTick = currentTick; - } + this.struggleState = new StruggleSnapshot( + struggling, + struggling ? currentTick : struggleState.startTick() + ); } /** * Set struggle animation flag (client-side only). - * IPlayerBindStateHost implementation. * Used by network sync - does NOT update timer (server manages timer). - * @param struggling True if struggling, false otherwise */ @Override public void setStrugglingClient(boolean struggling) { - this.isStruggling = struggling; + StruggleSnapshot current = this.struggleState; + this.struggleState = new StruggleSnapshot(struggling, current.startTick()); } /** @@ -635,31 +574,34 @@ public class PlayerBindState implements IRestrainable, IPlayerBindStateHost { * Delegated to PlayerEquipment component */ @Override - public synchronized int getCurrentBindResistance() { + public int getCurrentBindResistance() { return equipment.getCurrentBindResistance(); } /** - * Delegated to PlayerEquipment component + * Delegated to PlayerEquipment component. + * Thread safety: must be called from the server thread. */ @Override - public synchronized void setCurrentBindResistance(int resistance) { + public void setCurrentBindResistance(int resistance) { equipment.setCurrentBindResistance(resistance); } /** - * Delegated to PlayerEquipment component + * Delegated to PlayerEquipment component. + * Thread safety: must be called from the server thread. */ @Override - public synchronized int getCurrentCollarResistance() { + public int getCurrentCollarResistance() { return equipment.getCurrentCollarResistance(); } /** - * Delegated to PlayerEquipment component + * Delegated to PlayerEquipment component. + * Thread safety: must be called from the server thread. */ @Override - public synchronized void setCurrentCollarResistance(int resistance) { + public void setCurrentCollarResistance(int resistance) { equipment.setCurrentCollarResistance(resistance); } @@ -1006,12 +948,12 @@ public class PlayerBindState implements IRestrainable, IPlayerBindStateHost { // Delegated to PlayerEquipment component (except TORSO - handled by PlayerClothesPermission) @Override - public synchronized ItemStack replaceEquipment( + public ItemStack replaceEquipment( BodyRegionV2 region, ItemStack newStack, boolean force ) { - // MEDIUM FIX: Synchronized to prevent race condition during equipment replacement + // Thread safety: must be called from the server thread. return switch (region) { case ARMS -> equipment.replaceBind(newStack, force); case MOUTH -> equipment.replaceGag(newStack, force); diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerMovement.java b/src/main/java/com/tiedup/remake/state/components/PlayerMovement.java new file mode 100644 index 0000000..af46f03 --- /dev/null +++ b/src/main/java/com/tiedup/remake/state/components/PlayerMovement.java @@ -0,0 +1,154 @@ +package com.tiedup.remake.state.components; + +import com.tiedup.remake.state.hosts.IPlayerBindStateHost; +import com.tiedup.remake.v2.bondage.movement.MovementStyle; +import org.jetbrains.annotations.Nullable; + +/** + * Component responsible for movement style state management. + * Stores all per-player movement fields used exclusively by MovementStyleManager. + * + *
Thread safety: All fields are server-tick-only (PlayerTickEvent + LivingJumpEvent, + * both on the server thread). No synchronization needed.
+ */ +public class PlayerMovement { + + private final IPlayerBindStateHost host; + + // --- Resolved style state --- + @Nullable + private MovementStyle activeMovementStyle; + private float resolvedMovementSpeed = 1.0f; + private boolean resolvedJumpDisabled = false; + + // --- Hop state --- + private int hopCooldown = 0; + private boolean hopStartupPending = false; + private int hopStartupTicks = 0; + private int hopNotMovingTicks = 0; + + // --- Crawl state --- + private boolean pendingPoseRestore = false; + + // --- Position tracking for movement detection --- + private double lastX; + private double lastY; + private double lastZ; + + public PlayerMovement(IPlayerBindStateHost host) { + this.host = host; + } + + // --- Resolved style --- + + @Nullable + public MovementStyle getActiveMovementStyle() { + return activeMovementStyle; + } + + public void setActiveMovementStyle(@Nullable MovementStyle style) { + this.activeMovementStyle = style; + } + + public float getResolvedMovementSpeed() { + return resolvedMovementSpeed; + } + + public void setResolvedMovementSpeed(float speed) { + this.resolvedMovementSpeed = speed; + } + + public boolean isResolvedJumpDisabled() { + return resolvedJumpDisabled; + } + + public void setResolvedJumpDisabled(boolean disabled) { + this.resolvedJumpDisabled = disabled; + } + + // --- Hop --- + + public int getHopCooldown() { + return hopCooldown; + } + + public void setHopCooldown(int hopCooldown) { + this.hopCooldown = hopCooldown; + } + + public boolean isHopStartupPending() { + return hopStartupPending; + } + + public void setHopStartupPending(boolean hopStartupPending) { + this.hopStartupPending = hopStartupPending; + } + + public int getHopStartupTicks() { + return hopStartupTicks; + } + + public void setHopStartupTicks(int hopStartupTicks) { + this.hopStartupTicks = hopStartupTicks; + } + + public int getHopNotMovingTicks() { + return hopNotMovingTicks; + } + + public void setHopNotMovingTicks(int hopNotMovingTicks) { + this.hopNotMovingTicks = hopNotMovingTicks; + } + + // --- Crawl --- + + public boolean isPendingPoseRestore() { + return pendingPoseRestore; + } + + public void setPendingPoseRestore(boolean pendingPoseRestore) { + this.pendingPoseRestore = pendingPoseRestore; + } + + // --- Position tracking --- + + public double getLastX() { + return lastX; + } + + public void setLastX(double lastX) { + this.lastX = lastX; + } + + public double getLastY() { + return lastY; + } + + public void setLastY(double lastY) { + this.lastY = lastY; + } + + public double getLastZ() { + return lastZ; + } + + public void setLastZ(double lastZ) { + this.lastZ = lastZ; + } + + /** + * Resets all movement style state to defaults. + * Called on death, logout, and dimension change to ensure clean re-activation. + * Note: lastX/Y/Z are NOT reset — they track position, not style state. + */ + public void clear() { + this.activeMovementStyle = null; + this.resolvedMovementSpeed = 1.0f; + this.resolvedJumpDisabled = false; + this.hopCooldown = 0; + this.hopStartupPending = false; + this.hopStartupTicks = 0; + this.pendingPoseRestore = false; + this.hopNotMovingTicks = 0; + } +} diff --git a/src/main/java/com/tiedup/remake/state/components/PlayerStruggle.java b/src/main/java/com/tiedup/remake/state/components/PlayerStruggle.java index 94aac69..b5b44d2 100644 --- a/src/main/java/com/tiedup/remake/state/components/PlayerStruggle.java +++ b/src/main/java/com/tiedup/remake/state/components/PlayerStruggle.java @@ -42,10 +42,9 @@ public class PlayerStruggle { * Entry point for the Struggle logic (Key R). * Distributes effort between Binds and Collar. * - * Thread Safety: Synchronized to prevent lost updates when multiple struggle - * packets arrive simultaneously (e.g., from macro/rapid keypresses). + * Thread Safety: Must be called from the server thread (packet handlers use enqueueWork). */ - public synchronized void struggle() { + public void struggle() { if (struggleBindState != null) struggleBindState.struggle(state); if (struggleCollarState != null) struggleCollarState.struggle(state); } @@ -53,9 +52,9 @@ public class PlayerStruggle { /** * Restores resistance to base values when a master tightens the ties. * - * Thread Safety: Synchronized to prevent race with struggle operations. + * Thread Safety: Must be called from the server thread. */ - public synchronized void tighten(Player tightener) { + public void tighten(Player tightener) { if (struggleBindState != null) struggleBindState.tighten( tightener, state diff --git a/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleManager.java b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleManager.java index 4a106d9..4a178e7 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleManager.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/movement/MovementStyleManager.java @@ -129,14 +129,14 @@ public class MovementStyleManager { player.isDeadOrDying() || state.isStruggling() ) { - state.lastX = player.getX(); - state.lastY = player.getY(); - state.lastZ = player.getZ(); + state.getMovement().setLastX(player.getX()); + state.getMovement().setLastY(player.getY()); + state.getMovement().setLastZ(player.getZ()); return; } // --- Pending pose restore (crawl deactivated but can't stand) --- - if (state.pendingPoseRestore) { + if (state.getMovement().isPendingPoseRestore()) { tryRestoreStandingPose(player, state); } @@ -153,7 +153,7 @@ public class MovementStyleManager { // --- Compare with current active style --- MovementStyle newStyle = resolved.style(); - MovementStyle oldStyle = state.getActiveMovementStyle(); + MovementStyle oldStyle = state.getMovement().getActiveMovementStyle(); if (newStyle != oldStyle) { // Style changed: deactivate old, activate new @@ -161,14 +161,14 @@ public class MovementStyleManager { onDeactivate(player, state, oldStyle); } if (newStyle != null) { - state.setResolvedMovementSpeed(resolved.speedMultiplier()); - state.setResolvedJumpDisabled(resolved.jumpDisabled()); + state.getMovement().setResolvedMovementSpeed(resolved.speedMultiplier()); + state.getMovement().setResolvedJumpDisabled(resolved.jumpDisabled()); onActivate(player, state, newStyle); } else { - state.setResolvedMovementSpeed(1.0f); - state.setResolvedJumpDisabled(false); + state.getMovement().setResolvedMovementSpeed(1.0f); + state.getMovement().setResolvedJumpDisabled(false); } - state.setActiveMovementStyle(newStyle); + state.getMovement().setActiveMovementStyle(newStyle); // Sync to all tracking clients (animation + crawl pose) ModNetwork.sendToAllTrackingAndSelf( @@ -178,13 +178,13 @@ public class MovementStyleManager { } // --- Per-style tick --- - if (state.getActiveMovementStyle() != null) { + if (state.getMovement().getActiveMovementStyle() != null) { // Ladder suspension: skip style tick when on ladder // (ladder movement is controlled by BondageItemRestrictionHandler) if (player.onClimbable()) { - state.lastX = player.getX(); - state.lastY = player.getY(); - state.lastZ = player.getZ(); + state.getMovement().setLastX(player.getX()); + state.getMovement().setLastY(player.getY()); + state.getMovement().setLastZ(player.getZ()); return; } @@ -192,9 +192,9 @@ public class MovementStyleManager { } // Update last position for next tick's movement detection - state.lastX = player.getX(); - state.lastY = player.getY(); - state.lastZ = player.getZ(); + state.getMovement().setLastX(player.getX()); + state.getMovement().setLastY(player.getY()); + state.getMovement().setLastZ(player.getZ()); } // ==================== Jump Suppression ==================== @@ -216,7 +216,7 @@ public class MovementStyleManager { } PlayerBindState state = PlayerBindState.getInstance(player); - if (state == null || !state.isResolvedJumpDisabled()) { + if (state == null || !state.getMovement().isResolvedJumpDisabled()) { return; } @@ -274,7 +274,7 @@ public class MovementStyleManager { } private static void tickStyle(ServerPlayer player, PlayerBindState state) { - switch (state.getActiveMovementStyle()) { + switch (state.getMovement().getActiveMovementStyle()) { case WADDLE -> tickWaddle(player, state); case SHUFFLE -> tickShuffle(player, state); case HOP -> tickHop(player, state); @@ -292,7 +292,7 @@ public class MovementStyleManager { player, WADDLE_SPEED_UUID, "tiedup.waddle_speed", - state.getResolvedMovementSpeed() + state.getMovement().getResolvedMovementSpeed() ); } @@ -318,7 +318,7 @@ public class MovementStyleManager { player, SHUFFLE_SPEED_UUID, "tiedup.shuffle_speed", - state.getResolvedMovementSpeed() + state.getMovement().getResolvedMovementSpeed() ); } @@ -347,11 +347,11 @@ public class MovementStyleManager { player, HOP_SPEED_UUID, "tiedup.hop_speed", - state.getResolvedMovementSpeed() + state.getMovement().getResolvedMovementSpeed() ); - state.hopCooldown = 0; - state.hopStartupPending = true; - state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS; + state.getMovement().setHopCooldown(0); + state.getMovement().setHopStartupPending(true); + state.getMovement().setHopStartupTicks(HOP_STARTUP_DELAY_TICKS); } private static void deactivateHop( @@ -359,10 +359,10 @@ public class MovementStyleManager { PlayerBindState state ) { removeSpeedModifier(player, HOP_SPEED_UUID); - state.hopCooldown = 0; - state.hopStartupPending = false; - state.hopStartupTicks = 0; - state.hopNotMovingTicks = 0; + state.getMovement().setHopCooldown(0); + state.getMovement().setHopStartupPending(false); + state.getMovement().setHopStartupTicks(0); + state.getMovement().setHopNotMovingTicks(0); } /** @@ -375,43 +375,44 @@ public class MovementStyleManager { * */ private static void tickHop(ServerPlayer player, PlayerBindState state) { + var mov = state.getMovement(); boolean isMoving = - player.distanceToSqr(state.lastX, state.lastY, state.lastZ) > + player.distanceToSqr(mov.getLastX(), mov.getLastY(), mov.getLastZ()) > MOVEMENT_THRESHOLD_SQ; // Decrement cooldown - if (state.hopCooldown > 0) { - state.hopCooldown--; + if (mov.getHopCooldown() > 0) { + mov.setHopCooldown(mov.getHopCooldown() - 1); } - if (isMoving && player.onGround() && state.hopCooldown <= 0) { - if (state.hopStartupPending) { + if (isMoving && player.onGround() && mov.getHopCooldown() <= 0) { + if (mov.isHopStartupPending()) { // Startup delay: decrement and wait (latched: completes even if // player briefly releases input during these 4 ticks) - state.hopStartupTicks--; - if (state.hopStartupTicks <= 0) { + mov.setHopStartupTicks(mov.getHopStartupTicks() - 1); + if (mov.getHopStartupTicks() <= 0) { // Startup complete: execute first hop - state.hopStartupPending = false; + mov.setHopStartupPending(false); executeHop(player, state); } } else { // Normal hop executeHop(player, state); } - state.hopNotMovingTicks = 0; + mov.setHopNotMovingTicks(0); } else if (!isMoving) { - state.hopNotMovingTicks++; + mov.setHopNotMovingTicks(mov.getHopNotMovingTicks() + 1); // Reset startup if not moving for >= 2 consecutive ticks if ( - state.hopNotMovingTicks >= HOP_STARTUP_RESET_TICKS && - !state.hopStartupPending + mov.getHopNotMovingTicks() >= HOP_STARTUP_RESET_TICKS && + !mov.isHopStartupPending() ) { - state.hopStartupPending = true; - state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS; + mov.setHopStartupPending(true); + mov.setHopStartupTicks(HOP_STARTUP_DELAY_TICKS); } } else { // Moving but not on ground or cooldown active — reset not-moving counter - state.hopNotMovingTicks = 0; + mov.setHopNotMovingTicks(0); } } @@ -431,7 +432,7 @@ public class MovementStyleManager { currentMotion.z + forward.z * HOP_FORWARD_IMPULSE ); - state.hopCooldown = HOP_COOLDOWN_TICKS; + state.getMovement().setHopCooldown(HOP_COOLDOWN_TICKS); // Sync velocity to client to prevent rubber-banding player.connection.send(new ClientboundSetEntityMotionPacket(player)); @@ -447,11 +448,11 @@ public class MovementStyleManager { player, CRAWL_SPEED_UUID, "tiedup.crawl_speed", - state.getResolvedMovementSpeed() + state.getMovement().getResolvedMovementSpeed() ); player.setForcedPose(Pose.SWIMMING); player.refreshDimensions(); - state.pendingPoseRestore = false; + state.getMovement().setPendingPoseRestore(false); } private static void deactivateCrawl( @@ -470,7 +471,7 @@ public class MovementStyleManager { player.refreshDimensions(); } else { // Can't stand yet -- flag for periodic retry in tick flow (step 2) - state.pendingPoseRestore = true; + state.getMovement().setPendingPoseRestore(true); } } @@ -501,7 +502,7 @@ public class MovementStyleManager { if (canStand) { player.setForcedPose(null); player.refreshDimensions(); - state.pendingPoseRestore = false; + state.getMovement().setPendingPoseRestore(false); LOGGER.debug( "Restored standing pose for {} (pending pose restore cleared)", player.getName().getString()