Merge pull request 'chore/audit-s02-s05-state-cleanup' (#13) from chore/audit-s02-s05-state-cleanup into develop

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-04-15 14:48:42 +00:00
4 changed files with 247 additions and 155 deletions

View File

@@ -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.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);

View File

@@ -0,0 +1,150 @@
package com.tiedup.remake.state.components;
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.
*
* <p>Thread safety: All fields are server-tick-only (PlayerTickEvent + LivingJumpEvent,
* both on the server thread). No synchronization needed.</p>
*/
public class PlayerMovement {
// --- 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() {
}
// --- 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;
}
}

View File

@@ -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

View File

@@ -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 {
* </ul>
*/
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()