it = pendingRewards.iterator();
+ while (it.hasNext()) {
+ Bounty bounty = it.next();
+ if (bounty.isClient(player.getUUID())) {
+ giveReward(player, bounty);
+ it.remove();
+ setDirty();
+
+ SystemMessageManager.sendChatToPlayer(
+ player,
+ "Your expired bounty reward has been returned: " +
+ bounty.getRewardDescription(),
+ ChatFormatting.YELLOW
+ );
+ }
+ }
+ }
+
+ // ==================== VALIDATION ====================
+
+ /**
+ * Check if a player can create a new bounty.
+ */
+ public boolean canCreateBounty(ServerPlayer player, ServerLevel level) {
+ if (player.hasPermissions(2)) {
+ return true; // Admins bypass limit
+ }
+
+ int count = 0;
+ for (Bounty bounty : bounties) {
+ if (bounty.isClient(player.getUUID())) {
+ count++;
+ }
+ }
+
+ int max = SettingsAccessor.getMaxBounties(level.getGameRules());
+ return count < max;
+ }
+
+ /**
+ * Get the number of active bounties for a player.
+ */
+ public int getBountyCount(UUID playerId) {
+ int count = 0;
+ for (Bounty bounty : bounties) {
+ if (bounty.isClient(playerId)) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ // ==================== HELPERS ====================
+
+ private void giveReward(ServerPlayer player, Bounty bounty) {
+ ItemStack reward = bounty.getReward();
+ if (!reward.isEmpty()) {
+ if (!player.getInventory().add(reward)) {
+ // Inventory full - drop at feet
+ player.drop(reward, false);
+ }
+ }
+ }
+
+ private void broadcastMessage(MinecraftServer server, String message) {
+ server
+ .getPlayerList()
+ .broadcastSystemMessage(
+ Component.literal("[Bounty] " + message).withStyle(
+ ChatFormatting.GOLD
+ ),
+ false
+ );
+ }
+
+ /**
+ * Check if a pending reward has been waiting too long (>30 days).
+ * Uses the bounty's original expiration time as baseline.
+ */
+ private static boolean isPendingRewardExpired(Bounty bounty) {
+ // Calculate when the bounty originally expired
+ // creationTime is in milliseconds, durationSeconds needs conversion
+ long expirationTime =
+ bounty.getCreationTime() + (bounty.getDurationSeconds() * 1000L);
+ long now = System.currentTimeMillis();
+
+ // Check if it's been more than 30 days since expiration
+ return (now - expirationTime) > PENDING_REWARD_EXPIRATION_MS;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java b/src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java
new file mode 100644
index 0000000..c1bc7e8
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java
@@ -0,0 +1,332 @@
+package com.tiedup.remake.cells;
+
+import com.tiedup.remake.core.TiedUpMod;
+import com.tiedup.remake.items.base.ILockable;
+import com.tiedup.remake.items.base.ItemCollar;
+import com.tiedup.remake.prison.PrisonerManager;
+import com.tiedup.remake.state.IRestrainable;
+import com.tiedup.remake.util.KidnappedHelper;
+import com.tiedup.remake.v2.BodyRegionV2;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import net.minecraft.ChatFormatting;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+
+/**
+ * Handles camp lifecycle events: camp death, prisoner freeing, and camp defense alerts.
+ *
+ * This is a stateless utility class. All state lives in {@link CampOwnership}
+ * (the SavedData singleton). Methods here orchestrate multi-system side effects
+ * that don't belong in the data layer.
+ */
+public final class CampLifecycleManager {
+
+ private CampLifecycleManager() {} // utility class
+
+ /**
+ * Mark a camp as dead (trader killed) and perform full cleanup.
+ * This:
+ * - Cancels all ransoms for the camp's prisoners
+ * - Frees all prisoners (untie, unlock collars)
+ * - Clears all labor states
+ * - Removes all cells belonging to the camp
+ * - Makes the camp inactive
+ *
+ * @param campId The camp UUID
+ * @param level The server level for entity lookups
+ */
+ public static void markCampDead(UUID campId, ServerLevel level) {
+ CampOwnership ownership = CampOwnership.get(level);
+ CampOwnership.CampData data = ownership.getCamp(campId);
+ if (data == null) {
+ return;
+ }
+
+ UUID traderUUID = data.getTraderUUID();
+
+ TiedUpMod.LOGGER.info(
+ "[CampLifecycleManager] Camp {} dying - freeing all prisoners",
+ campId.toString().substring(0, 8)
+ );
+
+ // PERFORMANCE FIX: Use PrisonerManager's index instead of CellRegistry
+ // This is O(1) lookup instead of iterating all cells
+ PrisonerManager manager = PrisonerManager.get(level);
+ Set prisonerIds = manager.getPrisonersInCamp(campId);
+
+ TiedUpMod.LOGGER.debug(
+ "[CampLifecycleManager] Found {} prisoners in camp {} via index",
+ prisonerIds.size(),
+ campId.toString().substring(0, 8)
+ );
+
+ // Cancel ransoms and free each prisoner
+ for (UUID prisonerId : prisonerIds) {
+ // Cancel ransom by clearing it
+ if (manager.getRansomRecord(prisonerId) != null) {
+ manager.setRansomRecord(prisonerId, null);
+ TiedUpMod.LOGGER.debug(
+ "[CampLifecycleManager] Cancelled ransom for prisoner {}",
+ prisonerId.toString().substring(0, 8)
+ );
+ }
+
+ // Free the prisoner
+ ServerPlayer prisoner = level
+ .getServer()
+ .getPlayerList()
+ .getPlayer(prisonerId);
+ if (prisoner != null) {
+ // Online: untie, unlock collar, release with 5-min grace period
+ removeLaborTools(prisoner);
+ freePrisonerOnCampDeath(prisoner, traderUUID, level);
+ // Cell cleanup only -- freePrisonerOnCampDeath already called release()
+ // which transitions to PROTECTED with grace period.
+ // Calling escape() here would override PROTECTED->FREE, losing the grace.
+ CellRegistryV2.get(level).releasePrisonerFromAllCells(
+ prisonerId
+ );
+ } else {
+ // Offline: full escape via PrisonerService (no grace period needed)
+ com.tiedup.remake.prison.service.PrisonerService.get().escape(
+ level,
+ prisonerId,
+ "camp death"
+ );
+ }
+ ownership.unmarkPrisonerProcessed(prisonerId);
+ }
+
+ // HIGH FIX: Remove all cells belonging to this camp from CellRegistryV2
+ // Prevents memory leak and stale data in indices
+ CellRegistryV2 cellRegistry = CellRegistryV2.get(level);
+ List campCells = cellRegistry.getCellsByCamp(campId);
+ for (CellDataV2 cell : campCells) {
+ cellRegistry.removeCell(cell.getId());
+ TiedUpMod.LOGGER.debug(
+ "[CampLifecycleManager] Removed cell {} from registry",
+ cell.getId().toString().substring(0, 8)
+ );
+ }
+ TiedUpMod.LOGGER.info(
+ "[CampLifecycleManager] Removed {} cells for dead camp {}",
+ campCells.size(),
+ campId.toString().substring(0, 8)
+ );
+
+ // Mark camp as dead
+ data.setAlive(false);
+ ownership.setDirty();
+
+ TiedUpMod.LOGGER.info(
+ "[CampLifecycleManager] Camp {} is now dead, {} prisoners freed",
+ campId.toString().substring(0, 8),
+ prisonerIds.size()
+ );
+ }
+
+ /**
+ * Alert all NPCs in a camp to defend against an attacker.
+ * Called when someone tries to restrain the trader or maid.
+ *
+ * @param campId The camp UUID
+ * @param attacker The player attacking
+ * @param level The server level
+ */
+ public static void alertCampToDefend(
+ UUID campId,
+ Player attacker,
+ ServerLevel level
+ ) {
+ CampOwnership ownership = CampOwnership.get(level);
+ CampOwnership.CampData camp = ownership.getCamp(campId);
+ if (camp == null) return;
+
+ int alertedCount = 0;
+
+ // 1. Alert the trader
+ UUID traderUUID = camp.getTraderUUID();
+ if (traderUUID != null) {
+ net.minecraft.world.entity.Entity traderEntity = level.getEntity(
+ traderUUID
+ );
+ if (
+ traderEntity instanceof
+ com.tiedup.remake.entities.EntitySlaveTrader trader
+ ) {
+ if (trader.isAlive() && !trader.isTiedUp()) {
+ trader.setTarget(attacker);
+ trader.setLastAttacker(attacker);
+ alertedCount++;
+ }
+ }
+ }
+
+ // 2. Alert the maid
+ UUID maidUUID = camp.getMaidUUID();
+ if (maidUUID != null) {
+ net.minecraft.world.entity.Entity maidEntity = level.getEntity(
+ maidUUID
+ );
+ if (
+ maidEntity instanceof com.tiedup.remake.entities.EntityMaid maid
+ ) {
+ if (maid.isAlive() && !maid.isTiedUp()) {
+ maid.setTarget(attacker);
+ maid.setMaidState(
+ com.tiedup.remake.entities.ai.maid.MaidState.DEFENDING
+ );
+ alertedCount++;
+ }
+ }
+ }
+
+ // 3. Alert all kidnappers
+ Set kidnapperUUIDs = camp.getLinkedKidnappers();
+ for (UUID kidnapperUUID : kidnapperUUIDs) {
+ net.minecraft.world.entity.Entity entity = level.getEntity(
+ kidnapperUUID
+ );
+ if (
+ entity instanceof
+ com.tiedup.remake.entities.EntityKidnapper kidnapper
+ ) {
+ if (kidnapper.isAlive() && !kidnapper.isTiedUp()) {
+ kidnapper.setTarget(attacker);
+ kidnapper.setLastAttacker(attacker);
+ alertedCount++;
+ }
+ }
+ }
+
+ TiedUpMod.LOGGER.info(
+ "[CampLifecycleManager] Camp {} alerted {} NPCs to defend against {}",
+ campId.toString().substring(0, 8),
+ alertedCount,
+ attacker.getName().getString()
+ );
+ }
+
+ // ==================== PRIVATE HELPERS ====================
+
+ /**
+ * Free a prisoner when their camp dies.
+ * Untie, unlock collar, cancel sale, notify.
+ * Uses legitimate removal flag to prevent alerting kidnappers.
+ */
+ private static void freePrisonerOnCampDeath(
+ ServerPlayer prisoner,
+ UUID traderUUID,
+ ServerLevel level
+ ) {
+ IRestrainable state = KidnappedHelper.getKidnappedState(prisoner);
+ if (state == null) {
+ return;
+ }
+
+ // Suppress collar removal alerts - this is a legitimate release (camp death)
+ ItemCollar.runWithSuppressedAlert(() -> {
+ // Unlock collar if owned by the dead camp/trader
+ unlockCollarIfOwnedBy(prisoner, state, traderUUID);
+
+ // Remove all restraints (including collar if any)
+ state.untie(true);
+
+ // Cancel sale
+ state.cancelSale();
+ });
+
+ // Clear client HUD
+ com.tiedup.remake.network.ModNetwork.sendToPlayer(
+ new com.tiedup.remake.network.labor.PacketSyncLaborProgress(),
+ prisoner
+ );
+
+ // Notify prisoner
+ prisoner.sendSystemMessage(
+ Component.literal("Your captor has died. You are FREE!").withStyle(
+ ChatFormatting.GREEN,
+ ChatFormatting.BOLD
+ )
+ );
+
+ // Grant grace period (5 minutes = 6000 ticks)
+ PrisonerManager manager = PrisonerManager.get(level);
+ manager.release(prisoner.getUUID(), level.getGameTime(), 6000);
+
+ prisoner.sendSystemMessage(
+ Component.literal(
+ "You have 5 minutes of protection from kidnappers."
+ ).withStyle(ChatFormatting.AQUA)
+ );
+
+ TiedUpMod.LOGGER.info(
+ "[CampLifecycleManager] Freed prisoner {} on camp death (no alert)",
+ prisoner.getName().getString()
+ );
+ }
+
+ /**
+ * Unlock a prisoner's collar if it's owned by the specified owner (trader/kidnapper).
+ */
+ private static void unlockCollarIfOwnedBy(
+ ServerPlayer prisoner,
+ IRestrainable state,
+ UUID ownerUUID
+ ) {
+ ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
+ if (collar.isEmpty()) {
+ return;
+ }
+
+ if (collar.getItem() instanceof ItemCollar collarItem) {
+ List owners = collarItem.getOwners(collar);
+
+ // If the dead trader/camp is an owner, unlock the collar
+ if (owners.contains(ownerUUID)) {
+ if (collar.getItem() instanceof ILockable lockable) {
+ lockable.setLockedByKeyUUID(collar, null); // Unlock and clear
+ }
+ TiedUpMod.LOGGER.debug(
+ "[CampLifecycleManager] Unlocked collar for {} (owner {} died)",
+ prisoner.getName().getString(),
+ ownerUUID.toString().substring(0, 8)
+ );
+ }
+ }
+ }
+
+ /**
+ * SECURITY: Remove all labor tools from player inventory.
+ * Prevents prisoners from keeping unbreakable tools when freed/released.
+ */
+ private static void removeLaborTools(ServerPlayer player) {
+ var inventory = player.getInventory();
+ int removedCount = 0;
+
+ for (int i = 0; i < inventory.getContainerSize(); i++) {
+ ItemStack stack = inventory.getItem(i);
+ if (!stack.isEmpty() && stack.hasTag()) {
+ CompoundTag tag = stack.getTag();
+ if (tag != null && tag.getBoolean("LaborTool")) {
+ inventory.setItem(i, ItemStack.EMPTY);
+ removedCount++;
+ }
+ }
+ }
+
+ if (removedCount > 0) {
+ TiedUpMod.LOGGER.debug(
+ "[CampLifecycleManager] Removed {} labor tools from {} on camp death",
+ removedCount,
+ player.getName().getString()
+ );
+ }
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CampMaidManager.java b/src/main/java/com/tiedup/remake/cells/CampMaidManager.java
new file mode 100644
index 0000000..d11d644
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CampMaidManager.java
@@ -0,0 +1,158 @@
+package com.tiedup.remake.cells;
+
+import com.tiedup.remake.core.TiedUpMod;
+import com.tiedup.remake.prison.PrisonerManager;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import net.minecraft.server.level.ServerLevel;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Manages maid lifecycle within camps: death, respawn timers, prisoner reassignment.
+ *
+ * This is a stateless utility class. All state lives in {@link CampOwnership}
+ * (the SavedData singleton). Methods here orchestrate maid-specific side effects.
+ */
+public final class CampMaidManager {
+
+ private CampMaidManager() {} // utility class
+
+ /**
+ * Mark the maid as dead for a camp.
+ * The camp remains alive but prisoners are paused until new maid spawns.
+ *
+ * @param campId The camp UUID
+ * @param currentTime The current game time
+ * @param level The server level
+ */
+ public static void markMaidDead(UUID campId, long currentTime, ServerLevel level) {
+ CampOwnership ownership = CampOwnership.get(level);
+ CampOwnership.CampData data = ownership.getCamp(campId);
+ if (data == null || !data.isAlive()) {
+ return;
+ }
+
+ // Save maid UUID before clearing (fix NPE)
+ UUID deadMaidId = data.getMaidUUID();
+
+ data.setMaidDeathTime(currentTime);
+ data.setMaidUUID(null);
+
+ // Reset prisoners who were being escorted by the dead maid
+ if (deadMaidId != null) {
+ reassignPrisonersFromMaid(deadMaidId, null, level);
+ }
+
+ ownership.setDirty();
+
+ TiedUpMod.LOGGER.info(
+ "[CampMaidManager] Maid died for camp {} - respawn available in 5 minutes",
+ campId.toString().substring(0, 8)
+ );
+ }
+
+ /**
+ * Assign a new maid to a camp (after respawn or initial setup).
+ *
+ * @param campId The camp UUID
+ * @param newMaidUUID The new maid's UUID
+ * @param level The server level
+ */
+ public static void assignNewMaid(
+ UUID campId,
+ UUID newMaidUUID,
+ ServerLevel level
+ ) {
+ CampOwnership ownership = CampOwnership.get(level);
+ CampOwnership.CampData data = ownership.getCamp(campId);
+ if (data == null) {
+ return;
+ }
+
+ UUID oldMaidId = data.getMaidUUID();
+ data.setMaidUUID(newMaidUUID);
+ data.setMaidDeathTime(-1); // Reset death time
+
+ // Transfer prisoners to new maid
+ reassignPrisonersFromMaid(oldMaidId, newMaidUUID, level);
+
+ ownership.setDirty();
+
+ TiedUpMod.LOGGER.info(
+ "[CampMaidManager] New maid {} assigned to camp {}",
+ newMaidUUID.toString().substring(0, 8),
+ campId.toString().substring(0, 8)
+ );
+ }
+
+ /**
+ * Get camps that need a new maid spawned.
+ *
+ * @param currentTime The current game time
+ * @param level The server level
+ * @return List of camp IDs ready for maid respawn
+ */
+ public static List getCampsNeedingMaidRespawn(long currentTime, ServerLevel level) {
+ CampOwnership ownership = CampOwnership.get(level);
+ List result = new ArrayList<>();
+ for (CampOwnership.CampData data : ownership.getAllCamps()) {
+ if (data.isAlive() && data.canRespawnMaid(currentTime)) {
+ result.add(data.getCampId());
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Reassign all prisoners from one maid to another (for maid death/replacement).
+ * The new PrisonerManager tracks labor state separately via LaborRecord.
+ *
+ * @param oldMaidId The old maid's UUID (or null to assign to all unassigned prisoners)
+ * @param newMaidId The new maid's UUID (or null if maid died with no replacement)
+ * @param level The server level
+ */
+ public static void reassignPrisonersFromMaid(
+ @Nullable UUID oldMaidId,
+ @Nullable UUID newMaidId,
+ ServerLevel level
+ ) {
+ PrisonerManager manager = PrisonerManager.get(level);
+
+ for (UUID playerId : manager.getAllPrisonerIds()) {
+ com.tiedup.remake.prison.LaborRecord labor = manager.getLaborRecord(
+ playerId
+ );
+ if (labor == null) continue;
+
+ // Check if this prisoner was managed by the old maid
+ UUID assignedMaid = labor.getMaidId();
+ boolean shouldReassign =
+ (oldMaidId == null && assignedMaid == null) ||
+ (oldMaidId != null && oldMaidId.equals(assignedMaid));
+
+ if (shouldReassign) {
+ // Update maid ID in labor record
+ labor.setMaidId(newMaidId);
+
+ // If maid died (no replacement) during active work, prisoner can rest
+ if (
+ newMaidId == null &&
+ labor.getPhase() !=
+ com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE
+ ) {
+ labor.setPhase(
+ com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE,
+ level.getGameTime()
+ );
+ TiedUpMod.LOGGER.info(
+ "[CampMaidManager] Prisoner {} labor reset to IDLE - maid died during escort",
+ playerId.toString().substring(0, 8)
+ );
+ }
+ // If there's a replacement maid, keep current state so they get picked up
+ }
+ }
+ CampOwnership.get(level).setDirty();
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CampOwnership.java b/src/main/java/com/tiedup/remake/cells/CampOwnership.java
new file mode 100644
index 0000000..487d706
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CampOwnership.java
@@ -0,0 +1,576 @@
+package com.tiedup.remake.cells;
+
+import com.tiedup.remake.core.TiedUpMod;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.saveddata.SavedData;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Global registry for camp ownership linking camps to their SlaveTrader.
+ *
+ * This registry tracks:
+ * - Camp UUID -> CampData (trader, maid, alive status)
+ * - When a trader dies, the camp becomes inactive
+ *
+ * Persists across server restarts using Minecraft's SavedData system.
+ */
+public class CampOwnership extends SavedData {
+
+ private static final String DATA_NAME = "tiedup_camp_ownership";
+
+ // Camp UUID -> CampData
+ private final Map camps = new ConcurrentHashMap<>();
+
+ // Prisoners that have been processed (to avoid re-processing)
+ private final Set processedPrisoners = ConcurrentHashMap.newKeySet();
+
+ // ==================== CAMP DATA CLASS ====================
+
+ /**
+ * Data structure representing a camp and its owner.
+ * Uses thread-safe collections for concurrent access.
+ */
+ /** Maid respawn delay in ticks (5 minutes = 6000 ticks) */
+ public static final long MAID_RESPAWN_DELAY = 6000;
+
+ public static class CampData {
+
+ private final UUID campId;
+ private volatile UUID traderUUID;
+ private volatile UUID maidUUID;
+ private volatile boolean isAlive = true;
+ private volatile BlockPos center;
+ private final Set linkedKidnapperUUIDs =
+ ConcurrentHashMap.newKeySet();
+
+ /** Time when maid died (for respawn timer), -1 if alive */
+ private volatile long maidDeathTime = -1;
+
+ /** Cached positions of LOOT chests (below LOOT markers) */
+ private final List lootChestPositions = new ArrayList<>();
+
+ public CampData(UUID campId) {
+ this.campId = campId;
+ }
+
+ public CampData(
+ UUID campId,
+ UUID traderUUID,
+ @Nullable UUID maidUUID,
+ BlockPos center
+ ) {
+ this.campId = campId;
+ this.traderUUID = traderUUID;
+ this.maidUUID = maidUUID;
+ this.center = center;
+ this.isAlive = true;
+ }
+
+ // Getters
+ public UUID getCampId() {
+ return campId;
+ }
+
+ public UUID getTraderUUID() {
+ return traderUUID;
+ }
+
+ public UUID getMaidUUID() {
+ return maidUUID;
+ }
+
+ public boolean isAlive() {
+ return isAlive;
+ }
+
+ public BlockPos getCenter() {
+ return center;
+ }
+
+ // Setters
+ public void setTraderUUID(UUID traderUUID) {
+ this.traderUUID = traderUUID;
+ }
+
+ public void setMaidUUID(UUID maidUUID) {
+ this.maidUUID = maidUUID;
+ }
+
+ public void setAlive(boolean alive) {
+ this.isAlive = alive;
+ }
+
+ public void setCenter(BlockPos center) {
+ this.center = center;
+ }
+
+ // Maid death/respawn
+ public long getMaidDeathTime() {
+ return maidDeathTime;
+ }
+
+ public void setMaidDeathTime(long time) {
+ this.maidDeathTime = time;
+ }
+
+ public boolean isMaidDead() {
+ return maidDeathTime >= 0;
+ }
+
+ public boolean canRespawnMaid(long currentTime) {
+ return (
+ isMaidDead() &&
+ (currentTime - maidDeathTime) >= MAID_RESPAWN_DELAY
+ );
+ }
+
+ // Loot chest management
+ public List getLootChestPositions() {
+ return lootChestPositions;
+ }
+
+ public void addLootChestPosition(BlockPos pos) {
+ if (!lootChestPositions.contains(pos)) {
+ lootChestPositions.add(pos);
+ }
+ }
+
+ // Kidnapper management
+ public void addKidnapper(UUID kidnapperUUID) {
+ linkedKidnapperUUIDs.add(kidnapperUUID);
+ }
+
+ public void removeKidnapper(UUID kidnapperUUID) {
+ linkedKidnapperUUIDs.remove(kidnapperUUID);
+ }
+
+ public Set getLinkedKidnappers() {
+ return Collections.unmodifiableSet(linkedKidnapperUUIDs);
+ }
+
+ public boolean hasKidnapper(UUID kidnapperUUID) {
+ return linkedKidnapperUUIDs.contains(kidnapperUUID);
+ }
+
+ public int getKidnapperCount() {
+ return linkedKidnapperUUIDs.size();
+ }
+
+ // NBT Serialization
+ public CompoundTag save() {
+ CompoundTag tag = new CompoundTag();
+ tag.putUUID("campId", campId);
+ if (traderUUID != null) tag.putUUID("traderUUID", traderUUID);
+ if (maidUUID != null) tag.putUUID("maidUUID", maidUUID);
+ tag.putBoolean("isAlive", isAlive);
+ if (center != null) {
+ tag.putInt("centerX", center.getX());
+ tag.putInt("centerY", center.getY());
+ tag.putInt("centerZ", center.getZ());
+ }
+ // Save linked kidnappers
+ if (!linkedKidnapperUUIDs.isEmpty()) {
+ ListTag kidnapperList = new ListTag();
+ for (UUID uuid : linkedKidnapperUUIDs) {
+ CompoundTag uuidTag = new CompoundTag();
+ uuidTag.putUUID("uuid", uuid);
+ kidnapperList.add(uuidTag);
+ }
+ tag.put("linkedKidnappers", kidnapperList);
+ }
+ // Save maid death time
+ tag.putLong("maidDeathTime", maidDeathTime);
+ // Save loot chest positions
+ if (!lootChestPositions.isEmpty()) {
+ ListTag lootList = new ListTag();
+ for (BlockPos pos : lootChestPositions) {
+ CompoundTag posTag = new CompoundTag();
+ posTag.putInt("x", pos.getX());
+ posTag.putInt("y", pos.getY());
+ posTag.putInt("z", pos.getZ());
+ lootList.add(posTag);
+ }
+ tag.put("lootChestPositions", lootList);
+ }
+ return tag;
+ }
+
+ public static CampData load(CompoundTag tag) {
+ UUID campId = tag.getUUID("campId");
+ CampData data = new CampData(campId);
+ if (tag.contains("traderUUID")) data.traderUUID = tag.getUUID(
+ "traderUUID"
+ );
+ if (tag.contains("maidUUID")) data.maidUUID = tag.getUUID(
+ "maidUUID"
+ );
+ data.isAlive = tag.getBoolean("isAlive");
+ if (tag.contains("centerX")) {
+ data.center = new BlockPos(
+ tag.getInt("centerX"),
+ tag.getInt("centerY"),
+ tag.getInt("centerZ")
+ );
+ }
+ // Load linked kidnappers
+ if (tag.contains("linkedKidnappers")) {
+ ListTag kidnapperList = tag.getList(
+ "linkedKidnappers",
+ Tag.TAG_COMPOUND
+ );
+ for (int i = 0; i < kidnapperList.size(); i++) {
+ CompoundTag uuidTag = kidnapperList.getCompound(i);
+ if (uuidTag.contains("uuid")) {
+ data.linkedKidnapperUUIDs.add(uuidTag.getUUID("uuid"));
+ }
+ }
+ }
+ // Load maid death time
+ if (tag.contains("maidDeathTime")) {
+ data.maidDeathTime = tag.getLong("maidDeathTime");
+ }
+ // Load loot chest positions
+ if (tag.contains("lootChestPositions")) {
+ ListTag lootList = tag.getList(
+ "lootChestPositions",
+ Tag.TAG_COMPOUND
+ );
+ for (int i = 0; i < lootList.size(); i++) {
+ CompoundTag posTag = lootList.getCompound(i);
+ data.lootChestPositions.add(
+ new BlockPos(
+ posTag.getInt("x"),
+ posTag.getInt("y"),
+ posTag.getInt("z")
+ )
+ );
+ }
+ }
+ return data;
+ }
+ }
+
+ // ==================== STATIC ACCESS ====================
+
+ /**
+ * Get the CampOwnership registry for a server level.
+ */
+ public static CampOwnership get(ServerLevel level) {
+ return level
+ .getDataStorage()
+ .computeIfAbsent(
+ CampOwnership::load,
+ CampOwnership::new,
+ DATA_NAME
+ );
+ }
+
+ /**
+ * Get the CampOwnership from a MinecraftServer.
+ */
+ public static CampOwnership get(MinecraftServer server) {
+ ServerLevel overworld = server.overworld();
+ return get(overworld);
+ }
+
+ // ==================== CAMP MANAGEMENT ====================
+
+ /**
+ * Register a new camp with its trader and maid.
+ *
+ * @param campId The camp/structure UUID
+ * @param traderUUID The SlaveTrader entity UUID
+ * @param maidUUID The Maid entity UUID (can be null)
+ * @param center The center position of the camp
+ */
+ public void registerCamp(
+ UUID campId,
+ UUID traderUUID,
+ @Nullable UUID maidUUID,
+ BlockPos center
+ ) {
+ CampData data = new CampData(campId, traderUUID, maidUUID, center);
+ camps.put(campId, data);
+ setDirty();
+ }
+
+ /**
+ * Check if a camp is alive (has living trader).
+ *
+ * @param campId The camp UUID
+ * @return true if camp exists and is alive
+ */
+ public boolean isCampAlive(UUID campId) {
+ CampData data = camps.get(campId);
+ return data != null && data.isAlive();
+ }
+
+ /**
+ * Get camp data by camp UUID.
+ */
+ @Nullable
+ public CampData getCamp(UUID campId) {
+ return camps.get(campId);
+ }
+
+ /**
+ * Get camp data by trader UUID.
+ */
+ @Nullable
+ public CampData getCampByTrader(UUID traderUUID) {
+ for (CampData camp : camps.values()) {
+ if (traderUUID.equals(camp.getTraderUUID())) {
+ return camp;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get camp data by maid UUID.
+ */
+ @Nullable
+ public CampData getCampByMaid(UUID maidUUID) {
+ for (CampData camp : camps.values()) {
+ if (maidUUID.equals(camp.getMaidUUID())) {
+ return camp;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Find camps near a position.
+ *
+ * @param center The center position
+ * @param radius The search radius
+ * @return List of camps within radius
+ */
+ public List findCampsNear(BlockPos center, double radius) {
+ List nearby = new ArrayList<>();
+ double radiusSq = radius * radius;
+
+ for (CampData camp : camps.values()) {
+ if (
+ camp.getCenter() != null &&
+ camp.getCenter().distSqr(center) <= radiusSq
+ ) {
+ nearby.add(camp);
+ }
+ }
+ return nearby;
+ }
+
+ /**
+ * Find the nearest alive camp to a position.
+ *
+ * @param pos The position to search from
+ * @param radius Maximum search radius
+ * @return The nearest alive camp, or null
+ */
+ @Nullable
+ public CampData findNearestAliveCamp(BlockPos pos, double radius) {
+ CampData nearest = null;
+ double nearestDistSq = radius * radius;
+
+ for (CampData camp : camps.values()) {
+ if (!camp.isAlive() || camp.getCenter() == null) continue;
+
+ double distSq = camp.getCenter().distSqr(pos);
+ if (distSq < nearestDistSq) {
+ nearestDistSq = distSq;
+ nearest = camp;
+ }
+ }
+ return nearest;
+ }
+
+ /**
+ * Remove a camp from the registry.
+ */
+ @Nullable
+ public CampData removeCamp(UUID campId) {
+ CampData removed = camps.remove(campId);
+ if (removed != null) {
+ setDirty();
+ }
+ return removed;
+ }
+
+ /**
+ * Get all registered camps.
+ */
+ public Collection getAllCamps() {
+ return Collections.unmodifiableCollection(camps.values());
+ }
+
+ /**
+ * Get all alive camps.
+ */
+ public List getAliveCamps() {
+ List alive = new ArrayList<>();
+ for (CampData camp : camps.values()) {
+ if (camp.isAlive()) {
+ alive.add(camp);
+ }
+ }
+ return alive;
+ }
+
+ /**
+ * Link a kidnapper to a camp.
+ *
+ * @param campId The camp UUID
+ * @param kidnapperUUID The kidnapper UUID
+ */
+ public void linkKidnapperToCamp(UUID campId, UUID kidnapperUUID) {
+ CampData data = camps.get(campId);
+ if (data != null) {
+ data.addKidnapper(kidnapperUUID);
+ setDirty();
+ }
+ }
+
+ /**
+ * Unlink a kidnapper from a camp.
+ *
+ * @param campId The camp UUID
+ * @param kidnapperUUID The kidnapper UUID
+ */
+ public void unlinkKidnapperFromCamp(UUID campId, UUID kidnapperUUID) {
+ CampData data = camps.get(campId);
+ if (data != null) {
+ data.removeKidnapper(kidnapperUUID);
+ setDirty();
+ }
+ }
+
+ /**
+ * Check if a kidnapper is linked to a camp.
+ *
+ * @param campId The camp UUID
+ * @param kidnapperUUID The kidnapper UUID
+ * @return true if the kidnapper is linked to this camp
+ */
+ public boolean isKidnapperLinked(UUID campId, UUID kidnapperUUID) {
+ CampData data = camps.get(campId);
+ return data != null && data.hasKidnapper(kidnapperUUID);
+ }
+
+ /**
+ * Find the camp a kidnapper is linked to.
+ *
+ * @param kidnapperUUID The kidnapper UUID
+ * @return The camp data, or null if not linked
+ */
+ @Nullable
+ public CampData findCampByKidnapper(UUID kidnapperUUID) {
+ for (CampData camp : camps.values()) {
+ if (camp.hasKidnapper(kidnapperUUID)) {
+ return camp;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Mark a prisoner as processed (to avoid re-processing).
+ *
+ * @param prisonerId The prisoner's UUID
+ */
+ public void markPrisonerProcessed(UUID prisonerId) {
+ processedPrisoners.add(prisonerId);
+ setDirty();
+ }
+
+ /**
+ * Check if a prisoner has been processed.
+ *
+ * @param prisonerId The prisoner's UUID
+ * @return true if already processed
+ */
+ public boolean isPrisonerProcessed(UUID prisonerId) {
+ return processedPrisoners.contains(prisonerId);
+ }
+
+ /**
+ * Remove a prisoner from processed set.
+ *
+ * @param prisonerId The prisoner's UUID
+ */
+ public void unmarkPrisonerProcessed(UUID prisonerId) {
+ if (processedPrisoners.remove(prisonerId)) {
+ setDirty();
+ }
+ }
+
+ /**
+ * Get the set of processed prisoners for a camp (unmodifiable).
+ *
+ * @return Unmodifiable set of processed prisoner UUIDs
+ */
+ public Set getProcessedPrisoners() {
+ return Collections.unmodifiableSet(processedPrisoners);
+ }
+
+ // ==================== PERSISTENCE ====================
+
+ @Override
+ public @NotNull CompoundTag save(@NotNull CompoundTag tag) {
+ // Save camps
+ ListTag campList = new ListTag();
+ for (CampData camp : camps.values()) {
+ campList.add(camp.save());
+ }
+ tag.put("camps", campList);
+
+ // Save processed prisoners
+ ListTag processedList = new ListTag();
+ for (UUID uuid : processedPrisoners) {
+ CompoundTag uuidTag = new CompoundTag();
+ uuidTag.putUUID("uuid", uuid);
+ processedList.add(uuidTag);
+ }
+ tag.put("processedPrisoners", processedList);
+
+ return tag;
+ }
+
+ public static CampOwnership load(CompoundTag tag) {
+ CampOwnership registry = new CampOwnership();
+
+ // Load camps
+ if (tag.contains("camps")) {
+ ListTag campList = tag.getList("camps", Tag.TAG_COMPOUND);
+ for (int i = 0; i < campList.size(); i++) {
+ CampData camp = CampData.load(campList.getCompound(i));
+ registry.camps.put(camp.getCampId(), camp);
+ }
+ }
+
+ // Load processed prisoners
+ if (tag.contains("processedPrisoners")) {
+ ListTag processedList = tag.getList(
+ "processedPrisoners",
+ Tag.TAG_COMPOUND
+ );
+ for (int i = 0; i < processedList.size(); i++) {
+ CompoundTag uuidTag = processedList.getCompound(i);
+ if (uuidTag.contains("uuid")) {
+ registry.processedPrisoners.add(uuidTag.getUUID("uuid"));
+ }
+ }
+ }
+
+ return registry;
+ }
+
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CellDataV2.java b/src/main/java/com/tiedup/remake/cells/CellDataV2.java
new file mode 100644
index 0000000..c0acf4a
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CellDataV2.java
@@ -0,0 +1,607 @@
+package com.tiedup.remake.cells;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtUtils;
+import net.minecraft.nbt.Tag;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Cell data for Cell System V2.
+ *
+ * Named CellDataV2 to coexist with v1 CellData during the migration period.
+ * Contains geometry (interior + walls from flood-fill), breach tracking,
+ * prisoner management, and auto-detected features.
+ */
+public class CellDataV2 {
+
+ private static final int MAX_PRISONERS = 4;
+
+ // Identity
+ private final UUID id;
+ private CellState state;
+ private final BlockPos corePos;
+
+ // Cached from Core BE (for use when chunk is unloaded)
+ @Nullable
+ private BlockPos spawnPoint;
+
+ @Nullable
+ private BlockPos deliveryPoint;
+
+ // Ownership
+ @Nullable
+ private UUID ownerId;
+
+ private CellOwnerType ownerType = CellOwnerType.PLAYER;
+
+ @Nullable
+ private String name;
+
+ // Geometry (from flood-fill)
+ private final Set interiorBlocks;
+ private final Set wallBlocks;
+ private final Set breachedPositions;
+ private int totalWallCount;
+
+ // Interior face direction (which face of Core points inside)
+ @Nullable
+ private Direction interiorFace;
+
+ // Auto-detected features
+ private final List beds;
+ private final List petBeds;
+ private final List anchors;
+ private final List doors;
+ private final List linkedRedstone;
+
+ // Prisoners
+ private final List prisonerIds = new CopyOnWriteArrayList<>();
+ private final Map prisonerTimestamps =
+ new ConcurrentHashMap<>();
+
+ // Camp navigation
+ private final List pathWaypoints = new CopyOnWriteArrayList<>();
+
+ // ==================== CONSTRUCTORS ====================
+
+ /**
+ * Create from a successful flood-fill result.
+ */
+ public CellDataV2(BlockPos corePos, FloodFillResult result) {
+ this.id = UUID.randomUUID();
+ this.state = CellState.INTACT;
+ this.corePos = corePos.immutable();
+ this.interiorFace = result.getInteriorFace();
+
+ this.interiorBlocks = ConcurrentHashMap.newKeySet();
+ this.interiorBlocks.addAll(result.getInterior());
+
+ this.wallBlocks = ConcurrentHashMap.newKeySet();
+ this.wallBlocks.addAll(result.getWalls());
+
+ this.breachedPositions = ConcurrentHashMap.newKeySet();
+ this.totalWallCount = result.getWalls().size();
+
+ this.beds = new CopyOnWriteArrayList<>(result.getBeds());
+ this.petBeds = new CopyOnWriteArrayList<>(result.getPetBeds());
+ this.anchors = new CopyOnWriteArrayList<>(result.getAnchors());
+ this.doors = new CopyOnWriteArrayList<>(result.getDoors());
+ this.linkedRedstone = new CopyOnWriteArrayList<>(
+ result.getLinkedRedstone()
+ );
+ }
+
+ /**
+ * Create for NBT loading (minimal constructor).
+ */
+ public CellDataV2(UUID id, BlockPos corePos) {
+ this.id = id;
+ this.state = CellState.INTACT;
+ this.corePos = corePos.immutable();
+
+ this.interiorBlocks = ConcurrentHashMap.newKeySet();
+ this.wallBlocks = ConcurrentHashMap.newKeySet();
+ this.breachedPositions = ConcurrentHashMap.newKeySet();
+ this.totalWallCount = 0;
+
+ this.beds = new CopyOnWriteArrayList<>();
+ this.petBeds = new CopyOnWriteArrayList<>();
+ this.anchors = new CopyOnWriteArrayList<>();
+ this.doors = new CopyOnWriteArrayList<>();
+ this.linkedRedstone = new CopyOnWriteArrayList<>();
+ }
+
+ // ==================== IDENTITY ====================
+
+ public UUID getId() {
+ return id;
+ }
+
+ public CellState getState() {
+ return state;
+ }
+
+ public void setState(CellState state) {
+ this.state = state;
+ }
+
+ public BlockPos getCorePos() {
+ return corePos;
+ }
+
+ @Nullable
+ public BlockPos getSpawnPoint() {
+ return spawnPoint;
+ }
+
+ public void setSpawnPoint(@Nullable BlockPos spawnPoint) {
+ this.spawnPoint = spawnPoint != null ? spawnPoint.immutable() : null;
+ }
+
+ @Nullable
+ public BlockPos getDeliveryPoint() {
+ return deliveryPoint;
+ }
+
+ public void setDeliveryPoint(@Nullable BlockPos deliveryPoint) {
+ this.deliveryPoint =
+ deliveryPoint != null ? deliveryPoint.immutable() : null;
+ }
+
+ @Nullable
+ public Direction getInteriorFace() {
+ return interiorFace;
+ }
+
+ // ==================== OWNERSHIP ====================
+
+ @Nullable
+ public UUID getOwnerId() {
+ return ownerId;
+ }
+
+ public void setOwnerId(@Nullable UUID ownerId) {
+ this.ownerId = ownerId;
+ }
+
+ public CellOwnerType getOwnerType() {
+ return ownerType;
+ }
+
+ public void setOwnerType(CellOwnerType ownerType) {
+ this.ownerType = ownerType;
+ }
+
+ @Nullable
+ public String getName() {
+ return name;
+ }
+
+ public void setName(@Nullable String name) {
+ this.name = name;
+ }
+
+ public boolean isOwnedBy(UUID playerId) {
+ return playerId != null && playerId.equals(ownerId);
+ }
+
+ /**
+ * Check if a player can manage this cell (open menu, rename, modify settings).
+ * - OPs (level 2+) can always manage any cell (including camp-owned).
+ * - Player-owned cells: only the owning player.
+ * - Camp-owned cells: OPs only.
+ *
+ * @param playerId UUID of the player
+ * @param hasOpPerms true if the player has OP level 2+
+ * @return true if the player is allowed to manage this cell
+ */
+ public boolean canPlayerManage(UUID playerId, boolean hasOpPerms) {
+ if (hasOpPerms) return true;
+ if (isCampOwned()) return false;
+ return isOwnedBy(playerId);
+ }
+
+ public boolean hasOwner() {
+ return ownerId != null;
+ }
+
+ public boolean isCampOwned() {
+ return ownerType == CellOwnerType.CAMP;
+ }
+
+ public boolean isPlayerOwned() {
+ return ownerType == CellOwnerType.PLAYER;
+ }
+
+ @Nullable
+ public UUID getCampId() {
+ return isCampOwned() ? ownerId : null;
+ }
+
+ // ==================== GEOMETRY ====================
+
+ public Set getInteriorBlocks() {
+ return Collections.unmodifiableSet(interiorBlocks);
+ }
+
+ public Set getWallBlocks() {
+ return Collections.unmodifiableSet(wallBlocks);
+ }
+
+ public Set getBreachedPositions() {
+ return Collections.unmodifiableSet(breachedPositions);
+ }
+
+ public int getTotalWallCount() {
+ return totalWallCount;
+ }
+
+ public boolean isContainedInCell(BlockPos pos) {
+ return interiorBlocks.contains(pos);
+ }
+
+ public boolean isWallBlock(BlockPos pos) {
+ return wallBlocks.contains(pos);
+ }
+
+ // ==================== BREACH MANAGEMENT ====================
+
+ public void addBreach(BlockPos wallPos) {
+ if (wallBlocks.remove(wallPos)) {
+ breachedPositions.add(wallPos.immutable());
+ }
+ }
+
+ public void repairBreach(BlockPos wallPos) {
+ if (breachedPositions.remove(wallPos)) {
+ wallBlocks.add(wallPos.immutable());
+ }
+ }
+
+ public float getBreachPercentage() {
+ if (totalWallCount == 0) return 0.0f;
+ return (float) breachedPositions.size() / totalWallCount;
+ }
+
+ // ==================== FEATURES ====================
+
+ public List getBeds() {
+ return Collections.unmodifiableList(beds);
+ }
+
+ public List getPetBeds() {
+ return Collections.unmodifiableList(petBeds);
+ }
+
+ public List getAnchors() {
+ return Collections.unmodifiableList(anchors);
+ }
+
+ public List getDoors() {
+ return Collections.unmodifiableList(doors);
+ }
+
+ public List getLinkedRedstone() {
+ return Collections.unmodifiableList(linkedRedstone);
+ }
+
+ // ==================== PRISONER MANAGEMENT ====================
+
+ public List getPrisonerIds() {
+ return Collections.unmodifiableList(prisonerIds);
+ }
+
+ public boolean hasPrisoner(UUID prisonerId) {
+ return prisonerIds.contains(prisonerId);
+ }
+
+ public boolean isFull() {
+ return prisonerIds.size() >= MAX_PRISONERS;
+ }
+
+ public boolean isOccupied() {
+ return !prisonerIds.isEmpty();
+ }
+
+ public int getPrisonerCount() {
+ return prisonerIds.size();
+ }
+
+ public boolean addPrisoner(UUID prisonerId) {
+ if (isFull() || prisonerIds.contains(prisonerId)) {
+ return false;
+ }
+ prisonerIds.add(prisonerId);
+ prisonerTimestamps.put(prisonerId, System.currentTimeMillis());
+ return true;
+ }
+
+ public boolean removePrisoner(UUID prisonerId) {
+ boolean removed = prisonerIds.remove(prisonerId);
+ if (removed) {
+ prisonerTimestamps.remove(prisonerId);
+ }
+ return removed;
+ }
+
+ @Nullable
+ public Long getPrisonerTimestamp(UUID prisonerId) {
+ return prisonerTimestamps.get(prisonerId);
+ }
+
+ // ==================== CAMP NAVIGATION ====================
+
+ public List getPathWaypoints() {
+ return Collections.unmodifiableList(pathWaypoints);
+ }
+
+ public void setPathWaypoints(List waypoints) {
+ pathWaypoints.clear();
+ pathWaypoints.addAll(waypoints);
+ }
+
+ // ==================== WIRE RECONSTRUCTION (client-side) ====================
+
+ /** Add a wall block position (used for client-side packet reconstruction). */
+ public void addWallBlock(BlockPos pos) {
+ wallBlocks.add(pos.immutable());
+ }
+
+ /** Add a bed position (used for client-side packet reconstruction). */
+ public void addBed(BlockPos pos) {
+ beds.add(pos.immutable());
+ }
+
+ /** Add an anchor position (used for client-side packet reconstruction). */
+ public void addAnchor(BlockPos pos) {
+ anchors.add(pos.immutable());
+ }
+
+ /** Add a door position (used for client-side packet reconstruction). */
+ public void addDoor(BlockPos pos) {
+ doors.add(pos.immutable());
+ }
+
+ // ==================== GEOMETRY UPDATE (rescan) ====================
+
+ /**
+ * Replace geometry with a new flood-fill result (used during rescan).
+ */
+ public void updateGeometry(FloodFillResult result) {
+ interiorBlocks.clear();
+ interiorBlocks.addAll(result.getInterior());
+
+ wallBlocks.clear();
+ wallBlocks.addAll(result.getWalls());
+
+ breachedPositions.clear();
+ totalWallCount = result.getWalls().size();
+
+ if (result.getInteriorFace() != null) {
+ this.interiorFace = result.getInteriorFace();
+ }
+
+ beds.clear();
+ beds.addAll(result.getBeds());
+ petBeds.clear();
+ petBeds.addAll(result.getPetBeds());
+ anchors.clear();
+ anchors.addAll(result.getAnchors());
+ doors.clear();
+ doors.addAll(result.getDoors());
+ linkedRedstone.clear();
+ linkedRedstone.addAll(result.getLinkedRedstone());
+
+ state = CellState.INTACT;
+ }
+
+ // ==================== NBT PERSISTENCE ====================
+
+ public CompoundTag save() {
+ CompoundTag tag = new CompoundTag();
+
+ tag.putUUID("id", id);
+ tag.putString("state", state.getSerializedName());
+ tag.put("corePos", NbtUtils.writeBlockPos(corePos));
+
+ if (spawnPoint != null) {
+ tag.put("spawnPoint", NbtUtils.writeBlockPos(spawnPoint));
+ }
+ if (deliveryPoint != null) {
+ tag.put("deliveryPoint", NbtUtils.writeBlockPos(deliveryPoint));
+ }
+ if (interiorFace != null) {
+ tag.putString("interiorFace", interiorFace.getSerializedName());
+ }
+
+ // Ownership
+ if (ownerId != null) {
+ tag.putUUID("ownerId", ownerId);
+ }
+ tag.putString("ownerType", ownerType.getSerializedName());
+ if (name != null) {
+ tag.putString("name", name);
+ }
+
+ // Geometry
+ tag.put("interior", saveBlockPosSet(interiorBlocks));
+ tag.put("walls", saveBlockPosSet(wallBlocks));
+ tag.put("breached", saveBlockPosSet(breachedPositions));
+ tag.putInt("totalWallCount", totalWallCount);
+
+ // Features
+ tag.put("beds", saveBlockPosList(beds));
+ tag.put("petBeds", saveBlockPosList(petBeds));
+ tag.put("anchors", saveBlockPosList(anchors));
+ tag.put("doors", saveBlockPosList(doors));
+ tag.put("linkedRedstone", saveBlockPosList(linkedRedstone));
+
+ // Prisoners
+ if (!prisonerIds.isEmpty()) {
+ ListTag prisonerList = new ListTag();
+ for (UUID uuid : prisonerIds) {
+ CompoundTag prisonerTag = new CompoundTag();
+ prisonerTag.putUUID("id", uuid);
+ prisonerTag.putLong(
+ "timestamp",
+ prisonerTimestamps.getOrDefault(
+ uuid,
+ System.currentTimeMillis()
+ )
+ );
+ prisonerList.add(prisonerTag);
+ }
+ tag.put("prisoners", prisonerList);
+ }
+
+ // Path waypoints
+ if (!pathWaypoints.isEmpty()) {
+ tag.put("pathWaypoints", saveBlockPosList(pathWaypoints));
+ }
+
+ return tag;
+ }
+
+ @Nullable
+ public static CellDataV2 load(CompoundTag tag) {
+ if (!tag.contains("id") || !tag.contains("corePos")) {
+ return null;
+ }
+
+ UUID id = tag.getUUID("id");
+ BlockPos corePos = NbtUtils.readBlockPos(tag.getCompound("corePos"));
+ CellDataV2 cell = new CellDataV2(id, corePos);
+
+ cell.state = CellState.fromString(tag.getString("state"));
+
+ if (tag.contains("spawnPoint")) {
+ cell.spawnPoint = NbtUtils.readBlockPos(
+ tag.getCompound("spawnPoint")
+ );
+ }
+ if (tag.contains("deliveryPoint")) {
+ cell.deliveryPoint = NbtUtils.readBlockPos(
+ tag.getCompound("deliveryPoint")
+ );
+ }
+ if (tag.contains("interiorFace")) {
+ cell.interiorFace = Direction.byName(tag.getString("interiorFace"));
+ }
+
+ // Ownership
+ if (tag.contains("ownerId")) {
+ cell.ownerId = tag.getUUID("ownerId");
+ }
+ if (tag.contains("ownerType")) {
+ cell.ownerType = CellOwnerType.fromString(
+ tag.getString("ownerType")
+ );
+ }
+ if (tag.contains("name")) {
+ cell.name = tag.getString("name");
+ }
+
+ // Geometry
+ loadBlockPosSet(tag, "interior", cell.interiorBlocks);
+ loadBlockPosSet(tag, "walls", cell.wallBlocks);
+ loadBlockPosSet(tag, "breached", cell.breachedPositions);
+ cell.totalWallCount = tag.getInt("totalWallCount");
+
+ // Features
+ loadBlockPosList(tag, "beds", cell.beds);
+ loadBlockPosList(tag, "petBeds", cell.petBeds);
+ loadBlockPosList(tag, "anchors", cell.anchors);
+ loadBlockPosList(tag, "doors", cell.doors);
+ loadBlockPosList(tag, "linkedRedstone", cell.linkedRedstone);
+
+ // Prisoners
+ if (tag.contains("prisoners")) {
+ ListTag prisonerList = tag.getList("prisoners", Tag.TAG_COMPOUND);
+ for (int i = 0; i < prisonerList.size(); i++) {
+ CompoundTag prisonerTag = prisonerList.getCompound(i);
+ UUID prisonerId = prisonerTag.getUUID("id");
+ cell.prisonerIds.add(prisonerId);
+ long timestamp = prisonerTag.contains("timestamp")
+ ? prisonerTag.getLong("timestamp")
+ : System.currentTimeMillis();
+ cell.prisonerTimestamps.put(prisonerId, timestamp);
+ }
+ }
+
+ // Path waypoints
+ loadBlockPosList(tag, "pathWaypoints", cell.pathWaypoints);
+
+ return cell;
+ }
+
+ // ==================== NBT HELPERS ====================
+
+ private static ListTag saveBlockPosSet(Set positions) {
+ ListTag list = new ListTag();
+ for (BlockPos pos : positions) {
+ list.add(NbtUtils.writeBlockPos(pos));
+ }
+ return list;
+ }
+
+ private static ListTag saveBlockPosList(List positions) {
+ ListTag list = new ListTag();
+ for (BlockPos pos : positions) {
+ list.add(NbtUtils.writeBlockPos(pos));
+ }
+ return list;
+ }
+
+ private static void loadBlockPosSet(
+ CompoundTag parent,
+ String key,
+ Set target
+ ) {
+ if (parent.contains(key)) {
+ ListTag list = parent.getList(key, Tag.TAG_COMPOUND);
+ for (int i = 0; i < list.size(); i++) {
+ target.add(NbtUtils.readBlockPos(list.getCompound(i)));
+ }
+ }
+ }
+
+ private static void loadBlockPosList(
+ CompoundTag parent,
+ String key,
+ List target
+ ) {
+ if (parent.contains(key)) {
+ ListTag list = parent.getList(key, Tag.TAG_COMPOUND);
+ for (int i = 0; i < list.size(); i++) {
+ target.add(NbtUtils.readBlockPos(list.getCompound(i)));
+ }
+ }
+ }
+
+ // ==================== DEBUG ====================
+
+ @Override
+ public String toString() {
+ return (
+ "CellDataV2{id=" +
+ id.toString().substring(0, 8) +
+ "..., state=" +
+ state +
+ ", core=" +
+ corePos.toShortString() +
+ ", interior=" +
+ interiorBlocks.size() +
+ ", walls=" +
+ wallBlocks.size() +
+ ", prisoners=" +
+ prisonerIds.size() +
+ "}"
+ );
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CellOwnerType.java b/src/main/java/com/tiedup/remake/cells/CellOwnerType.java
new file mode 100644
index 0000000..6e94053
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CellOwnerType.java
@@ -0,0 +1,33 @@
+package com.tiedup.remake.cells;
+
+/**
+ * Enum indicating who owns a cell.
+ * Used to distinguish player-created cells from camp-generated cells.
+ *
+ * Extracted from CellData.OwnerType to decouple V2 code from V1 CellData.
+ */
+public enum CellOwnerType {
+ /** Cell created by a player using Cell Wand */
+ PLAYER("player"),
+ /** Cell generated with a kidnapper camp structure */
+ CAMP("camp");
+
+ private final String serializedName;
+
+ CellOwnerType(String serializedName) {
+ this.serializedName = serializedName;
+ }
+
+ public String getSerializedName() {
+ return serializedName;
+ }
+
+ public static CellOwnerType fromString(String name) {
+ for (CellOwnerType type : values()) {
+ if (type.serializedName.equalsIgnoreCase(name)) {
+ return type;
+ }
+ }
+ return PLAYER; // Default to PLAYER for backwards compatibility
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CellRegistryV2.java b/src/main/java/com/tiedup/remake/cells/CellRegistryV2.java
new file mode 100644
index 0000000..91e4345
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CellRegistryV2.java
@@ -0,0 +1,903 @@
+package com.tiedup.remake.cells;
+
+import com.tiedup.remake.core.SystemMessageManager;
+import com.tiedup.remake.core.TiedUpMod;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.saveddata.SavedData;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Global registry for Cell System V2 data.
+ *
+ * Named CellRegistryV2 to coexist with v1 CellRegistry during migration.
+ * Uses "tiedup_cell_registry_v2" as the SavedData name.
+ *
+ * Provides spatial indices for fast lookups by wall position, interior position,
+ * core position, chunk, and camp.
+ */
+public class CellRegistryV2 extends SavedData {
+
+ private static final String DATA_NAME = "tiedup_cell_registry_v2";
+
+ /** Reservation timeout in ticks (30 seconds = 600 ticks) */
+ private static final long RESERVATION_TIMEOUT_TICKS = 600L;
+
+ // ==================== RESERVATION ====================
+
+ private static class CellReservation {
+
+ private final UUID kidnapperUUID;
+ private final long expiryTime;
+
+ public CellReservation(UUID kidnapperUUID, long expiryTime) {
+ this.kidnapperUUID = kidnapperUUID;
+ this.expiryTime = expiryTime;
+ }
+
+ public UUID getKidnapperUUID() {
+ return kidnapperUUID;
+ }
+
+ public boolean isExpired(long currentTime) {
+ return currentTime >= expiryTime;
+ }
+ }
+
+ // ==================== STORAGE ====================
+
+ // Primary storage
+ private final Map cells = new ConcurrentHashMap<>();
+
+ // Indices (rebuilt on load)
+ private final Map wallToCell = new ConcurrentHashMap<>();
+ private final Map interiorToCell =
+ new ConcurrentHashMap<>();
+ private final Map coreToCell = new ConcurrentHashMap<>();
+
+ // Spatial + camp indices
+ private final Map> cellsByChunk =
+ new ConcurrentHashMap<>();
+ private final Map> cellsByCamp = new ConcurrentHashMap<>();
+
+ // Breach tracking index (breached wall position → cell ID)
+ private final Map breachedToCell =
+ new ConcurrentHashMap<>();
+
+ // Reservations (not persisted)
+ private final Map reservations =
+ new ConcurrentHashMap<>();
+
+ // ==================== STATIC ACCESS ====================
+
+ public static CellRegistryV2 get(ServerLevel level) {
+ return level
+ .getDataStorage()
+ .computeIfAbsent(
+ CellRegistryV2::load,
+ CellRegistryV2::new,
+ DATA_NAME
+ );
+ }
+
+ public static CellRegistryV2 get(MinecraftServer server) {
+ return get(server.overworld());
+ }
+
+ // ==================== CELL LIFECYCLE ====================
+
+ /**
+ * Create a new cell from a flood-fill result.
+ */
+ public CellDataV2 createCell(
+ BlockPos corePos,
+ FloodFillResult result,
+ @Nullable UUID ownerId
+ ) {
+ CellDataV2 cell = new CellDataV2(corePos, result);
+ if (ownerId != null) {
+ cell.setOwnerId(ownerId);
+ }
+
+ cells.put(cell.getId(), cell);
+
+ // Register in indices
+ coreToCell.put(corePos.immutable(), cell.getId());
+ for (BlockPos pos : cell.getWallBlocks()) {
+ wallToCell.put(pos.immutable(), cell.getId());
+ }
+ for (BlockPos pos : cell.getInteriorBlocks()) {
+ interiorToCell.put(pos.immutable(), cell.getId());
+ }
+
+ addToSpatialIndex(cell);
+ setDirty();
+ return cell;
+ }
+
+ /**
+ * Register a pre-constructed CellDataV2 (used by migration and structure loading).
+ * The cell must already have its ID and corePos set.
+ */
+ public void registerExistingCell(CellDataV2 cell) {
+ cells.put(cell.getId(), cell);
+ coreToCell.put(cell.getCorePos().immutable(), cell.getId());
+
+ for (BlockPos pos : cell.getWallBlocks()) {
+ wallToCell.put(pos.immutable(), cell.getId());
+ }
+ for (BlockPos pos : cell.getInteriorBlocks()) {
+ interiorToCell.put(pos.immutable(), cell.getId());
+ }
+
+ addToSpatialIndex(cell);
+ setDirty();
+ }
+
+ /**
+ * Remove a cell from the registry and all indices.
+ */
+ public void removeCell(UUID cellId) {
+ CellDataV2 cell = cells.remove(cellId);
+ if (cell == null) return;
+
+ coreToCell.remove(cell.getCorePos());
+ for (BlockPos pos : cell.getWallBlocks()) {
+ wallToCell.remove(pos);
+ }
+ for (BlockPos pos : cell.getBreachedPositions()) {
+ breachedToCell.remove(pos);
+ }
+ for (BlockPos pos : cell.getInteriorBlocks()) {
+ interiorToCell.remove(pos);
+ }
+
+ removeFromSpatialIndex(cell);
+ reservations.remove(cellId);
+ setDirty();
+ }
+
+ /**
+ * Rescan a cell with a new flood-fill result.
+ * Clears old indices and repopulates with new geometry.
+ */
+ public void rescanCell(UUID cellId, FloodFillResult newResult) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell == null) return;
+
+ // Clear old indices for this cell
+ for (BlockPos pos : cell.getWallBlocks()) {
+ wallToCell.remove(pos);
+ }
+ for (BlockPos pos : cell.getBreachedPositions()) {
+ wallToCell.remove(pos);
+ breachedToCell.remove(pos);
+ }
+ for (BlockPos pos : cell.getInteriorBlocks()) {
+ interiorToCell.remove(pos);
+ }
+ removeFromSpatialIndex(cell);
+
+ // Update geometry
+ cell.updateGeometry(newResult);
+
+ // Rebuild indices
+ for (BlockPos pos : cell.getWallBlocks()) {
+ wallToCell.put(pos.immutable(), cellId);
+ }
+ for (BlockPos pos : cell.getInteriorBlocks()) {
+ interiorToCell.put(pos.immutable(), cellId);
+ }
+ addToSpatialIndex(cell);
+
+ setDirty();
+ }
+
+ // ==================== QUERIES ====================
+
+ @Nullable
+ public CellDataV2 getCell(UUID cellId) {
+ return cells.get(cellId);
+ }
+
+ @Nullable
+ public CellDataV2 getCellAtCore(BlockPos corePos) {
+ UUID cellId = coreToCell.get(corePos);
+ return cellId != null ? cells.get(cellId) : null;
+ }
+
+ @Nullable
+ public CellDataV2 getCellContaining(BlockPos pos) {
+ UUID cellId = interiorToCell.get(pos);
+ return cellId != null ? cells.get(cellId) : null;
+ }
+
+ @Nullable
+ public CellDataV2 getCellByWall(BlockPos pos) {
+ UUID cellId = wallToCell.get(pos);
+ return cellId != null ? cells.get(cellId) : null;
+ }
+
+ @Nullable
+ public UUID getCellIdAtWall(BlockPos pos) {
+ return wallToCell.get(pos);
+ }
+
+ public boolean isInsideAnyCell(BlockPos pos) {
+ return interiorToCell.containsKey(pos);
+ }
+
+ public Collection getAllCells() {
+ return Collections.unmodifiableCollection(cells.values());
+ }
+
+ public int getCellCount() {
+ return cells.size();
+ }
+
+ public List getCellsByCamp(UUID campId) {
+ Set cellIds = cellsByCamp.get(campId);
+ if (cellIds == null) return Collections.emptyList();
+
+ List result = new ArrayList<>();
+ for (UUID cellId : cellIds) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell != null) {
+ result.add(cell);
+ }
+ }
+ return result;
+ }
+
+ public List findCellsNear(BlockPos center, double radius) {
+ List nearby = new ArrayList<>();
+ double radiusSq = radius * radius;
+
+ int chunkRadius = (int) Math.ceil(radius / 16.0) + 1;
+ ChunkPos centerChunk = new ChunkPos(center);
+
+ for (int dx = -chunkRadius; dx <= chunkRadius; dx++) {
+ for (int dz = -chunkRadius; dz <= chunkRadius; dz++) {
+ ChunkPos checkChunk = new ChunkPos(
+ centerChunk.x + dx,
+ centerChunk.z + dz
+ );
+ Set cellsInChunk = cellsByChunk.get(checkChunk);
+
+ if (cellsInChunk != null) {
+ for (UUID cellId : cellsInChunk) {
+ CellDataV2 cell = cells.get(cellId);
+ if (
+ cell != null &&
+ cell.getCorePos().distSqr(center) <= radiusSq
+ ) {
+ nearby.add(cell);
+ }
+ }
+ }
+ }
+ }
+ return nearby;
+ }
+
+ @Nullable
+ public CellDataV2 findCellByPrisoner(UUID prisonerId) {
+ for (CellDataV2 cell : cells.values()) {
+ if (cell.hasPrisoner(prisonerId)) {
+ return cell;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ public CellDataV2 getCellByName(String name) {
+ if (name == null || name.isEmpty()) return null;
+ for (CellDataV2 cell : cells.values()) {
+ if (name.equals(cell.getName())) {
+ return cell;
+ }
+ }
+ return null;
+ }
+
+ public List getCellsByOwner(UUID ownerId) {
+ if (ownerId == null) return Collections.emptyList();
+ return cells
+ .values()
+ .stream()
+ .filter(c -> ownerId.equals(c.getOwnerId()))
+ .collect(Collectors.toList());
+ }
+
+ public int getCellCountOwnedBy(UUID ownerId) {
+ if (ownerId == null) return 0;
+ return (int) cells
+ .values()
+ .stream()
+ .filter(c -> ownerId.equals(c.getOwnerId()))
+ .count();
+ }
+
+ @Nullable
+ public UUID getNextCellId(@Nullable UUID currentId) {
+ if (cells.isEmpty()) return null;
+ List ids = new ArrayList<>(cells.keySet());
+ if (currentId == null) return ids.get(0);
+ int index = ids.indexOf(currentId);
+ if (index < 0 || index >= ids.size() - 1) return ids.get(0);
+ return ids.get(index + 1);
+ }
+
+ // ==================== CAMP QUERIES ====================
+
+ public List getPrisonersInCamp(UUID campId) {
+ if (campId == null) return Collections.emptyList();
+ return getCellsByCamp(campId)
+ .stream()
+ .flatMap(cell -> cell.getPrisonerIds().stream())
+ .collect(Collectors.toList());
+ }
+
+ public int getPrisonerCountInCamp(UUID campId) {
+ if (campId == null) return 0;
+ return getCellsByCamp(campId)
+ .stream()
+ .mapToInt(CellDataV2::getPrisonerCount)
+ .sum();
+ }
+
+ @Nullable
+ public CellDataV2 findAvailableCellInCamp(UUID campId) {
+ if (campId == null) return null;
+ for (CellDataV2 cell : getCellsByCamp(campId)) {
+ if (!cell.isFull()) {
+ return cell;
+ }
+ }
+ return null;
+ }
+
+ public boolean hasCampCells(UUID campId) {
+ if (campId == null) return false;
+ Set cellIds = cellsByCamp.get(campId);
+ return cellIds != null && !cellIds.isEmpty();
+ }
+
+ /**
+ * Update the camp index for a cell after ownership change.
+ * Removes from old camp index, adds to new if camp-owned.
+ */
+ public void updateCampIndex(CellDataV2 cell, @Nullable UUID oldOwnerId) {
+ if (oldOwnerId != null) {
+ Set oldCampCells = cellsByCamp.get(oldOwnerId);
+ if (oldCampCells != null) {
+ oldCampCells.remove(cell.getId());
+ if (oldCampCells.isEmpty()) {
+ cellsByCamp.remove(oldOwnerId);
+ }
+ }
+ }
+
+ if (cell.isCampOwned() && cell.getOwnerId() != null) {
+ cellsByCamp
+ .computeIfAbsent(cell.getOwnerId(), k ->
+ ConcurrentHashMap.newKeySet()
+ )
+ .add(cell.getId());
+ }
+
+ setDirty();
+ }
+
+ // ==================== PRISONER MANAGEMENT ====================
+
+ public synchronized boolean assignPrisoner(UUID cellId, UUID prisonerId) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell == null || cell.isFull()) return false;
+
+ // Ensure prisoner uniqueness across cells
+ CellDataV2 existingCell = findCellByPrisoner(prisonerId);
+ if (existingCell != null) {
+ if (existingCell.getId().equals(cellId)) {
+ return true; // Already in this cell
+ }
+ return false; // Already in another cell
+ }
+
+ if (cell.addPrisoner(prisonerId)) {
+ setDirty();
+ return true;
+ }
+ return false;
+ }
+
+ public boolean releasePrisoner(
+ UUID cellId,
+ UUID prisonerId,
+ MinecraftServer server
+ ) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell == null) return false;
+
+ if (cell.removePrisoner(prisonerId)) {
+ // Synchronize with PrisonerManager
+ if (cell.isCampOwned() && cell.getCampId() != null) {
+ com.tiedup.remake.prison.PrisonerManager manager =
+ com.tiedup.remake.prison.PrisonerManager.get(
+ server.overworld()
+ );
+ com.tiedup.remake.prison.PrisonerState currentState =
+ manager.getState(prisonerId);
+
+ boolean isBeingExtracted =
+ currentState ==
+ com.tiedup.remake.prison.PrisonerState.WORKING;
+
+ if (
+ !isBeingExtracted &&
+ currentState ==
+ com.tiedup.remake.prison.PrisonerState.IMPRISONED
+ ) {
+ manager.release(
+ prisonerId,
+ server.overworld().getGameTime()
+ );
+ }
+ }
+
+ setDirty();
+ return true;
+ }
+ return false;
+ }
+
+ public boolean assignPrisonerWithNotification(
+ UUID cellId,
+ UUID prisonerId,
+ MinecraftServer server,
+ String prisonerName
+ ) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell == null || cell.isFull()) return false;
+
+ if (cell.addPrisoner(prisonerId)) {
+ setDirty();
+ if (cell.hasOwner()) {
+ notifyOwner(
+ server,
+ cell.getOwnerId(),
+ SystemMessageManager.MessageCategory.PRISONER_ARRIVED,
+ prisonerName
+ );
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public boolean releasePrisonerWithNotification(
+ UUID cellId,
+ UUID prisonerId,
+ MinecraftServer server,
+ String prisonerName,
+ boolean escaped
+ ) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell == null) return false;
+
+ if (cell.removePrisoner(prisonerId)) {
+ // Synchronize with PrisonerManager
+ if (cell.isCampOwned() && cell.getCampId() != null) {
+ com.tiedup.remake.prison.PrisonerManager manager =
+ com.tiedup.remake.prison.PrisonerManager.get(
+ server.overworld()
+ );
+ com.tiedup.remake.prison.PrisonerState currentState =
+ manager.getState(prisonerId);
+
+ if (
+ currentState ==
+ com.tiedup.remake.prison.PrisonerState.IMPRISONED
+ ) {
+ manager.release(
+ prisonerId,
+ server.overworld().getGameTime()
+ );
+ }
+ }
+
+ setDirty();
+
+ if (cell.hasOwner()) {
+ SystemMessageManager.MessageCategory category = escaped
+ ? SystemMessageManager.MessageCategory.PRISONER_ESCAPED
+ : SystemMessageManager.MessageCategory.PRISONER_RELEASED;
+ notifyOwner(server, cell.getOwnerId(), category, prisonerName);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public int releasePrisonerFromAllCells(UUID prisonerId) {
+ int count = 0;
+ for (CellDataV2 cell : cells.values()) {
+ if (cell.removePrisoner(prisonerId)) {
+ count++;
+ }
+ }
+ if (count > 0) {
+ setDirty();
+ }
+ return count;
+ }
+
+ /** Offline timeout for cleanup: 30 minutes */
+ private static final long OFFLINE_TIMEOUT_MS = 30 * 60 * 1000L;
+
+ public int cleanupEscapedPrisoners(
+ ServerLevel level,
+ com.tiedup.remake.state.CollarRegistry collarRegistry,
+ double maxDistance
+ ) {
+ int removed = 0;
+
+ for (CellDataV2 cell : cells.values()) {
+ List toRemove = new ArrayList<>();
+
+ for (UUID prisonerId : cell.getPrisonerIds()) {
+ boolean shouldRemove = false;
+ String reason = null;
+
+ ServerPlayer prisoner = level
+ .getServer()
+ .getPlayerList()
+ .getPlayer(prisonerId);
+
+ if (prisoner == null) {
+ Long timestamp = cell.getPrisonerTimestamp(prisonerId);
+ long ts =
+ timestamp != null
+ ? timestamp
+ : System.currentTimeMillis();
+ long offlineDuration = System.currentTimeMillis() - ts;
+
+ if (offlineDuration > OFFLINE_TIMEOUT_MS) {
+ shouldRemove = true;
+ reason =
+ "offline for too long (" +
+ (offlineDuration / 60000) +
+ " minutes)";
+ } else {
+ continue;
+ }
+ } else {
+ // Use corePos for distance check (V2 uses core position, not spawnPoint)
+ double distSq = prisoner
+ .blockPosition()
+ .distSqr(cell.getCorePos());
+ if (distSq > maxDistance * maxDistance) {
+ shouldRemove = true;
+ reason =
+ "too far from cell (" +
+ (int) Math.sqrt(distSq) +
+ " blocks)";
+ }
+
+ if (
+ !shouldRemove && !collarRegistry.hasOwners(prisonerId)
+ ) {
+ shouldRemove = true;
+ reason = "no collar registered";
+ }
+
+ if (!shouldRemove) {
+ com.tiedup.remake.state.IBondageState state =
+ com.tiedup.remake.util.KidnappedHelper.getKidnappedState(
+ prisoner
+ );
+ if (state == null || !state.isCaptive()) {
+ shouldRemove = true;
+ reason = "no longer captive";
+ }
+ }
+ }
+
+ if (shouldRemove) {
+ toRemove.add(prisonerId);
+ TiedUpMod.LOGGER.info(
+ "[CellRegistryV2] Removing escaped prisoner {} from cell {} - reason: {}",
+ prisonerId.toString().substring(0, 8),
+ cell.getId().toString().substring(0, 8),
+ reason
+ );
+ }
+ }
+
+ for (UUID id : toRemove) {
+ cell.removePrisoner(id);
+
+ if (cell.isCampOwned() && cell.getCampId() != null) {
+ com.tiedup.remake.prison.PrisonerManager manager =
+ com.tiedup.remake.prison.PrisonerManager.get(
+ level.getServer().overworld()
+ );
+ com.tiedup.remake.prison.PrisonerState currentState =
+ manager.getState(id);
+
+ if (
+ currentState ==
+ com.tiedup.remake.prison.PrisonerState.IMPRISONED
+ ) {
+ com.tiedup.remake.prison.service.PrisonerService.get().escape(
+ level,
+ id,
+ "offline_cleanup"
+ );
+ }
+ }
+
+ removed++;
+ }
+ }
+
+ if (removed > 0) {
+ setDirty();
+ }
+
+ return removed;
+ }
+
+ // ==================== NOTIFICATIONS ====================
+
+ private void notifyOwner(
+ MinecraftServer server,
+ UUID ownerId,
+ SystemMessageManager.MessageCategory category,
+ String prisonerName
+ ) {
+ if (server == null || ownerId == null) return;
+ ServerPlayer owner = server.getPlayerList().getPlayer(ownerId);
+ if (owner != null) {
+ String template = SystemMessageManager.getTemplate(category);
+ String formattedMessage = String.format(template, prisonerName);
+ SystemMessageManager.sendToPlayer(
+ owner,
+ category,
+ formattedMessage
+ );
+ }
+ }
+
+ // ==================== BREACH MANAGEMENT ====================
+
+ /**
+ * Record a wall breach: updates CellDataV2 and indices atomically.
+ */
+ public void addBreach(UUID cellId, BlockPos pos) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell == null) return;
+
+ cell.addBreach(pos);
+ wallToCell.remove(pos);
+ breachedToCell.put(pos.immutable(), cellId);
+ setDirty();
+ }
+
+ /**
+ * Repair a wall breach: updates CellDataV2 and indices atomically.
+ */
+ public void repairBreach(UUID cellId, BlockPos pos) {
+ CellDataV2 cell = cells.get(cellId);
+ if (cell == null) return;
+
+ cell.repairBreach(pos);
+ breachedToCell.remove(pos);
+ wallToCell.put(pos.immutable(), cellId);
+ setDirty();
+ }
+
+ /**
+ * Get the cell ID for a breached wall position.
+ */
+ @Nullable
+ public UUID getCellIdAtBreach(BlockPos pos) {
+ return breachedToCell.get(pos);
+ }
+
+ // ==================== RESERVATIONS ====================
+
+ public boolean reserveCell(UUID cellId, UUID kidnapperUUID, long gameTime) {
+ cleanupExpiredReservations(gameTime);
+
+ long expiryTime = gameTime + RESERVATION_TIMEOUT_TICKS;
+ CellReservation existing = reservations.get(cellId);
+
+ if (existing != null) {
+ if (existing.getKidnapperUUID().equals(kidnapperUUID)) {
+ reservations.put(
+ cellId,
+ new CellReservation(kidnapperUUID, expiryTime)
+ );
+ return true;
+ }
+ if (!existing.isExpired(gameTime)) {
+ return false;
+ }
+ }
+
+ reservations.put(
+ cellId,
+ new CellReservation(kidnapperUUID, expiryTime)
+ );
+ return true;
+ }
+
+ public boolean consumeReservation(UUID cellId, UUID kidnapperUUID) {
+ CellReservation reservation = reservations.remove(cellId);
+ if (reservation == null) return false;
+ return reservation.getKidnapperUUID().equals(kidnapperUUID);
+ }
+
+ public boolean isReservedByOther(
+ UUID cellId,
+ @Nullable UUID kidnapperUUID,
+ long gameTime
+ ) {
+ CellReservation reservation = reservations.get(cellId);
+ if (reservation == null) return false;
+ if (reservation.isExpired(gameTime)) {
+ reservations.remove(cellId);
+ return false;
+ }
+ return (
+ kidnapperUUID == null ||
+ !reservation.getKidnapperUUID().equals(kidnapperUUID)
+ );
+ }
+
+ public void cancelReservation(UUID cellId, UUID kidnapperUUID) {
+ CellReservation reservation = reservations.get(cellId);
+ if (
+ reservation != null &&
+ reservation.getKidnapperUUID().equals(kidnapperUUID)
+ ) {
+ reservations.remove(cellId);
+ }
+ }
+
+ private void cleanupExpiredReservations(long gameTime) {
+ reservations
+ .entrySet()
+ .removeIf(entry -> entry.getValue().isExpired(gameTime));
+ }
+
+ // ==================== SPATIAL INDEX ====================
+
+ private void addToSpatialIndex(CellDataV2 cell) {
+ ChunkPos chunkPos = new ChunkPos(cell.getCorePos());
+ cellsByChunk
+ .computeIfAbsent(chunkPos, k -> ConcurrentHashMap.newKeySet())
+ .add(cell.getId());
+
+ // Add to camp index if camp-owned
+ if (
+ cell.getOwnerType() == CellOwnerType.CAMP &&
+ cell.getOwnerId() != null
+ ) {
+ cellsByCamp
+ .computeIfAbsent(cell.getOwnerId(), k ->
+ ConcurrentHashMap.newKeySet()
+ )
+ .add(cell.getId());
+ }
+ }
+
+ private void removeFromSpatialIndex(CellDataV2 cell) {
+ ChunkPos chunkPos = new ChunkPos(cell.getCorePos());
+ Set cellsInChunk = cellsByChunk.get(chunkPos);
+ if (cellsInChunk != null) {
+ cellsInChunk.remove(cell.getId());
+ if (cellsInChunk.isEmpty()) {
+ cellsByChunk.remove(chunkPos);
+ }
+ }
+
+ if (
+ cell.getOwnerType() == CellOwnerType.CAMP &&
+ cell.getOwnerId() != null
+ ) {
+ Set cellsInCamp = cellsByCamp.get(cell.getOwnerId());
+ if (cellsInCamp != null) {
+ cellsInCamp.remove(cell.getId());
+ if (cellsInCamp.isEmpty()) {
+ cellsByCamp.remove(cell.getOwnerId());
+ }
+ }
+ }
+ }
+
+ // ==================== INDEX REBUILD ====================
+
+ private void rebuildIndices() {
+ wallToCell.clear();
+ interiorToCell.clear();
+ coreToCell.clear();
+ breachedToCell.clear();
+ cellsByChunk.clear();
+ cellsByCamp.clear();
+
+ for (CellDataV2 cell : cells.values()) {
+ coreToCell.put(cell.getCorePos(), cell.getId());
+ for (BlockPos pos : cell.getWallBlocks()) {
+ wallToCell.put(pos, cell.getId());
+ }
+ for (BlockPos pos : cell.getBreachedPositions()) {
+ breachedToCell.put(pos, cell.getId());
+ }
+ for (BlockPos pos : cell.getInteriorBlocks()) {
+ interiorToCell.put(pos, cell.getId());
+ }
+ addToSpatialIndex(cell);
+ }
+ }
+
+ // ==================== PERSISTENCE ====================
+
+ @Override
+ public @NotNull CompoundTag save(@NotNull CompoundTag tag) {
+ ListTag cellList = new ListTag();
+ for (CellDataV2 cell : cells.values()) {
+ cellList.add(cell.save());
+ }
+ tag.put("cells", cellList);
+ return tag;
+ }
+
+ public static CellRegistryV2 load(CompoundTag tag) {
+ CellRegistryV2 registry = new CellRegistryV2();
+
+ if (tag.contains("cells")) {
+ ListTag cellList = tag.getList("cells", Tag.TAG_COMPOUND);
+ for (int i = 0; i < cellList.size(); i++) {
+ CellDataV2 cell = CellDataV2.load(cellList.getCompound(i));
+ if (cell != null) {
+ registry.cells.put(cell.getId(), cell);
+ }
+ }
+ }
+
+ registry.rebuildIndices();
+ return registry;
+ }
+
+ // ==================== DEBUG ====================
+
+ public String toDebugString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("CellRegistryV2:\n");
+ sb.append(" Total cells: ").append(cells.size()).append("\n");
+ sb.append(" Wall index: ").append(wallToCell.size()).append("\n");
+ sb
+ .append(" Interior index: ")
+ .append(interiorToCell.size())
+ .append("\n");
+ sb.append(" Core index: ").append(coreToCell.size()).append("\n");
+
+ for (CellDataV2 cell : cells.values()) {
+ sb.append(" ").append(cell.toString()).append("\n");
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CellSelectionManager.java b/src/main/java/com/tiedup/remake/cells/CellSelectionManager.java
new file mode 100644
index 0000000..f373754
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CellSelectionManager.java
@@ -0,0 +1,96 @@
+package com.tiedup.remake.cells;
+
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import net.minecraft.core.BlockPos;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Server-side manager tracking which players are in selection mode
+ * (Set Spawn, Set Delivery, Set Disguise) after clicking a Cell Core menu button.
+ *
+ * Static map pattern matching ForcedSeatingHandler.
+ */
+public class CellSelectionManager {
+
+ private static final long TIMEOUT_MS = 30 * 1000L;
+ private static final double MAX_DISTANCE_SQ = 10.0 * 10.0;
+
+ private static final ConcurrentHashMap selections =
+ new ConcurrentHashMap<>();
+
+ public static class SelectionContext {
+
+ public final SelectionMode mode;
+ public final BlockPos corePos;
+ public final UUID cellId;
+ public final long startTimeMs;
+ public final BlockPos playerStartPos;
+
+ public SelectionContext(
+ SelectionMode mode,
+ BlockPos corePos,
+ UUID cellId,
+ BlockPos playerStartPos
+ ) {
+ this.mode = mode;
+ this.corePos = corePos;
+ this.cellId = cellId;
+ this.startTimeMs = System.currentTimeMillis();
+ this.playerStartPos = playerStartPos;
+ }
+ }
+
+ public static void startSelection(
+ UUID playerId,
+ SelectionMode mode,
+ BlockPos corePos,
+ UUID cellId,
+ BlockPos playerPos
+ ) {
+ selections.put(
+ playerId,
+ new SelectionContext(mode, corePos, cellId, playerPos)
+ );
+ }
+
+ @Nullable
+ public static SelectionContext getSelection(UUID playerId) {
+ return selections.get(playerId);
+ }
+
+ public static void clearSelection(UUID playerId) {
+ selections.remove(playerId);
+ }
+
+ public static boolean isInSelectionMode(UUID playerId) {
+ return selections.containsKey(playerId);
+ }
+
+ /**
+ * Check if selection should be cancelled due to timeout or distance.
+ */
+ public static boolean shouldCancel(UUID playerId, BlockPos currentPos) {
+ SelectionContext ctx = selections.get(playerId);
+ if (ctx == null) return false;
+
+ // Timeout check
+ if (System.currentTimeMillis() - ctx.startTimeMs > TIMEOUT_MS) {
+ return true;
+ }
+
+ // Distance check (from core, not player start)
+ if (ctx.corePos.distSqr(currentPos) > MAX_DISTANCE_SQ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Called on player disconnect to prevent memory leaks.
+ */
+ public static void cleanup(UUID playerId) {
+ selections.remove(playerId);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/CellState.java b/src/main/java/com/tiedup/remake/cells/CellState.java
new file mode 100644
index 0000000..494ab03
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/CellState.java
@@ -0,0 +1,33 @@
+package com.tiedup.remake.cells;
+
+/**
+ * State of a Cell System V2 cell.
+ *
+ * INTACT — all walls present, fully operational.
+ * BREACHED — some walls broken, prisoners may escape.
+ * COMPROMISED — Core destroyed or too many walls broken; cell is non-functional.
+ */
+public enum CellState {
+ INTACT("intact"),
+ BREACHED("breached"),
+ COMPROMISED("compromised");
+
+ private final String serializedName;
+
+ CellState(String serializedName) {
+ this.serializedName = serializedName;
+ }
+
+ public String getSerializedName() {
+ return serializedName;
+ }
+
+ public static CellState fromString(String name) {
+ for (CellState state : values()) {
+ if (state.serializedName.equalsIgnoreCase(name)) {
+ return state;
+ }
+ }
+ return INTACT;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/ConfiscatedInventoryRegistry.java b/src/main/java/com/tiedup/remake/cells/ConfiscatedInventoryRegistry.java
new file mode 100644
index 0000000..d38739e
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/ConfiscatedInventoryRegistry.java
@@ -0,0 +1,641 @@
+package com.tiedup.remake.cells;
+
+import com.tiedup.remake.core.TiedUpMod;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtUtils;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.Container;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.entity.ChestBlockEntity;
+import net.minecraft.world.level.saveddata.SavedData;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Phase 2: SavedData registry for confiscated player inventories.
+ *
+ * When a player is imprisoned:
+ * 1. Their inventory is saved to NBT
+ * 2. Items are transferred to a LOOT chest in the cell
+ * 3. Player's inventory is cleared
+ * 4. Data is persisted for recovery after server restart
+ *
+ * Recovery options:
+ * - Player finds and opens the chest manually
+ * - Player escapes and returns to chest
+ * - Admin command /tiedup returnstuff
+ */
+public class ConfiscatedInventoryRegistry extends SavedData {
+
+ private static final String DATA_NAME =
+ TiedUpMod.MOD_ID + "_confiscated_inventories";
+
+ /**
+ * Map of prisoner UUID to their confiscated inventory data
+ */
+ private final Map confiscatedInventories =
+ new HashMap<>();
+
+ /**
+ * Data class for a single confiscated inventory
+ */
+ public static class ConfiscatedData {
+
+ public final UUID prisonerId;
+ public final CompoundTag inventoryNbt;
+ public final BlockPos chestPos;
+ public final UUID cellId;
+ public final long confiscatedTime;
+
+ public ConfiscatedData(
+ UUID prisonerId,
+ CompoundTag inventoryNbt,
+ BlockPos chestPos,
+ @Nullable UUID cellId,
+ long confiscatedTime
+ ) {
+ this.prisonerId = prisonerId;
+ this.inventoryNbt = inventoryNbt;
+ this.chestPos = chestPos;
+ this.cellId = cellId;
+ this.confiscatedTime = confiscatedTime;
+ }
+
+ public CompoundTag save() {
+ CompoundTag tag = new CompoundTag();
+ tag.putUUID("prisonerId", prisonerId);
+ tag.put("inventory", inventoryNbt);
+ tag.put("chestPos", NbtUtils.writeBlockPos(chestPos));
+ if (cellId != null) {
+ tag.putUUID("cellId", cellId);
+ }
+ tag.putLong("time", confiscatedTime);
+ return tag;
+ }
+
+ public static ConfiscatedData load(CompoundTag tag) {
+ UUID prisonerId = tag.getUUID("prisonerId");
+ CompoundTag inventoryNbt = tag.getCompound("inventory");
+ BlockPos chestPos = NbtUtils.readBlockPos(
+ tag.getCompound("chestPos")
+ );
+ UUID cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null;
+ long time = tag.getLong("time");
+ return new ConfiscatedData(
+ prisonerId,
+ inventoryNbt,
+ chestPos,
+ cellId,
+ time
+ );
+ }
+ }
+
+ public ConfiscatedInventoryRegistry() {}
+
+ // ==================== STATIC ACCESSORS ====================
+
+ /**
+ * Get or create the registry for a server level.
+ */
+ public static ConfiscatedInventoryRegistry get(ServerLevel level) {
+ return level
+ .getDataStorage()
+ .computeIfAbsent(
+ ConfiscatedInventoryRegistry::load,
+ ConfiscatedInventoryRegistry::new,
+ DATA_NAME
+ );
+ }
+
+ // ==================== CONFISCATION METHODS ====================
+
+ /**
+ * Confiscate a player's inventory.
+ *
+ * @param player The player whose inventory to confiscate
+ * @param chestPos The position of the LOOT chest
+ * @param cellId The cell ID (optional)
+ * @return true if confiscation was successful
+ */
+ public boolean confiscate(
+ ServerPlayer player,
+ BlockPos chestPos,
+ @Nullable UUID cellId
+ ) {
+ Inventory inventory = player.getInventory();
+
+ // CRITICAL FIX: Transaction safety - save record BEFORE any state changes
+ // This ensures that if the server crashes, we have a backup to restore from
+
+ // Save inventory to NBT (backup in case of issues)
+ CompoundTag inventoryNbt = savePlayerInventory(player);
+
+ // Create and persist record FIRST (before modifying game state)
+ ConfiscatedData data = new ConfiscatedData(
+ player.getUUID(),
+ inventoryNbt,
+ chestPos,
+ cellId,
+ System.currentTimeMillis()
+ );
+ confiscatedInventories.put(player.getUUID(), data);
+ setDirty(); // Persist immediately before state changes
+
+ // Now attempt transfer to chest
+ boolean transferred = transferToChest(
+ player.serverLevel(),
+ chestPos,
+ inventory
+ );
+
+ if (!transferred) {
+ // Transfer failed - rollback: remove record and DO NOT clear inventory
+ confiscatedInventories.remove(player.getUUID());
+ setDirty(); // Persist the rollback
+
+ TiedUpMod.LOGGER.error(
+ "[ConfiscatedInventoryRegistry] Failed to transfer items for {} - items NOT confiscated, record rolled back",
+ player.getName().getString()
+ );
+ return false;
+ }
+
+ // Transfer succeeded - now safe to clear inventory
+ inventory.clearContent();
+
+ TiedUpMod.LOGGER.info(
+ "[ConfiscatedInventoryRegistry] Confiscated inventory from {} (chest: {}, cell: {})",
+ player.getName().getString(),
+ chestPos.toShortString(),
+ cellId != null ? cellId.toString().substring(0, 8) : "none"
+ );
+
+ // Final persist to save cleared inventory state
+ setDirty();
+ return true;
+ }
+
+ /**
+ * Dump a player's inventory to a chest WITHOUT creating a backup record.
+ * Used for daily labor returns - items gathered during work are transferred to camp storage.
+ * Does NOT clear the player's inventory - caller should clear labor tools separately.
+ *
+ * @param player The player whose inventory to dump
+ * @param chestPos The position of the chest
+ * @return true if transfer was successful
+ */
+ public boolean dumpInventoryToChest(
+ ServerPlayer player,
+ BlockPos chestPos
+ ) {
+ Inventory inventory = player.getInventory();
+
+ // Transfer items to chest
+ boolean transferred = transferToChest(
+ player.serverLevel(),
+ chestPos,
+ inventory
+ );
+
+ if (!transferred) {
+ TiedUpMod.LOGGER.warn(
+ "[ConfiscatedInventoryRegistry] Failed to dump labor inventory for {} - chest issue",
+ player.getName().getString()
+ );
+ return false;
+ }
+
+ // Clear player inventory (items are now in chest)
+ inventory.clearContent();
+
+ TiedUpMod.LOGGER.debug(
+ "[ConfiscatedInventoryRegistry] Dumped labor inventory from {} to chest at {}",
+ player.getName().getString(),
+ chestPos.toShortString()
+ );
+
+ return true;
+ }
+
+ /**
+ * Deposits items across multiple LOOT chests with smart rotation.
+ * Distributes items evenly to prevent single chest from filling too quickly.
+ *
+ * @param items List of items to deposit
+ * @param chestPositions List of chest positions to use (in priority order)
+ * @param level Server level
+ * @return Number of items successfully deposited (remainder dropped)
+ */
+ public int depositItemsInChests(
+ List items,
+ List chestPositions,
+ ServerLevel level
+ ) {
+ if (items.isEmpty() || chestPositions.isEmpty()) {
+ return 0;
+ }
+
+ int deposited = 0;
+ List overflow = new ArrayList<>();
+
+ // Try to deposit each item
+ for (ItemStack stack : items) {
+ if (stack.isEmpty()) continue;
+
+ boolean placed = false;
+
+ // Try all chests in order
+ for (BlockPos chestPos : chestPositions) {
+ BlockEntity be = level.getBlockEntity(chestPos);
+ if (!(be instanceof ChestBlockEntity chest)) continue;
+
+ // Try to stack with existing items first
+ for (int i = 0; i < chest.getContainerSize(); i++) {
+ ItemStack slot = chest.getItem(i);
+
+ // Stack with existing
+ if (
+ !slot.isEmpty() &&
+ ItemStack.isSameItemSameTags(slot, stack) &&
+ slot.getCount() < slot.getMaxStackSize()
+ ) {
+ int spaceInSlot =
+ slot.getMaxStackSize() - slot.getCount();
+ int toAdd = Math.min(spaceInSlot, stack.getCount());
+
+ slot.grow(toAdd);
+ chest.setChanged();
+ stack.shrink(toAdd);
+ deposited += toAdd;
+
+ if (stack.isEmpty()) {
+ placed = true;
+ break;
+ }
+ }
+ }
+
+ if (placed) break;
+
+ // Try to place in empty slot
+ if (!stack.isEmpty()) {
+ for (int i = 0; i < chest.getContainerSize(); i++) {
+ if (chest.getItem(i).isEmpty()) {
+ chest.setItem(i, stack.copy());
+ chest.setChanged();
+ deposited += stack.getCount();
+ placed = true;
+ break;
+ }
+ }
+ }
+
+ if (placed) break;
+ }
+
+ // If not placed, add to overflow
+ if (!placed && !stack.isEmpty()) {
+ overflow.add(stack);
+ }
+ }
+
+ // Drop overflow items at first chest location
+ if (!overflow.isEmpty() && !chestPositions.isEmpty()) {
+ BlockPos dropPos = chestPositions.get(0);
+ for (ItemStack stack : overflow) {
+ net.minecraft.world.entity.item.ItemEntity itemEntity =
+ new net.minecraft.world.entity.item.ItemEntity(
+ level,
+ dropPos.getX() + 0.5,
+ dropPos.getY() + 1.0,
+ dropPos.getZ() + 0.5,
+ stack
+ );
+ level.addFreshEntity(itemEntity);
+ }
+
+ TiedUpMod.LOGGER.warn(
+ "[ConfiscatedInventoryRegistry] {} items overflowed - dropped at {}",
+ overflow.stream().mapToInt(ItemStack::getCount).sum(),
+ dropPos.toShortString()
+ );
+ }
+
+ return deposited;
+ }
+
+ /**
+ * Save player inventory to NBT.
+ */
+ private CompoundTag savePlayerInventory(ServerPlayer player) {
+ CompoundTag tag = new CompoundTag();
+ tag.put("Items", player.getInventory().save(new ListTag()));
+ return tag;
+ }
+
+ /**
+ * Transfer inventory contents to a chest.
+ * Handles ALL inventory slots:
+ * - Slots 0-35: Main inventory (hotbar 0-8, backpack 9-35)
+ * - Slots 36-39: Armor (boots, leggings, chestplate, helmet)
+ * - Slot 40: Offhand
+ */
+ private boolean transferToChest(
+ ServerLevel level,
+ BlockPos chestPos,
+ Inventory inventory
+ ) {
+ // Find an existing chest near the LOOT marker position
+ BlockPos actualChestPos = findExistingChestNear(level, chestPos);
+ if (actualChestPos == null) {
+ TiedUpMod.LOGGER.warn(
+ "[ConfiscatedInventoryRegistry] No existing chest found near {} - structure may be damaged",
+ chestPos.toShortString()
+ );
+ return false;
+ }
+
+ BlockEntity be = level.getBlockEntity(actualChestPos);
+ if (!(be instanceof Container chest)) {
+ TiedUpMod.LOGGER.warn(
+ "[ConfiscatedInventoryRegistry] Block at {} is not a container",
+ actualChestPos.toShortString()
+ );
+ return false;
+ }
+
+ // Transfer ALL items including armor and offhand
+ int mainItems = 0;
+ int armorItems = 0;
+ int offhandItems = 0;
+ int droppedItems = 0;
+
+ // getContainerSize() returns 41: 36 main + 4 armor + 1 offhand
+ for (int i = 0; i < inventory.getContainerSize(); i++) {
+ ItemStack stack = inventory.getItem(i);
+ if (!stack.isEmpty()) {
+ ItemStack remaining = addToContainer(chest, stack.copy());
+ if (remaining.isEmpty()) {
+ // Track which slot type was transferred
+ if (i < 36) {
+ mainItems++;
+ } else if (i < 40) {
+ armorItems++;
+ } else {
+ offhandItems++;
+ }
+ } else {
+ // Couldn't fit all items - drop them on the ground near the chest
+ // This prevents permanent item loss
+ net.minecraft.world.entity.item.ItemEntity itemEntity =
+ new net.minecraft.world.entity.item.ItemEntity(
+ level,
+ actualChestPos.getX() + 0.5,
+ actualChestPos.getY() + 1.0,
+ actualChestPos.getZ() + 0.5,
+ remaining
+ );
+ // Add slight random velocity to spread items
+ itemEntity.setDeltaMovement(
+ (level.random.nextDouble() - 0.5) * 0.2,
+ 0.2,
+ (level.random.nextDouble() - 0.5) * 0.2
+ );
+ level.addFreshEntity(itemEntity);
+ droppedItems += remaining.getCount();
+ }
+ }
+ }
+
+ if (droppedItems > 0) {
+ TiedUpMod.LOGGER.info(
+ "[ConfiscatedInventoryRegistry] Dropped {} overflow items at {}",
+ droppedItems,
+ actualChestPos.toShortString()
+ );
+ }
+
+ int totalTransferred = mainItems + armorItems + offhandItems;
+ TiedUpMod.LOGGER.info(
+ "[ConfiscatedInventoryRegistry] Confiscated {} items (inventory: {}, armor: {}, offhand: {})",
+ totalTransferred,
+ mainItems,
+ armorItems,
+ offhandItems
+ );
+
+ return true;
+ }
+
+ /**
+ * Add an item stack to a container, returning any remainder.
+ */
+ private ItemStack addToContainer(Container container, ItemStack stack) {
+ ItemStack remaining = stack.copy();
+
+ for (
+ int i = 0;
+ i < container.getContainerSize() && !remaining.isEmpty();
+ i++
+ ) {
+ ItemStack slotStack = container.getItem(i);
+
+ if (slotStack.isEmpty()) {
+ container.setItem(i, remaining.copy());
+ remaining = ItemStack.EMPTY;
+ } else if (ItemStack.isSameItemSameTags(slotStack, remaining)) {
+ int space = slotStack.getMaxStackSize() - slotStack.getCount();
+ int toTransfer = Math.min(space, remaining.getCount());
+ if (toTransfer > 0) {
+ slotStack.grow(toTransfer);
+ remaining.shrink(toTransfer);
+ }
+ }
+ }
+
+ container.setChanged();
+ return remaining;
+ }
+
+ /**
+ * Find an existing chest near the given position (typically a LOOT marker).
+ * The LOOT marker is placed ABOVE the physical chest in the structure,
+ * so we check below first, then at the position, then in a small radius.
+ *
+ * Does NOT spawn new chests - structures must have chests placed by their templates.
+ *
+ * @return The position of an existing chest, or null if none found
+ */
+ @Nullable
+ private static BlockPos findExistingChestNear(
+ ServerLevel level,
+ BlockPos pos
+ ) {
+ // Check below the marker (chest is usually under the LOOT marker)
+ BlockPos below = pos.below();
+ if (level.getBlockEntity(below) instanceof ChestBlockEntity) {
+ return below;
+ }
+
+ // Check at the marker position itself
+ if (level.getBlockEntity(pos) instanceof ChestBlockEntity) {
+ return pos;
+ }
+
+ // Search in a small radius (3 blocks)
+ for (int radius = 1; radius <= 3; radius++) {
+ for (int dx = -radius; dx <= radius; dx++) {
+ for (int dy = -2; dy <= 1; dy++) {
+ for (int dz = -radius; dz <= radius; dz++) {
+ if (dx == 0 && dy == 0 && dz == 0) continue;
+
+ BlockPos testPos = pos.offset(dx, dy, dz);
+ if (
+ level.getBlockEntity(testPos) instanceof
+ ChestBlockEntity
+ ) {
+ TiedUpMod.LOGGER.debug(
+ "[ConfiscatedInventoryRegistry] Found existing chest at {} (offset from marker {})",
+ testPos.toShortString(),
+ pos.toShortString()
+ );
+ return testPos;
+ }
+ }
+ }
+ }
+ }
+
+ TiedUpMod.LOGGER.warn(
+ "[ConfiscatedInventoryRegistry] No existing chest found near marker at {}",
+ pos.toShortString()
+ );
+ return null;
+ }
+
+ // ==================== RECOVERY METHODS ====================
+
+ /**
+ * Check if a player has confiscated inventory.
+ */
+ public boolean hasConfiscatedInventory(UUID playerId) {
+ return confiscatedInventories.containsKey(playerId);
+ }
+
+ /**
+ * Get confiscated data for a player.
+ */
+ @Nullable
+ public ConfiscatedData getConfiscatedData(UUID playerId) {
+ return confiscatedInventories.get(playerId);
+ }
+
+ /**
+ * Get the chest position for a player's confiscated inventory.
+ */
+ @Nullable
+ public BlockPos getChestPosition(UUID playerId) {
+ ConfiscatedData data = confiscatedInventories.get(playerId);
+ return data != null ? data.chestPos : null;
+ }
+
+ /**
+ * Restore a player's confiscated inventory from the NBT backup.
+ * This gives items directly to the player, bypassing the chest.
+ *
+ * @param player The player to restore inventory to
+ * @return true if restoration was successful, false if no confiscated data found
+ */
+ public boolean restoreInventory(ServerPlayer player) {
+ ConfiscatedData data = confiscatedInventories.get(player.getUUID());
+ if (data == null) {
+ TiedUpMod.LOGGER.debug(
+ "[ConfiscatedInventoryRegistry] No confiscated inventory for {}",
+ player.getName().getString()
+ );
+ return false;
+ }
+
+ // Load inventory from NBT backup
+ if (data.inventoryNbt.contains("Items")) {
+ ListTag items = data.inventoryNbt.getList(
+ "Items",
+ Tag.TAG_COMPOUND
+ );
+ player.getInventory().load(items);
+
+ TiedUpMod.LOGGER.info(
+ "[ConfiscatedInventoryRegistry] Restored {} inventory slots to {}",
+ items.size(),
+ player.getName().getString()
+ );
+ }
+
+ // Remove the confiscation record
+ confiscatedInventories.remove(player.getUUID());
+ setDirty();
+
+ return true;
+ }
+
+ /**
+ * Remove the confiscation record for a player without restoring items.
+ * Used when the player has already retrieved items from the chest manually.
+ *
+ * @param playerId The player's UUID
+ */
+ public void clearConfiscationRecord(UUID playerId) {
+ if (confiscatedInventories.remove(playerId) != null) {
+ TiedUpMod.LOGGER.debug(
+ "[ConfiscatedInventoryRegistry] Cleared confiscation record for {}",
+ playerId.toString().substring(0, 8)
+ );
+ setDirty();
+ }
+ }
+
+ // ==================== SERIALIZATION ====================
+
+ @Override
+ public CompoundTag save(CompoundTag tag) {
+ ListTag list = new ListTag();
+ for (ConfiscatedData data : confiscatedInventories.values()) {
+ list.add(data.save());
+ }
+ tag.put("confiscated", list);
+ return tag;
+ }
+
+ public static ConfiscatedInventoryRegistry load(CompoundTag tag) {
+ ConfiscatedInventoryRegistry registry =
+ new ConfiscatedInventoryRegistry();
+
+ if (tag.contains("confiscated")) {
+ ListTag list = tag.getList("confiscated", Tag.TAG_COMPOUND);
+ for (int i = 0; i < list.size(); i++) {
+ ConfiscatedData data = ConfiscatedData.load(
+ list.getCompound(i)
+ );
+ registry.confiscatedInventories.put(data.prisonerId, data);
+ }
+ }
+
+ TiedUpMod.LOGGER.info(
+ "[ConfiscatedInventoryRegistry] Loaded {} confiscated inventory records",
+ registry.confiscatedInventories.size()
+ );
+
+ return registry;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java b/src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java
new file mode 100644
index 0000000..1bdd077
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java
@@ -0,0 +1,407 @@
+package com.tiedup.remake.cells;
+
+import java.util.*;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.*;
+import net.minecraft.world.level.block.state.BlockState;
+
+/**
+ * BFS flood-fill algorithm for detecting enclosed rooms around a Cell Core.
+ *
+ * Scans outward from air neighbors of the Core block, treating solid blocks
+ * (including the Core itself) as walls. Picks the smallest successful fill
+ * as the cell interior (most likely the room, not the hallway).
+ */
+public final class FloodFillAlgorithm {
+
+ static final int MAX_VOLUME = 1200;
+ static final int MIN_VOLUME = 2;
+ static final int MAX_X = 12;
+ static final int MAX_Y = 8;
+ static final int MAX_Z = 12;
+
+ private FloodFillAlgorithm() {}
+
+ /**
+ * Try flood-fill from each air neighbor of the Core position.
+ * Pick the smallest successful fill (= most likely the cell, not the hallway).
+ * If none succeed, return a failure result.
+ */
+ public static FloodFillResult tryFill(Level level, BlockPos corePos) {
+ Set bestInterior = null;
+ Direction bestDirection = null;
+
+ for (Direction dir : Direction.values()) {
+ BlockPos neighbor = corePos.relative(dir);
+ BlockState neighborState = level.getBlockState(neighbor);
+
+ if (!isPassable(neighborState)) {
+ continue;
+ }
+
+ Set interior = bfs(level, neighbor, corePos);
+ if (interior == null) {
+ // Overflow or out of bounds — this direction opens to the outside
+ continue;
+ }
+
+ if (interior.size() < MIN_VOLUME) {
+ continue;
+ }
+
+ if (bestInterior == null || interior.size() < bestInterior.size()) {
+ bestInterior = interior;
+ bestDirection = dir;
+ }
+ }
+
+ if (bestInterior == null) {
+ // No direction produced a valid fill — check why
+ // Try again to determine the most helpful error message
+ boolean anyAir = false;
+ boolean tooLarge = false;
+ boolean tooSmall = false;
+ boolean outOfBounds = false;
+
+ for (Direction dir : Direction.values()) {
+ BlockPos neighbor = corePos.relative(dir);
+ BlockState neighborState = level.getBlockState(neighbor);
+ if (!isPassable(neighborState)) continue;
+ anyAir = true;
+
+ Set result = bfsDiagnostic(level, neighbor, corePos);
+ if (result == null) {
+ // Overflowed — could be not enclosed or too large
+ tooLarge = true;
+ } else if (result.size() < MIN_VOLUME) {
+ tooSmall = true;
+ } else {
+ outOfBounds = true;
+ }
+ }
+
+ if (!anyAir) {
+ return FloodFillResult.failure(
+ "msg.tiedup.cell_core.not_enclosed"
+ );
+ } else if (tooLarge) {
+ // Could be open to outside or genuinely too large
+ return FloodFillResult.failure(
+ "msg.tiedup.cell_core.not_enclosed"
+ );
+ } else if (outOfBounds) {
+ return FloodFillResult.failure(
+ "msg.tiedup.cell_core.out_of_bounds"
+ );
+ } else if (tooSmall) {
+ return FloodFillResult.failure(
+ "msg.tiedup.cell_core.too_small"
+ );
+ } else {
+ return FloodFillResult.failure(
+ "msg.tiedup.cell_core.not_enclosed"
+ );
+ }
+ }
+
+ // Build walls set
+ Set walls = findWalls(level, bestInterior, corePos);
+
+ // Detect features
+ List beds = new ArrayList<>();
+ List petBeds = new ArrayList<>();
+ List anchors = new ArrayList<>();
+ List doors = new ArrayList<>();
+ List linkedRedstone = new ArrayList<>();
+ detectFeatures(
+ level,
+ bestInterior,
+ walls,
+ beds,
+ petBeds,
+ anchors,
+ doors,
+ linkedRedstone
+ );
+
+ return FloodFillResult.success(
+ bestInterior,
+ walls,
+ bestDirection,
+ beds,
+ petBeds,
+ anchors,
+ doors,
+ linkedRedstone
+ );
+ }
+
+ /**
+ * BFS from start position, treating corePos and solid blocks as walls.
+ *
+ * @return The set of interior (passable) positions, or null if the fill
+ * overflowed MAX_VOLUME or exceeded MAX bounds.
+ */
+ private static Set bfs(
+ Level level,
+ BlockPos start,
+ BlockPos corePos
+ ) {
+ Set visited = new HashSet<>();
+ Queue queue = new ArrayDeque<>();
+
+ visited.add(start);
+ queue.add(start);
+
+ int minX = start.getX(),
+ maxX = start.getX();
+ int minY = start.getY(),
+ maxY = start.getY();
+ int minZ = start.getZ(),
+ maxZ = start.getZ();
+
+ while (!queue.isEmpty()) {
+ BlockPos current = queue.poll();
+
+ for (Direction dir : Direction.values()) {
+ BlockPos next = current.relative(dir);
+
+ if (next.equals(corePos)) {
+ // Core is always treated as wall
+ continue;
+ }
+
+ if (visited.contains(next)) {
+ continue;
+ }
+
+ // Treat unloaded chunks as walls to avoid synchronous chunk loading
+ if (!level.isLoaded(next)) {
+ continue;
+ }
+
+ BlockState state = level.getBlockState(next);
+ if (!isPassable(state)) {
+ // Solid block = wall, don't expand
+ continue;
+ }
+
+ visited.add(next);
+
+ // Check volume
+ if (visited.size() > MAX_VOLUME) {
+ return null; // Too large or not enclosed
+ }
+
+ // Update bounds
+ minX = Math.min(minX, next.getX());
+ maxX = Math.max(maxX, next.getX());
+ minY = Math.min(minY, next.getY());
+ maxY = Math.max(maxY, next.getY());
+ minZ = Math.min(minZ, next.getZ());
+ maxZ = Math.max(maxZ, next.getZ());
+
+ // Check dimensional bounds
+ if (
+ (maxX - minX + 1) > MAX_X ||
+ (maxY - minY + 1) > MAX_Y ||
+ (maxZ - minZ + 1) > MAX_Z
+ ) {
+ return null; // Exceeds max dimensions
+ }
+
+ queue.add(next);
+ }
+ }
+
+ return visited;
+ }
+
+ /**
+ * Diagnostic BFS: same as bfs() but returns the set even on bounds overflow
+ * (returns null only on volume overflow). Used to determine error messages.
+ */
+ private static Set bfsDiagnostic(
+ Level level,
+ BlockPos start,
+ BlockPos corePos
+ ) {
+ Set visited = new HashSet<>();
+ Queue queue = new ArrayDeque<>();
+
+ visited.add(start);
+ queue.add(start);
+
+ while (!queue.isEmpty()) {
+ BlockPos current = queue.poll();
+
+ for (Direction dir : Direction.values()) {
+ BlockPos next = current.relative(dir);
+
+ if (next.equals(corePos) || visited.contains(next)) {
+ continue;
+ }
+
+ // Treat unloaded chunks as walls to avoid synchronous chunk loading
+ if (!level.isLoaded(next)) {
+ continue;
+ }
+
+ BlockState state = level.getBlockState(next);
+ if (!isPassable(state)) {
+ continue;
+ }
+
+ visited.add(next);
+
+ if (visited.size() > MAX_VOLUME) {
+ return null;
+ }
+
+ queue.add(next);
+ }
+ }
+
+ return visited;
+ }
+
+ /**
+ * Find all solid blocks adjacent to the interior set (the walls of the cell).
+ * The Core block itself is always included as a wall.
+ */
+ private static Set findWalls(
+ Level level,
+ Set interior,
+ BlockPos corePos
+ ) {
+ Set walls = new HashSet<>();
+ walls.add(corePos);
+
+ for (BlockPos pos : interior) {
+ for (Direction dir : Direction.values()) {
+ BlockPos neighbor = pos.relative(dir);
+ if (!interior.contains(neighbor) && !neighbor.equals(corePos)) {
+ // This is a solid boundary block
+ walls.add(neighbor);
+ }
+ }
+ }
+
+ return walls;
+ }
+
+ /**
+ * Scan interior and wall blocks to detect notable features.
+ */
+ private static void detectFeatures(
+ Level level,
+ Set interior,
+ Set walls,
+ List beds,
+ List petBeds,
+ List anchors,
+ List doors,
+ List linkedRedstone
+ ) {
+ // Scan interior for beds and pet beds
+ for (BlockPos pos : interior) {
+ BlockState state = level.getBlockState(pos);
+ Block block = state.getBlock();
+
+ if (block instanceof BedBlock) {
+ // Only count the HEAD part to avoid double-counting (beds are 2 blocks)
+ if (
+ state.getValue(BedBlock.PART) ==
+ net.minecraft.world.level.block.state.properties.BedPart.HEAD
+ ) {
+ beds.add(pos.immutable());
+ }
+ }
+
+ // Check for mod's pet bed block
+ if (block instanceof com.tiedup.remake.v2.blocks.PetBedBlock) {
+ petBeds.add(pos.immutable());
+ }
+ }
+
+ // Scan walls for doors, redstone components, and anchors
+ for (BlockPos pos : walls) {
+ BlockState state = level.getBlockState(pos);
+ Block block = state.getBlock();
+
+ // Doors, trapdoors, fence gates
+ if (block instanceof DoorBlock) {
+ // Only count the lower half to avoid double-counting
+ if (
+ state.getValue(DoorBlock.HALF) ==
+ net.minecraft.world.level.block.state.properties.DoubleBlockHalf.LOWER
+ ) {
+ doors.add(pos.immutable());
+ }
+ } else if (
+ block instanceof TrapDoorBlock ||
+ block instanceof FenceGateBlock
+ ) {
+ doors.add(pos.immutable());
+ }
+
+ // Chain blocks as anchors
+ if (block instanceof ChainBlock) {
+ anchors.add(pos.immutable());
+ }
+
+ // Buttons and levers as linked redstone
+ if (block instanceof ButtonBlock || block instanceof LeverBlock) {
+ linkedRedstone.add(pos.immutable());
+ }
+ }
+
+ // Also check for buttons/levers on the interior side adjacent to walls
+ for (BlockPos pos : interior) {
+ BlockState state = level.getBlockState(pos);
+ Block block = state.getBlock();
+
+ if (block instanceof ButtonBlock || block instanceof LeverBlock) {
+ linkedRedstone.add(pos.immutable());
+ }
+ }
+ }
+
+ /**
+ * Determine if a block state is passable for flood-fill purposes.
+ *
+ * Air and non-solid blocks (torches, carpets, flowers, signs, etc.) are passable.
+ * Closed doors block the fill (treated as walls). Open doors let fill through.
+ * Glass, bars, fences are solid → treated as wall.
+ */
+ private static boolean isPassable(BlockState state) {
+ if (state.isAir()) {
+ return true;
+ }
+
+ Block block = state.getBlock();
+
+ // Doors are always treated as walls for flood-fill (detected as features separately).
+ // This prevents the fill from leaking through open doors.
+ if (
+ block instanceof DoorBlock ||
+ block instanceof TrapDoorBlock ||
+ block instanceof FenceGateBlock
+ ) {
+ return false;
+ }
+
+ // Beds are interior furniture, not walls.
+ // BedBlock.isSolid() returns true in 1.20.1 which would misclassify them as walls,
+ // preventing detectFeatures() from finding them (it only scans interior for beds).
+ if (block instanceof BedBlock) {
+ return true;
+ }
+
+ // Non-solid decorative blocks are passable
+ // This covers torches, carpets, flowers, signs, pressure plates, etc.
+ return !state.isSolid();
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/FloodFillResult.java b/src/main/java/com/tiedup/remake/cells/FloodFillResult.java
new file mode 100644
index 0000000..4b2fdef
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/FloodFillResult.java
@@ -0,0 +1,140 @@
+package com.tiedup.remake.cells;
+
+import java.util.*;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Immutable result container returned by the flood-fill algorithm.
+ *
+ * Either a success (with geometry and detected features) or a failure (with an error translation key).
+ */
+public class FloodFillResult {
+
+ private final boolean success;
+
+ @Nullable
+ private final String errorKey;
+
+ // Geometry
+ private final Set interior;
+ private final Set walls;
+
+ @Nullable
+ private final Direction interiorFace;
+
+ // Auto-detected features
+ private final List beds;
+ private final List petBeds;
+ private final List anchors;
+ private final List doors;
+ private final List linkedRedstone;
+
+ private FloodFillResult(
+ boolean success,
+ @Nullable String errorKey,
+ Set interior,
+ Set walls,
+ @Nullable Direction interiorFace,
+ List beds,
+ List petBeds,
+ List anchors,
+ List doors,
+ List linkedRedstone
+ ) {
+ this.success = success;
+ this.errorKey = errorKey;
+ this.interior = Collections.unmodifiableSet(interior);
+ this.walls = Collections.unmodifiableSet(walls);
+ this.interiorFace = interiorFace;
+ this.beds = Collections.unmodifiableList(beds);
+ this.petBeds = Collections.unmodifiableList(petBeds);
+ this.anchors = Collections.unmodifiableList(anchors);
+ this.doors = Collections.unmodifiableList(doors);
+ this.linkedRedstone = Collections.unmodifiableList(linkedRedstone);
+ }
+
+ public static FloodFillResult success(
+ Set interior,
+ Set walls,
+ Direction interiorFace,
+ List beds,
+ List petBeds,
+ List anchors,
+ List doors,
+ List linkedRedstone
+ ) {
+ return new FloodFillResult(
+ true,
+ null,
+ interior,
+ walls,
+ interiorFace,
+ beds,
+ petBeds,
+ anchors,
+ doors,
+ linkedRedstone
+ );
+ }
+
+ public static FloodFillResult failure(String errorKey) {
+ return new FloodFillResult(
+ false,
+ errorKey,
+ Collections.emptySet(),
+ Collections.emptySet(),
+ null,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList()
+ );
+ }
+
+ // --- Getters ---
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ @Nullable
+ public String getErrorKey() {
+ return errorKey;
+ }
+
+ public Set getInterior() {
+ return interior;
+ }
+
+ public Set getWalls() {
+ return walls;
+ }
+
+ @Nullable
+ public Direction getInteriorFace() {
+ return interiorFace;
+ }
+
+ public List getBeds() {
+ return beds;
+ }
+
+ public List getPetBeds() {
+ return petBeds;
+ }
+
+ public List getAnchors() {
+ return anchors;
+ }
+
+ public List getDoors() {
+ return doors;
+ }
+
+ public List getLinkedRedstone() {
+ return linkedRedstone;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/MarkerType.java b/src/main/java/com/tiedup/remake/cells/MarkerType.java
new file mode 100644
index 0000000..af786a2
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/MarkerType.java
@@ -0,0 +1,161 @@
+package com.tiedup.remake.cells;
+
+/**
+ * Enum defining the types of markers used in the cell system.
+ *
+ * Phase: Kidnapper Revamp - Cell System
+ *
+ * Markers are invisible points placed by structure builders to define
+ * functional areas within kidnapper hideouts.
+ */
+public enum MarkerType {
+ // ==================== CELL MARKERS (V1 legacy — kept for retrocompat) ====================
+
+ /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
+ @Deprecated
+ WALL("wall", true),
+
+ /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
+ @Deprecated
+ ANCHOR("anchor", true),
+
+ /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
+ @Deprecated
+ BED("bed", true),
+
+ /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
+ @Deprecated
+ DOOR("door", true),
+
+ /** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
+ @Deprecated
+ DELIVERY("delivery", true),
+
+ // ==================== STRUCTURE MARKERS (Admin Wand) ====================
+
+ /**
+ * Entrance marker - Main entry point to the structure.
+ * Used for AI pathfinding and player release point.
+ */
+ ENTRANCE("entrance", false),
+
+ /**
+ * Patrol marker - Waypoint for kidnapper AI patrol routes.
+ * Guards will walk between these points.
+ */
+ PATROL("patrol", false),
+
+ /**
+ * Loot marker - Position for loot chests in structures.
+ * Used for confiscated inventory storage.
+ */
+ LOOT("loot", false),
+
+ /**
+ * Spawner marker - Position for kidnapper spawns.
+ * Kidnappers respawn at these points.
+ */
+ SPAWNER("spawner", false),
+
+ /**
+ * Trader spawn marker - Position for SlaveTrader spawn.
+ * Only spawns once when structure generates.
+ */
+ TRADER_SPAWN("trader_spawn", false),
+
+ /**
+ * Maid spawn marker - Position for Maid spawn.
+ * Spawns linked to the nearest trader.
+ */
+ MAID_SPAWN("maid_spawn", false),
+
+ /**
+ * Merchant spawn marker - Position for Merchant spawn.
+ * Spawns a merchant NPC that can trade/buy items.
+ */
+ MERCHANT_SPAWN("merchant_spawn", false);
+
+ private final String serializedName;
+ private final boolean cellMarker;
+
+ MarkerType(String serializedName, boolean cellMarker) {
+ this.serializedName = serializedName;
+ this.cellMarker = cellMarker;
+ }
+
+ /**
+ * Get the serialized name for NBT/network storage.
+ */
+ public String getSerializedName() {
+ return serializedName;
+ }
+
+ /**
+ * Check if this is a cell-level marker (vs structure-level).
+ */
+ public boolean isCellMarker() {
+ return cellMarker;
+ }
+
+ /**
+ * Check if this is a structure-level marker.
+ */
+ public boolean isStructureMarker() {
+ return !cellMarker;
+ }
+
+ /**
+ * Check if this marker type should be linked to a cell's positions.
+ * This includes WALL, ANCHOR, BED, DOOR - positions that define cell structure.
+ */
+ public boolean isLinkedPosition() {
+ return cellMarker;
+ }
+
+ /**
+ * Parse a MarkerType from its serialized name.
+ *
+ * @param name The serialized name
+ * @return The MarkerType, or WALL as default
+ */
+ public static MarkerType fromString(String name) {
+ for (MarkerType type : values()) {
+ if (type.serializedName.equalsIgnoreCase(name)) {
+ return type;
+ }
+ }
+ return ENTRANCE;
+ }
+
+ /**
+ * Get the next STRUCTURE marker type (for Admin Wand).
+ * Cycles: ENTRANCE -> PATROL -> LOOT -> SPAWNER -> TRADER_SPAWN -> MAID_SPAWN -> MERCHANT_SPAWN -> ENTRANCE
+ */
+ public MarkerType nextStructureType() {
+ return switch (this) {
+ case ENTRANCE -> PATROL;
+ case PATROL -> LOOT;
+ case LOOT -> SPAWNER;
+ case SPAWNER -> TRADER_SPAWN;
+ case TRADER_SPAWN -> MAID_SPAWN;
+ case MAID_SPAWN -> MERCHANT_SPAWN;
+ case MERCHANT_SPAWN -> ENTRANCE;
+ default -> ENTRANCE;
+ };
+ }
+
+ /**
+ * Get all structure marker types.
+ */
+ public static MarkerType[] structureTypes() {
+ return new MarkerType[] {
+ ENTRANCE,
+ PATROL,
+ LOOT,
+ SPAWNER,
+ TRADER_SPAWN,
+ MAID_SPAWN,
+ MERCHANT_SPAWN,
+ };
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/cells/SelectionMode.java b/src/main/java/com/tiedup/remake/cells/SelectionMode.java
new file mode 100644
index 0000000..6d5acaf
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/cells/SelectionMode.java
@@ -0,0 +1,12 @@
+package com.tiedup.remake.cells;
+
+/**
+ * Selection modes for the Cell Core right-click menu.
+ * When a player clicks "Set Spawn", "Set Delivery", or "Set Disguise",
+ * they enter a selection mode where their next block click is captured.
+ */
+public enum SelectionMode {
+ SET_SPAWN,
+ SET_DELIVERY,
+ SET_DISGUISE,
+}
diff --git a/src/main/java/com/tiedup/remake/client/FirstPersonMittensRenderer.java b/src/main/java/com/tiedup/remake/client/FirstPersonMittensRenderer.java
new file mode 100644
index 0000000..75aa239
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/FirstPersonMittensRenderer.java
@@ -0,0 +1,155 @@
+package com.tiedup.remake.client;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import com.tiedup.remake.core.TiedUpMod;
+import com.tiedup.remake.items.GenericBind;
+import com.tiedup.remake.items.base.BindVariant;
+import com.tiedup.remake.state.PlayerBindState;
+import com.tiedup.remake.v2.BodyRegionV2;
+import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.model.PlayerModel;
+import net.minecraft.client.model.geom.ModelPart;
+import net.minecraft.client.player.AbstractClientPlayer;
+import net.minecraft.client.renderer.MultiBufferSource;
+import net.minecraft.client.renderer.RenderType;
+import net.minecraft.client.renderer.entity.player.PlayerRenderer;
+import net.minecraft.client.renderer.texture.OverlayTexture;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.HumanoidArm;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+import net.minecraftforge.client.event.RenderArmEvent;
+import net.minecraftforge.eventbus.api.SubscribeEvent;
+import net.minecraftforge.fml.common.Mod;
+
+/**
+ * Renders mittens on the player's arms in first-person view.
+ *
+ * Uses RenderArmEvent which fires specifically when a player's arm
+ * is being rendered in first person. This is more targeted than RenderHandEvent.
+ *
+ * @see RenderArmEvent Documentation
+ */
+@OnlyIn(Dist.CLIENT)
+@Mod.EventBusSubscriber(
+ modid = TiedUpMod.MOD_ID,
+ bus = Mod.EventBusSubscriber.Bus.FORGE,
+ value = Dist.CLIENT
+)
+public class FirstPersonMittensRenderer {
+
+ private static final ResourceLocation MITTENS_TEXTURE =
+ ResourceLocation.fromNamespaceAndPath(
+ TiedUpMod.MOD_ID,
+ "textures/models/bondage/mittens/mittens.png"
+ );
+
+ /**
+ * Render mittens overlay on the player's arm in first-person view.
+ *
+ * This event fires after the arm is set up for rendering but we can add
+ * our own rendering on top of it.
+ */
+ @SubscribeEvent
+ public static void onRenderArm(RenderArmEvent event) {
+ AbstractClientPlayer player = event.getPlayer();
+
+ // Get player's bind state
+ PlayerBindState state = PlayerBindState.getInstance(player);
+ if (state == null) return;
+
+ // If tied up, arms are hidden by FirstPersonHandHideHandler - don't render mittens
+ if (state.isTiedUp()) return;
+
+ // Check if player has mittens
+ if (!state.hasMittens()) return;
+
+ // Hide mittens when player is in a wrap or latex sack (hands are covered)
+ if (isBindHidingMittens(player)) return;
+
+ // Render mittens on this arm
+ renderMittensOnArm(event);
+ }
+
+ /**
+ * Render the mittens overlay on the arm.
+ */
+ private static void renderMittensOnArm(RenderArmEvent event) {
+ PoseStack poseStack = event.getPoseStack();
+ MultiBufferSource buffer = event.getMultiBufferSource();
+ int packedLight = event.getPackedLight();
+ AbstractClientPlayer player = event.getPlayer();
+ HumanoidArm arm = event.getArm();
+
+ // Get the player's model to access the arm ModelPart
+ Minecraft mc = Minecraft.getInstance();
+ var renderer = mc.getEntityRenderDispatcher().getRenderer(player);
+ if (!(renderer instanceof PlayerRenderer playerRenderer)) return;
+
+ PlayerModel playerModel =
+ playerRenderer.getModel();
+
+ poseStack.pushPose();
+
+ // Get the appropriate arm from the player model
+ ModelPart armPart = (arm == HumanoidArm.RIGHT)
+ ? playerModel.rightArm
+ : playerModel.leftArm;
+ ModelPart sleevePart = (arm == HumanoidArm.RIGHT)
+ ? playerModel.rightSleeve
+ : playerModel.leftSleeve;
+
+ // The arm is already positioned by the game's first-person renderer
+ // We just need to render our mittens texture on top
+
+ // Use a slightly scaled version to appear on top (avoid z-fighting)
+ poseStack.scale(1.001F, 1.001F, 1.001F);
+
+ // Render the arm with mittens texture
+ VertexConsumer vertexConsumer = buffer.getBuffer(
+ RenderType.entitySolid(MITTENS_TEXTURE)
+ );
+
+ // Render the arm part with mittens texture
+ armPart.render(
+ poseStack,
+ vertexConsumer,
+ packedLight,
+ OverlayTexture.NO_OVERLAY
+ );
+
+ // Also render the sleeve part if visible
+ if (sleevePart.visible) {
+ sleevePart.render(
+ poseStack,
+ vertexConsumer,
+ packedLight,
+ OverlayTexture.NO_OVERLAY
+ );
+ }
+
+ poseStack.popPose();
+ }
+
+ /**
+ * Check if the player's current bind variant hides mittens.
+ * WRAP and LATEX_SACK cover the entire body including hands.
+ */
+ private static boolean isBindHidingMittens(AbstractClientPlayer player) {
+ net.minecraft.world.item.ItemStack bindStack =
+ V2EquipmentHelper.getInRegion(
+ player,
+ BodyRegionV2.ARMS
+ );
+ if (bindStack.isEmpty()) return false;
+ if (bindStack.getItem() instanceof GenericBind bind) {
+ BindVariant variant = bind.getVariant();
+ return (
+ variant == BindVariant.WRAP || variant == BindVariant.LATEX_SACK
+ );
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/ModKeybindings.java b/src/main/java/com/tiedup/remake/client/ModKeybindings.java
new file mode 100644
index 0000000..7c23660
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/ModKeybindings.java
@@ -0,0 +1,446 @@
+package com.tiedup.remake.client;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
+import com.tiedup.remake.client.gui.screens.AdjustmentScreen;
+import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen;
+import com.tiedup.remake.items.base.ItemCollar;
+import org.jetbrains.annotations.Nullable;
+import com.tiedup.remake.core.ModConfig;
+import com.tiedup.remake.core.TiedUpMod;
+import com.tiedup.remake.items.base.ILockable;
+import com.tiedup.remake.network.ModNetwork;
+import com.tiedup.remake.network.action.PacketForceSeatModifier;
+import com.tiedup.remake.network.action.PacketStruggle;
+import com.tiedup.remake.network.action.PacketTighten;
+import com.tiedup.remake.network.bounty.PacketRequestBounties;
+import com.tiedup.remake.v2.BodyRegionV2;
+import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
+import com.tiedup.remake.state.PlayerBindState;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.KeyMapping;
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
+import net.minecraftforge.event.TickEvent;
+import net.minecraftforge.eventbus.api.SubscribeEvent;
+import net.minecraftforge.fml.common.Mod;
+
+/**
+ * Phase 7: Client-side keybindings for TiedUp mod.
+ *
+ * Manages key mappings and sends packets to server when keys are pressed.
+ *
+ * Based on original KeyBindings from 1.12.2
+ */
+@Mod.EventBusSubscriber(
+ modid = TiedUpMod.MOD_ID,
+ bus = Mod.EventBusSubscriber.Bus.FORGE,
+ value = Dist.CLIENT
+)
+public class ModKeybindings {
+
+ /**
+ * Key category for TiedUp keybindings
+ */
+ private static final String CATEGORY = "key.categories.tiedup";
+
+ /**
+ * Struggle keybinding - Press to struggle against binds
+ * Default: R key
+ */
+ public static final KeyMapping STRUGGLE_KEY = new KeyMapping(
+ "key.tiedup.struggle", // Translation key
+ InputConstants.Type.KEYSYM,
+ InputConstants.KEY_R, // Default key: R
+ CATEGORY
+ );
+
+ /**
+ * Adjustment screen keybinding - Open item adjustment screen
+ * Default: K key
+ */
+ public static final KeyMapping ADJUSTMENT_KEY = new KeyMapping(
+ "key.tiedup.adjustment_screen",
+ InputConstants.Type.KEYSYM,
+ InputConstants.KEY_K, // Default key: K
+ CATEGORY
+ );
+
+ /**
+ * Bondage inventory keybinding - Open bondage inventory screen
+ * Default: J key
+ */
+ public static final KeyMapping INVENTORY_KEY = new KeyMapping(
+ "key.tiedup.bondage_inventory",
+ InputConstants.Type.KEYSYM,
+ InputConstants.KEY_J, // Default key: J
+ CATEGORY
+ );
+
+ /**
+ * Slave management keybinding - Open slave management dashboard
+ * Default: L key
+ */
+ public static final KeyMapping SLAVE_MANAGEMENT_KEY = new KeyMapping(
+ "key.tiedup.slave_management",
+ InputConstants.Type.KEYSYM,
+ InputConstants.KEY_L, // Default key: L
+ CATEGORY
+ );
+
+ /**
+ * Bounty list keybinding - Open bounty list screen
+ * Default: B key
+ */
+ public static final KeyMapping BOUNTY_KEY = new KeyMapping(
+ "key.tiedup.bounties",
+ InputConstants.Type.KEYSYM,
+ InputConstants.KEY_B, // Default key: B
+ CATEGORY
+ );
+
+ /**
+ * Force seat keybinding - Hold to force captive on/off vehicles
+ * Default: Left ALT key
+ */
+ public static final KeyMapping FORCE_SEAT_KEY = new KeyMapping(
+ "key.tiedup.force_seat",
+ InputConstants.Type.KEYSYM,
+ InputConstants.KEY_LALT, // Default key: Left ALT
+ CATEGORY
+ );
+
+ /**
+ * Tighten bind keybinding - Tighten binds on looked-at target
+ * Default: T key
+ */
+ public static final KeyMapping TIGHTEN_KEY = new KeyMapping(
+ "key.tiedup.tighten",
+ InputConstants.Type.KEYSYM,
+ InputConstants.KEY_T, // Default key: T
+ CATEGORY
+ );
+
+ /** Track last sent state to avoid spamming packets */
+ private static boolean lastForceSeatState = false;
+
+ /**
+ * Check if Force Seat key is currently pressed.
+ */
+ public static boolean isForceSeatPressed() {
+ return FORCE_SEAT_KEY.isDown();
+ }
+
+ /**
+ * Register keybindings.
+ * Called during mod initialization (MOD bus).
+ *
+ * @param event The registration event
+ */
+ public static void register(RegisterKeyMappingsEvent event) {
+ event.register(STRUGGLE_KEY);
+ event.register(ADJUSTMENT_KEY);
+ event.register(INVENTORY_KEY);
+ event.register(SLAVE_MANAGEMENT_KEY);
+ event.register(BOUNTY_KEY);
+ event.register(FORCE_SEAT_KEY);
+ event.register(TIGHTEN_KEY);
+ TiedUpMod.LOGGER.info("Registered {} keybindings", 7);
+ }
+
+ // ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ====================
+
+ /**
+ * Get the vanilla movement keybind for a given direction index.
+ * Uses Minecraft's movement keys so AZERTY/QWERTY is already configured.
+ * @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
+ * @return The keybind or null if invalid index
+ */
+ public static KeyMapping getStruggleDirectionKey(int index) {
+ Minecraft mc = Minecraft.getInstance();
+ if (mc.options == null) return null;
+ return switch (index) {
+ case 0 -> mc.options.keyUp; // Forward (W/Z)
+ case 1 -> mc.options.keyLeft; // Strafe Left (A/Q)
+ case 2 -> mc.options.keyDown; // Back (S)
+ case 3 -> mc.options.keyRight; // Strafe Right (D)
+ default -> null;
+ };
+ }
+
+ /**
+ * Check if a keycode matches any vanilla movement keybind.
+ * @param keyCode The GLFW key code
+ * @return The direction index (0-3) or -1 if not a movement key
+ */
+ public static int getStruggleDirectionFromKeyCode(int keyCode) {
+ Minecraft mc = Minecraft.getInstance();
+ if (mc.options == null) return -1;
+ if (mc.options.keyUp.matches(keyCode, 0)) return 0;
+ if (mc.options.keyLeft.matches(keyCode, 0)) return 1;
+ if (mc.options.keyDown.matches(keyCode, 0)) return 2;
+ if (mc.options.keyRight.matches(keyCode, 0)) return 3;
+ return -1;
+ }
+
+ /**
+ * Get the display name of a vanilla movement key.
+ * Shows the actual bound key (W for QWERTY, Z for AZERTY, etc.)
+ * @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
+ * @return The key's display name
+ */
+ public static String getStruggleDirectionKeyName(int index) {
+ KeyMapping key = getStruggleDirectionKey(index);
+ if (key == null) return "?";
+ return key.getTranslatedKeyMessage().getString().toUpperCase();
+ }
+
+ /**
+ * Handle key presses on client tick.
+ * Called every client tick (FORGE bus).
+ *
+ * @param event The tick event
+ */
+ @SubscribeEvent
+ public static void onClientTick(TickEvent.ClientTickEvent event) {
+ // Only run at end of tick
+ if (event.phase != TickEvent.Phase.END) {
+ return;
+ }
+
+ Minecraft mc = Minecraft.getInstance();
+ if (mc.player == null || mc.level == null) {
+ return;
+ }
+
+ // Sync Force Seat keybind state to server (only send on change)
+ boolean currentForceSeatState = isForceSeatPressed();
+ if (currentForceSeatState != lastForceSeatState) {
+ lastForceSeatState = currentForceSeatState;
+ ModNetwork.sendToServer(
+ new PacketForceSeatModifier(currentForceSeatState)
+ );
+ }
+
+ // Check struggle key - Phase 21: Flow based on bind/accessories
+ while (STRUGGLE_KEY.consumeClick()) {
+ handleStruggleKey();
+ }
+
+ // Check adjustment screen key
+ while (ADJUSTMENT_KEY.consumeClick()) {
+ // Only open if not already in a screen and player has adjustable items
+ if (mc.screen == null && AdjustmentScreen.canOpen()) {
+ mc.setScreen(new AdjustmentScreen());
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] Adjustment key pressed - opening screen"
+ );
+ }
+ }
+
+ // Check bondage inventory key - opens UnifiedBondageScreen in SELF or MASTER mode
+ while (INVENTORY_KEY.consumeClick()) {
+ if (mc.screen == null) {
+ LivingEntity masterTarget = findOwnedCollarTarget(mc.player);
+ if (masterTarget != null) {
+ mc.setScreen(new UnifiedBondageScreen(masterTarget));
+ } else {
+ mc.setScreen(new UnifiedBondageScreen());
+ }
+ }
+ }
+
+ // SLAVE_MANAGEMENT_KEY: now handled by [J] with master mode detection (see above)
+ while (SLAVE_MANAGEMENT_KEY.consumeClick()) {
+ // consumed but no-op — kept registered to avoid key conflict during transition
+ }
+
+ // Check bounty list key
+ while (BOUNTY_KEY.consumeClick()) {
+ // Request bounty list from server (server will open the screen)
+ if (mc.screen == null) {
+ ModNetwork.sendToServer(new PacketRequestBounties());
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] Bounty key pressed - requesting bounty list"
+ );
+ }
+ }
+
+ // Check tighten key
+ while (TIGHTEN_KEY.consumeClick()) {
+ // Send tighten packet to server (server finds target)
+ if (mc.screen == null) {
+ ModNetwork.sendToServer(new PacketTighten());
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] Tighten key pressed - sending tighten request"
+ );
+ }
+ }
+ }
+
+ /**
+ * Phase 21: Handle struggle key press with new flow.
+ *
+ * Flow:
+ * 1. If bind equipped: Send PacketStruggle to server (struggle against bind)
+ * 2. If no bind: Check for locked accessories
+ * - If locked accessories exist: Open StruggleChoiceScreen
+ * - If no locked accessories: Show "Nothing to struggle" message
+ */
+ private static void handleStruggleKey() {
+ Minecraft mc = Minecraft.getInstance();
+ Player player = mc.player;
+ if (player == null || mc.screen != null) {
+ return;
+ }
+
+ // V2 path: check if player has V2 equipment to struggle against
+ if (com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.hasAnyEquipment(player)) {
+ handleV2Struggle(player);
+ return;
+ }
+
+ PlayerBindState state = PlayerBindState.getInstance(player);
+ if (state == null) {
+ return;
+ }
+
+ // Check if player has bind equipped
+ if (state.isTiedUp()) {
+ // Has bind - struggle against it
+ // Phase 2.5: Check if mini-game is enabled
+ if (ModConfig.SERVER.struggleMiniGameEnabled.get()) {
+ // New: Start struggle mini-game
+ ModNetwork.sendToServer(new PacketV2StruggleStart(BodyRegionV2.ARMS));
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] Struggle key pressed - starting V2 struggle mini-game"
+ );
+ } else {
+ // Legacy: Probability-based struggle
+ ModNetwork.sendToServer(new PacketStruggle());
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] Struggle key pressed - legacy struggle against bind"
+ );
+ }
+ return;
+ }
+
+ // No bind - check for locked accessories
+ boolean hasLockedAccessories = hasAnyLockedAccessory(player);
+
+ if (hasLockedAccessories) {
+ // Open UnifiedBondageScreen in self mode
+ mc.setScreen(new UnifiedBondageScreen());
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] Struggle key pressed - opening unified bondage screen"
+ );
+ } else {
+ // No locked accessories - show message
+ player.displayClientMessage(
+ Component.translatable("tiedup.struggle.nothing").withStyle(
+ ChatFormatting.GRAY
+ ),
+ true
+ );
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] Struggle key pressed - nothing to struggle"
+ );
+ }
+ }
+
+ /**
+ * Handle struggle key for V2 equipment.
+ * Auto-targets the highest posePriority item.
+ */
+ private static void handleV2Struggle(Player player) {
+ java.util.Map equipped =
+ com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getAllEquipped(player);
+
+ if (equipped.isEmpty()) return;
+
+ // Auto-target: find highest posePriority item
+ com.tiedup.remake.v2.BodyRegionV2 bestRegion = null;
+ int bestPriority = Integer.MIN_VALUE;
+
+ for (java.util.Map.Entry entry : equipped.entrySet()) {
+ ItemStack stack = entry.getValue();
+ if (stack.getItem() instanceof com.tiedup.remake.v2.bondage.IV2BondageItem item) {
+ if (item.getPosePriority(stack) > bestPriority) {
+ bestPriority = item.getPosePriority(stack);
+ bestRegion = entry.getKey();
+ }
+ }
+ }
+
+ if (bestRegion != null) {
+ ModNetwork.sendToServer(
+ new com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart(bestRegion)
+ );
+ TiedUpMod.LOGGER.debug(
+ "[CLIENT] V2 Struggle key pressed - targeting region {}",
+ bestRegion.name()
+ );
+ }
+ }
+
+ /**
+ * Check the crosshair entity: if it is a LivingEntity wearing a collar owned by the player,
+ * return it as the MASTER mode target. Returns null if no valid target.
+ */
+ @Nullable
+ private static LivingEntity findOwnedCollarTarget(Player player) {
+ if (player == null) return null;
+ Minecraft mc = Minecraft.getInstance();
+ net.minecraft.world.entity.Entity crosshair = mc.crosshairPickEntity;
+ if (crosshair instanceof LivingEntity living) {
+ return checkCollarOwnership(living, player) ? living : null;
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the given entity has a collar in the NECK region that lists the player as an owner.
+ */
+ private static boolean checkCollarOwnership(LivingEntity target, Player player) {
+ ItemStack collarStack = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion(
+ target, BodyRegionV2.NECK
+ );
+ if (!collarStack.isEmpty() && collarStack.getItem() instanceof ItemCollar collar) {
+ return collar.isOwner(collarStack, player);
+ }
+ return false;
+ }
+
+ /**
+ * Check if player has any locked accessories.
+ */
+ private static boolean hasAnyLockedAccessory(Player player) {
+ BodyRegionV2[] accessoryRegions = {
+ BodyRegionV2.MOUTH,
+ BodyRegionV2.EYES,
+ BodyRegionV2.EARS,
+ BodyRegionV2.NECK,
+ BodyRegionV2.TORSO,
+ BodyRegionV2.HANDS,
+ };
+
+ for (BodyRegionV2 region : accessoryRegions) {
+ ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
+ if (
+ !stack.isEmpty() &&
+ stack.getItem() instanceof ILockable lockable
+ ) {
+ if (lockable.isLocked(stack)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java b/src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java
new file mode 100644
index 0000000..a664171
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java
@@ -0,0 +1,118 @@
+package com.tiedup.remake.client;
+
+import net.minecraft.client.resources.sounds.Sound;
+import net.minecraft.client.resources.sounds.SoundInstance;
+import net.minecraft.client.resources.sounds.TickableSoundInstance;
+import net.minecraft.client.sounds.SoundManager;
+import net.minecraft.client.sounds.WeighedSoundEvents;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.sounds.SoundSource;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Wrapper around a SoundInstance that applies volume and pitch modifiers.
+ * Used for the earplugs muffling effect.
+ *
+ * This delegates all methods to the wrapped sound, but overrides
+ * getVolume() and getPitch() to apply modifiers.
+ */
+@OnlyIn(Dist.CLIENT)
+public class MuffledSoundInstance implements SoundInstance {
+
+ private final SoundInstance wrapped;
+ private final float volumeMultiplier;
+ private final float pitchMultiplier;
+
+ public MuffledSoundInstance(
+ SoundInstance wrapped,
+ float volumeMultiplier,
+ float pitchMultiplier
+ ) {
+ this.wrapped = wrapped;
+ this.volumeMultiplier = volumeMultiplier;
+ this.pitchMultiplier = pitchMultiplier;
+ }
+
+ @Override
+ public ResourceLocation getLocation() {
+ return wrapped.getLocation();
+ }
+
+ @Override
+ public WeighedSoundEvents resolve(SoundManager soundManager) {
+ return wrapped.resolve(soundManager);
+ }
+
+ @Override
+ public Sound getSound() {
+ return wrapped.getSound();
+ }
+
+ @Override
+ public SoundSource getSource() {
+ return wrapped.getSource();
+ }
+
+ @Override
+ public boolean isLooping() {
+ return wrapped.isLooping();
+ }
+
+ @Override
+ public boolean isRelative() {
+ return wrapped.isRelative();
+ }
+
+ @Override
+ public int getDelay() {
+ return wrapped.getDelay();
+ }
+
+ @Override
+ public float getVolume() {
+ // Apply muffling to volume
+ return wrapped.getVolume() * volumeMultiplier;
+ }
+
+ @Override
+ public float getPitch() {
+ // Apply muffling to pitch
+ return wrapped.getPitch() * pitchMultiplier;
+ }
+
+ @Override
+ public double getX() {
+ return wrapped.getX();
+ }
+
+ @Override
+ public double getY() {
+ return wrapped.getY();
+ }
+
+ @Override
+ public double getZ() {
+ return wrapped.getZ();
+ }
+
+ @Override
+ public Attenuation getAttenuation() {
+ return wrapped.getAttenuation();
+ }
+
+ /**
+ * Check if this is wrapping a tickable sound.
+ * Used to handle special cases.
+ */
+ public boolean isTickable() {
+ return wrapped instanceof TickableSoundInstance;
+ }
+
+ /**
+ * Get the wrapped sound instance.
+ */
+ public SoundInstance getWrapped() {
+ return wrapped;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java b/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java
new file mode 100644
index 0000000..bdfb1cd
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java
@@ -0,0 +1,60 @@
+package com.tiedup.remake.client.animation;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Central registry for player animation state tracking.
+ *
+ * Holds per-player state maps that were previously scattered across
+ * AnimationTickHandler. Provides a single clearAll() entry point for
+ * world unload cleanup.
+ */
+@OnlyIn(Dist.CLIENT)
+public final class AnimationStateRegistry {
+
+ /** Track last tied state per player */
+ static final Map lastTiedState = new ConcurrentHashMap<>();
+
+ /** Track last animation ID per player to avoid redundant updates */
+ static final Map lastAnimId = new ConcurrentHashMap<>();
+
+ private AnimationStateRegistry() {}
+
+ public static Map getLastTiedState() {
+ return lastTiedState;
+ }
+
+ public static Map getLastAnimId() {
+ return lastAnimId;
+ }
+
+ /**
+ * Clear all animation-related state in one call.
+ * Called on world unload to prevent memory leaks and stale data.
+ */
+ public static void clearAll() {
+ // Animation state tracking
+ lastTiedState.clear();
+ lastAnimId.clear();
+
+ // Animation managers
+ BondageAnimationManager.clearAll();
+ PendingAnimationManager.clearAll();
+
+ // V2 animation context system (clearAll chains to ContextAnimationFactory.clearCache)
+ com.tiedup.remake.client.gltf.GltfAnimationApplier.clearAll();
+
+ // Render state
+ com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
+
+ // NPC animation state
+ com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();
+
+ // MCA animation cache
+ com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.clear();
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java b/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java
new file mode 100644
index 0000000..8fe0e1b
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java
@@ -0,0 +1,737 @@
+package com.tiedup.remake.client.animation;
+
+import com.mojang.logging.LogUtils;
+import com.tiedup.remake.v2.furniture.ISeatProvider;
+import dev.kosmx.playerAnim.api.layered.IAnimation;
+import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
+import dev.kosmx.playerAnim.api.layered.ModifierLayer;
+import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
+import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationFactory;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import net.minecraft.client.player.AbstractClientPlayer;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.player.Player;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+import org.slf4j.Logger;
+
+/**
+ * Unified animation manager for bondage animations.
+ *
+ * Handles both players and NPCs (any entity implementing IAnimatedPlayer).
+ * Uses PlayerAnimator library for smooth keyframe animations with bendy-lib support.
+ *
+ *
This replaces the previous split system:
+ *
+ * - PlayerAnimatorBridge (for players)
+ * - DamselAnimationManager (for NPCs)
+ *
+ */
+@OnlyIn(Dist.CLIENT)
+public class BondageAnimationManager {
+
+ private static final Logger LOGGER = LogUtils.getLogger();
+
+ /** Cache of ModifierLayers for NPC entities (players use PlayerAnimationAccess) */
+ private static final Map> npcLayers =
+ new ConcurrentHashMap<>();
+
+ /** Cache of context ModifierLayers for NPC entities */
+ private static final Map> npcContextLayers =
+ new ConcurrentHashMap<>();
+
+ /** Cache of furniture ModifierLayers for NPC entities */
+ private static final Map> npcFurnitureLayers =
+ new ConcurrentHashMap<>();
+
+ /** Factory ID for PlayerAnimator item layer (players only) */
+ private static final ResourceLocation FACTORY_ID =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "bondage");
+
+ /** Factory ID for PlayerAnimator context layer (players only) */
+ private static final ResourceLocation CONTEXT_FACTORY_ID =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_context");
+
+ /** Factory ID for PlayerAnimator furniture layer (players only) */
+ private static final ResourceLocation FURNITURE_FACTORY_ID =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_furniture");
+
+ /** Priority for context animation layer (lower = overridable by item layer) */
+ private static final int CONTEXT_LAYER_PRIORITY = 40;
+ /** Priority for item animation layer (higher = overrides context layer) */
+ private static final int ITEM_LAYER_PRIORITY = 42;
+ /**
+ * Priority for furniture animation layer (highest = overrides item layer on blocked bones).
+ * Non-blocked bones are disabled so items can still animate them via the item layer.
+ */
+ private static final int FURNITURE_LAYER_PRIORITY = 43;
+
+ /** Number of ticks to wait before removing a stale furniture animation. */
+ private static final int FURNITURE_GRACE_TICKS = 3;
+
+ /**
+ * Tracks ticks since a player with an active furniture animation stopped riding
+ * an ISeatProvider. After {@link #FURNITURE_GRACE_TICKS}, the animation is removed
+ * to prevent stuck poses from entity death or network issues.
+ *
+ * Uses ConcurrentHashMap for safe access from both client tick and render thread.
+ */
+ private static final Map furnitureGraceTicks = new ConcurrentHashMap<>();
+
+ /**
+ * Initialize the animation system.
+ * Must be called during client setup to register the player animation factory.
+ */
+ public static void init() {
+ LOGGER.info("BondageAnimationManager initializing...");
+
+ // Context layer: lower priority = evaluated first, overridable by item layer.
+ // In AnimationStack, layers are sorted ascending by priority and evaluated in order.
+ // Higher priority layers override lower ones.
+ PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
+ CONTEXT_FACTORY_ID,
+ CONTEXT_LAYER_PRIORITY,
+ player -> new ModifierLayer<>()
+ );
+
+ // Item layer: higher priority = evaluated last, overrides context layer
+ PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
+ FACTORY_ID,
+ ITEM_LAYER_PRIORITY,
+ player -> new ModifierLayer<>()
+ );
+
+ // Furniture layer: highest priority = overrides item layer on blocked bones.
+ // Non-blocked bones are disabled via FurnitureAnimationContext so items
+ // can still animate free regions (gag, blindfold, etc.).
+ PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
+ FURNITURE_FACTORY_ID,
+ FURNITURE_LAYER_PRIORITY,
+ player -> new ModifierLayer<>()
+ );
+
+ LOGGER.info(
+ "BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})",
+ CONTEXT_LAYER_PRIORITY, ITEM_LAYER_PRIORITY, FURNITURE_LAYER_PRIORITY
+ );
+ }
+
+ // ========================================
+ // PLAY ANIMATION
+ // ========================================
+
+ /**
+ * Play an animation on any entity (player or NPC).
+ *
+ * @param entity The entity to animate
+ * @param animId Animation ID string (will be prefixed with "tiedup:" namespace)
+ * @return true if animation started successfully, false if layer not available
+ */
+ public static boolean playAnimation(LivingEntity entity, String animId) {
+ ResourceLocation location = ResourceLocation.fromNamespaceAndPath(
+ "tiedup",
+ animId
+ );
+ return playAnimation(entity, location);
+ }
+
+ /**
+ * Play an animation on any entity (player or NPC).
+ *
+ * If the animation layer is not available (e.g., remote player not fully
+ * initialized), the animation will be queued for retry via PendingAnimationManager.
+ *
+ * @param entity The entity to animate
+ * @param animId Full ResourceLocation of the animation
+ * @return true if animation started successfully, false if layer not available
+ */
+ public static boolean playAnimation(
+ LivingEntity entity,
+ ResourceLocation animId
+ ) {
+ if (entity == null || !entity.level().isClientSide()) {
+ return false;
+ }
+
+ KeyframeAnimation anim = PlayerAnimationRegistry.getAnimation(animId);
+ if (anim == null) {
+ // Try fallback: remove _sneak_ suffix if present
+ ResourceLocation fallbackId = tryFallbackAnimation(animId);
+ if (fallbackId != null) {
+ anim = PlayerAnimationRegistry.getAnimation(fallbackId);
+ if (anim != null) {
+ LOGGER.debug(
+ "Using fallback animation '{}' for missing '{}'",
+ fallbackId,
+ animId
+ );
+ }
+ }
+ if (anim == null) {
+ LOGGER.warn("Animation not found in registry: {}", animId);
+ return false;
+ }
+ }
+
+ ModifierLayer layer = getOrCreateLayer(entity);
+ if (layer != null) {
+ // Check if same animation is already playing
+ // Use reference comparison (==) instead of equals() because:
+ // 1. PlayerAnimationRegistry caches animations by ID
+ // 2. Same ID = same cached object reference
+ // 3. This avoids issues with KeyframeAnimation.equals() implementation
+ IAnimation current = layer.getAnimation();
+ if (current instanceof KeyframeAnimationPlayer player) {
+ if (player.getData() == anim) {
+ // Same animation already playing, don't reset
+ return true; // Still counts as success
+ }
+ }
+ layer.setAnimation(new KeyframeAnimationPlayer(anim));
+
+ // Remove from pending queue if it was waiting
+ PendingAnimationManager.remove(entity.getUUID());
+
+ LOGGER.debug(
+ "Playing animation '{}' on entity: {}",
+ animId,
+ entity.getUUID()
+ );
+ return true;
+ } else {
+ // Layer not available - queue for retry if it's a player
+ if (entity instanceof AbstractClientPlayer) {
+ PendingAnimationManager.queueForRetry(
+ entity.getUUID(),
+ animId.getPath()
+ );
+ LOGGER.debug(
+ "Animation layer not ready for {}, queued for retry",
+ entity.getName().getString()
+ );
+ } else {
+ LOGGER.warn(
+ "Animation layer is NULL for NPC: {} (type: {})",
+ entity.getName().getString(),
+ entity.getClass().getSimpleName()
+ );
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Play a pre-converted KeyframeAnimation directly on an entity, bypassing the registry.
+ * Used by GltfAnimationApplier for GLB-converted poses.
+ *
+ * @param entity The entity to animate
+ * @param anim The KeyframeAnimation to play
+ * @return true if animation started successfully
+ */
+ public static boolean playDirect(LivingEntity entity, KeyframeAnimation anim) {
+ if (entity == null || anim == null || !entity.level().isClientSide()) {
+ return false;
+ }
+
+ ModifierLayer layer = getOrCreateLayer(entity);
+ if (layer != null) {
+ IAnimation current = layer.getAnimation();
+ if (current instanceof KeyframeAnimationPlayer player) {
+ if (player.getData() == anim) {
+ return true; // Same animation already playing
+ }
+ }
+ layer.setAnimation(new KeyframeAnimationPlayer(anim));
+ PendingAnimationManager.remove(entity.getUUID());
+ return true;
+ }
+ return false;
+ }
+
+ // ========================================
+ // STOP ANIMATION
+ // ========================================
+
+ /**
+ * Stop any currently playing animation on an entity.
+ *
+ * @param entity The entity
+ */
+ public static void stopAnimation(LivingEntity entity) {
+ if (entity == null || !entity.level().isClientSide()) {
+ return;
+ }
+
+ ModifierLayer layer = getLayer(entity);
+ if (layer != null) {
+ layer.setAnimation(null);
+ LOGGER.debug("Stopped animation on entity: {}", entity.getUUID());
+ }
+ }
+
+ // ========================================
+ // LAYER MANAGEMENT
+ // ========================================
+
+ /**
+ * Get the ModifierLayer for an entity (without creating).
+ */
+ private static ModifierLayer getLayer(LivingEntity entity) {
+ // Players: try PlayerAnimationAccess first, then cache
+ if (entity instanceof AbstractClientPlayer player) {
+ ModifierLayer factoryLayer = getPlayerLayer(player);
+ if (factoryLayer != null) {
+ return factoryLayer;
+ }
+ // Check cache (for remote players using fallback)
+ return npcLayers.get(entity.getUUID());
+ }
+
+ // NPCs: use cache
+ return npcLayers.get(entity.getUUID());
+ }
+
+ /**
+ * Get or create the ModifierLayer for an entity.
+ */
+ @SuppressWarnings("unchecked")
+ private static ModifierLayer getOrCreateLayer(
+ LivingEntity entity
+ ) {
+ UUID uuid = entity.getUUID();
+
+ // Players: try factory-based access first, fallback to direct stack access
+ if (entity instanceof AbstractClientPlayer player) {
+ // Try the registered factory first (works for local player)
+ ModifierLayer factoryLayer = getPlayerLayer(player);
+ if (factoryLayer != null) {
+ return factoryLayer;
+ }
+
+ // Fallback for remote players: use direct stack access like NPCs
+ // This handles cases where the factory data isn't available
+ if (player instanceof IAnimatedPlayer animated) {
+ return npcLayers.computeIfAbsent(uuid, k -> {
+ ModifierLayer newLayer = new ModifierLayer<>();
+ animated
+ .getAnimationStack()
+ .addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
+ LOGGER.info(
+ "Created animation layer for remote player via stack: {}",
+ player.getName().getString()
+ );
+ return newLayer;
+ });
+ }
+ }
+
+ // NPCs implementing IAnimatedPlayer: create/cache layer
+ if (entity instanceof IAnimatedPlayer animated) {
+ return npcLayers.computeIfAbsent(uuid, k -> {
+ ModifierLayer newLayer = new ModifierLayer<>();
+ animated
+ .getAnimationStack()
+ .addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
+ LOGGER.debug("Created animation layer for NPC: {}", uuid);
+ return newLayer;
+ });
+ }
+
+ LOGGER.warn(
+ "Entity {} does not support animations (not a player or IAnimatedPlayer)",
+ uuid
+ );
+ return null;
+ }
+
+ /**
+ * Get the animation layer for a player from PlayerAnimationAccess.
+ */
+ @SuppressWarnings("unchecked")
+ private static ModifierLayer getPlayerLayer(
+ AbstractClientPlayer player
+ ) {
+ try {
+ return (ModifierLayer<
+ IAnimation
+ >) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
+ FACTORY_ID
+ );
+ } catch (Exception e) {
+ LOGGER.error(
+ "Failed to get animation layer for player: {}",
+ player.getName().getString(),
+ e
+ );
+ return null;
+ }
+ }
+
+ /**
+ * Safely get the animation layer for a player.
+ * Returns null if the layer is not yet initialized.
+ *
+ * Public method for PendingAnimationManager to access.
+ * Checks both the factory-based layer and the NPC cache fallback.
+ *
+ * @param player The player
+ * @return The animation layer, or null if not available
+ */
+ @javax.annotation.Nullable
+ public static ModifierLayer getPlayerLayerSafe(
+ AbstractClientPlayer player
+ ) {
+ // Try factory first
+ ModifierLayer factoryLayer = getPlayerLayer(player);
+ if (factoryLayer != null) {
+ return factoryLayer;
+ }
+
+ // Check NPC cache (for remote players using fallback path)
+ return npcLayers.get(player.getUUID());
+ }
+
+ // ========================================
+ // CONTEXT LAYER (lower priority, for sit/kneel/sneak)
+ // ========================================
+
+ /**
+ * Get the context animation layer for a player from PlayerAnimationAccess.
+ * Returns null if the layer is not yet initialized.
+ */
+ @SuppressWarnings("unchecked")
+ @javax.annotation.Nullable
+ private static ModifierLayer getPlayerContextLayer(
+ AbstractClientPlayer player
+ ) {
+ try {
+ return (ModifierLayer<
+ IAnimation
+ >) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
+ CONTEXT_FACTORY_ID
+ );
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Get or create the context animation layer for an NPC entity.
+ * Uses CONTEXT_LAYER_PRIORITY, below the item layer at ITEM_LAYER_PRIORITY.
+ */
+ @javax.annotation.Nullable
+ private static ModifierLayer getOrCreateNpcContextLayer(
+ LivingEntity entity
+ ) {
+ if (entity instanceof IAnimatedPlayer animated) {
+ return npcContextLayers.computeIfAbsent(
+ entity.getUUID(),
+ k -> {
+ ModifierLayer layer = new ModifierLayer<>();
+ animated.getAnimationStack().addAnimLayer(CONTEXT_LAYER_PRIORITY, layer);
+ return layer;
+ }
+ );
+ }
+ return null;
+ }
+
+ /**
+ * Play a context animation on the context layer (lower priority).
+ * Context animations (sit, kneel, sneak) can be overridden by item animations
+ * on the main layer which has higher priority.
+ *
+ * @param entity The entity to animate
+ * @param anim The KeyframeAnimation to play on the context layer
+ * @return true if animation started successfully
+ */
+ public static boolean playContext(
+ LivingEntity entity,
+ KeyframeAnimation anim
+ ) {
+ if (entity == null || anim == null || !entity.level().isClientSide()) {
+ return false;
+ }
+
+ ModifierLayer layer;
+ if (entity instanceof AbstractClientPlayer player) {
+ layer = getPlayerContextLayer(player);
+ } else {
+ layer = getOrCreateNpcContextLayer(entity);
+ }
+
+ if (layer != null) {
+ layer.setAnimation(new KeyframeAnimationPlayer(anim));
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Stop the context layer animation.
+ *
+ * @param entity The entity whose context animation should stop
+ */
+ public static void stopContext(LivingEntity entity) {
+ if (entity == null || !entity.level().isClientSide()) {
+ return;
+ }
+
+ ModifierLayer layer;
+ if (entity instanceof AbstractClientPlayer player) {
+ layer = getPlayerContextLayer(player);
+ } else {
+ layer = npcContextLayers.get(entity.getUUID());
+ }
+
+ if (layer != null) {
+ layer.setAnimation(null);
+ }
+ }
+
+ // ========================================
+ // FURNITURE LAYER (highest priority, for seat poses)
+ // ========================================
+
+ /**
+ * Play a furniture animation on the furniture layer (highest priority).
+ *
+ * The furniture layer sits above the item layer so it controls blocked-region
+ * bones. Non-blocked bones should already be disabled in the provided animation
+ * (via {@link com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext#create}).
+ * This allows bondage items on free regions to still animate via the item layer.
+ *
+ * @param player the player to animate
+ * @param animation the KeyframeAnimation from FurnitureAnimationContext
+ * @return true if animation started successfully
+ */
+ public static boolean playFurniture(Player player, KeyframeAnimation animation) {
+ if (player == null || animation == null || !player.level().isClientSide()) {
+ return false;
+ }
+
+ ModifierLayer layer = getFurnitureLayer(player);
+ if (layer != null) {
+ layer.setAnimation(new KeyframeAnimationPlayer(animation));
+ // Reset grace ticks since we just started/refreshed the animation
+ furnitureGraceTicks.remove(player.getUUID());
+ LOGGER.debug("Playing furniture animation on player: {}", player.getName().getString());
+ return true;
+ }
+
+ LOGGER.warn("Furniture layer not available for player: {}", player.getName().getString());
+ return false;
+ }
+
+ /**
+ * Stop the furniture layer animation for a player.
+ *
+ * @param player the player whose furniture animation should stop
+ */
+ public static void stopFurniture(Player player) {
+ if (player == null || !player.level().isClientSide()) {
+ return;
+ }
+
+ ModifierLayer layer = getFurnitureLayer(player);
+ if (layer != null) {
+ layer.setAnimation(null);
+ }
+ furnitureGraceTicks.remove(player.getUUID());
+ LOGGER.debug("Stopped furniture animation on player: {}", player.getName().getString());
+ }
+
+ /**
+ * Check whether a player currently has an active furniture animation.
+ *
+ * @param player the player to check
+ * @return true if the furniture layer has an active animation
+ */
+ public static boolean hasFurnitureAnimation(Player player) {
+ if (player == null || !player.level().isClientSide()) {
+ return false;
+ }
+
+ ModifierLayer layer = getFurnitureLayer(player);
+ return layer != null && layer.getAnimation() != null;
+ }
+
+ /**
+ * Get the furniture ModifierLayer for a player.
+ * Uses PlayerAnimationAccess for local/factory-registered players,
+ * falls back to NPC cache for remote players.
+ */
+ @SuppressWarnings("unchecked")
+ @javax.annotation.Nullable
+ private static ModifierLayer getFurnitureLayer(Player player) {
+ if (player instanceof AbstractClientPlayer clientPlayer) {
+ try {
+ ModifierLayer layer = (ModifierLayer)
+ PlayerAnimationAccess.getPlayerAssociatedData(clientPlayer)
+ .get(FURNITURE_FACTORY_ID);
+ if (layer != null) {
+ return layer;
+ }
+ } catch (Exception e) {
+ // Fall through to NPC cache
+ }
+
+ // Fallback for remote players: check NPC furniture cache
+ return npcFurnitureLayers.get(player.getUUID());
+ }
+
+ // Non-player entities: use NPC cache
+ return npcFurnitureLayers.get(player.getUUID());
+ }
+
+ /**
+ * Safety tick for furniture animations. Call once per client tick per player.
+ *
+ * If a player has an active furniture animation but is NOT riding an
+ * {@link ISeatProvider}, increment a grace counter. After
+ * {@link #FURNITURE_GRACE_TICKS} consecutive ticks without a seat, the
+ * animation is removed to prevent stuck poses from entity death, network
+ * desync, or teleportation.
+ *
+ * If the player IS riding an ISeatProvider, the counter is reset.
+ *
+ * @param player the player to check
+ */
+ public static void tickFurnitureSafety(Player player) {
+ if (player == null || !player.level().isClientSide()) {
+ return;
+ }
+
+ if (!hasFurnitureAnimation(player)) {
+ // No furniture animation active, nothing to guard
+ furnitureGraceTicks.remove(player.getUUID());
+ return;
+ }
+
+ UUID uuid = player.getUUID();
+
+ // Check if the player is riding an ISeatProvider
+ Entity vehicle = player.getVehicle();
+ boolean ridingSeat = vehicle instanceof ISeatProvider;
+
+ if (ridingSeat) {
+ // Player is properly seated, reset grace counter
+ furnitureGraceTicks.remove(uuid);
+ } else {
+ // Player has furniture anim but no seat -- increment grace
+ int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum);
+ if (ticks >= FURNITURE_GRACE_TICKS) {
+ LOGGER.info("Removing stale furniture animation for player {} "
+ + "(not riding ISeatProvider for {} ticks)",
+ player.getName().getString(), ticks);
+ stopFurniture(player);
+ }
+ }
+ }
+
+ // ========================================
+ // FALLBACK ANIMATION HANDLING
+ // ========================================
+
+ /**
+ * Try to find a fallback animation ID when the requested one doesn't exist.
+ *
+ * Fallback chain:
+ *
+ * - Remove _sneak_ suffix (sneak variants often missing)
+ * - For sit_dog/kneel_dog variants, fall back to basic standing DOG
+ * - For _arms_ variants, try FULL variant
+ *
+ *
+ * @param originalId The original animation ID that wasn't found
+ * @return A fallback ResourceLocation to try, or null if no fallback
+ */
+ @javax.annotation.Nullable
+ private static ResourceLocation tryFallbackAnimation(
+ ResourceLocation originalId
+ ) {
+ String path = originalId.getPath();
+ String namespace = originalId.getNamespace();
+
+ // 1. Remove _sneak_ suffix
+ if (path.contains("_sneak_")) {
+ String fallback = path.replace("_sneak_", "_");
+ return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
+ }
+
+ // 2. sit_dog_* / kneel_dog_* -> tied_up_dog_*
+ if (path.startsWith("sit_dog_") || path.startsWith("kneel_dog_")) {
+ String suffix = path.substring(path.lastIndexOf("_")); // _idle or _struggle
+ return ResourceLocation.fromNamespaceAndPath(
+ namespace,
+ "tied_up_dog" + suffix
+ );
+ }
+
+ // 3. _arms_ variants -> try FULL variant (remove _arms)
+ if (path.contains("_arms_")) {
+ String fallback = path.replace("_arms_", "_");
+ return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
+ }
+
+ // 4. Struggle variants for free/legs -> idle variant
+ if (
+ (path.startsWith("sit_free_") ||
+ path.startsWith("kneel_free_") ||
+ path.startsWith("sit_legs_") ||
+ path.startsWith("kneel_legs_")) &&
+ path.endsWith("_struggle")
+ ) {
+ String fallback = path.replace("_struggle", "_idle");
+ return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
+ }
+
+ return null;
+ }
+
+ // ========================================
+ // CLEANUP
+ // ========================================
+
+ /**
+ * Clean up animation layer for an NPC when it's removed.
+ *
+ * @param entityId UUID of the removed entity
+ */
+ /** All NPC layer caches, for bulk cleanup operations. */
+ private static final Map>[] ALL_NPC_CACHES = new Map[] {
+ npcLayers, npcContextLayers, npcFurnitureLayers
+ };
+
+ public static void cleanup(UUID entityId) {
+ for (Map> cache : ALL_NPC_CACHES) {
+ ModifierLayer layer = cache.remove(entityId);
+ if (layer != null) {
+ layer.setAnimation(null);
+ }
+ }
+ furnitureGraceTicks.remove(entityId);
+ LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
+ }
+
+ /**
+ * Clear all NPC animation layers.
+ * Should be called on world unload.
+ */
+ public static void clearAll() {
+ for (Map> cache : ALL_NPC_CACHES) {
+ cache.values().forEach(layer -> layer.setAnimation(null));
+ cache.clear();
+ }
+ furnitureGraceTicks.clear();
+ LOGGER.info("Cleared all NPC animation layers");
+ }
+
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/PendingAnimationManager.java b/src/main/java/com/tiedup/remake/client/animation/PendingAnimationManager.java
new file mode 100644
index 0000000..58ccff8
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/PendingAnimationManager.java
@@ -0,0 +1,156 @@
+package com.tiedup.remake.client.animation;
+
+import com.mojang.logging.LogUtils;
+import dev.kosmx.playerAnim.api.layered.IAnimation;
+import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
+import dev.kosmx.playerAnim.api.layered.ModifierLayer;
+import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import net.minecraft.client.multiplayer.ClientLevel;
+import net.minecraft.client.player.AbstractClientPlayer;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.player.Player;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+import org.slf4j.Logger;
+
+/**
+ * Manages pending animations for remote players whose animation layers
+ * may not be immediately available due to timing issues.
+ *
+ * When a player is tied, the sync packet may arrive before the remote player's
+ * animation layer is initialized by PlayerAnimator. This class queues failed
+ * animation attempts and retries them each tick until success or timeout.
+ *
+ *
This follows the same pattern as SyncManager's pending queue for inventory sync.
+ */
+@OnlyIn(Dist.CLIENT)
+public class PendingAnimationManager {
+
+ private static final Logger LOGGER = LogUtils.getLogger();
+
+ /** Pending animations waiting for layer initialization */
+ private static final Map pending =
+ new ConcurrentHashMap<>();
+
+ /** Maximum retry attempts before giving up (~2 seconds at 20 ticks/sec) */
+ private static final int MAX_RETRIES = 40;
+
+ /**
+ * Queue a player's animation for retry.
+ * Called when playAnimation fails due to null layer.
+ *
+ * @param uuid The player's UUID
+ * @param animId The animation ID (without namespace)
+ */
+ public static void queueForRetry(UUID uuid, String animId) {
+ pending.compute(uuid, (k, existing) -> {
+ if (existing == null) {
+ LOGGER.debug(
+ "Queued animation '{}' for retry on player {}",
+ animId,
+ uuid
+ );
+ return new PendingEntry(animId, 0);
+ }
+ // Update animation ID but preserve retry count
+ return new PendingEntry(animId, existing.retries);
+ });
+ }
+
+ /**
+ * Remove a player from the pending queue.
+ * Called when animation succeeds or player disconnects.
+ *
+ * @param uuid The player's UUID
+ */
+ public static void remove(UUID uuid) {
+ pending.remove(uuid);
+ }
+
+ /**
+ * Check if a player has a pending animation.
+ *
+ * @param uuid The player's UUID
+ * @return true if pending
+ */
+ public static boolean hasPending(UUID uuid) {
+ return pending.containsKey(uuid);
+ }
+
+ /**
+ * Process pending animations. Called every tick from AnimationTickHandler.
+ * Attempts to play queued animations and removes successful or expired entries.
+ *
+ * @param level The client level
+ */
+ public static void processPending(ClientLevel level) {
+ if (pending.isEmpty()) return;
+
+ Iterator> it = pending
+ .entrySet()
+ .iterator();
+
+ while (it.hasNext()) {
+ Map.Entry entry = it.next();
+ UUID uuid = entry.getKey();
+ PendingEntry pe = entry.getValue();
+
+ // Check expiration
+ if (pe.retries >= MAX_RETRIES) {
+ LOGGER.warn("Animation retry exhausted for player {}", uuid);
+ it.remove();
+ continue;
+ }
+
+ // Try to find player and play animation
+ Player player = level.getPlayerByUUID(uuid);
+ if (player instanceof AbstractClientPlayer clientPlayer) {
+ ModifierLayer layer =
+ BondageAnimationManager.getPlayerLayerSafe(clientPlayer);
+
+ if (layer != null) {
+ ResourceLocation loc =
+ ResourceLocation.fromNamespaceAndPath(
+ "tiedup",
+ pe.animId
+ );
+ KeyframeAnimation anim =
+ PlayerAnimationRegistry.getAnimation(loc);
+
+ if (anim != null) {
+ layer.setAnimation(new KeyframeAnimationPlayer(anim));
+ LOGGER.info(
+ "Animation retry succeeded for {} after {} attempts",
+ clientPlayer.getName().getString(),
+ pe.retries
+ );
+ it.remove();
+ continue;
+ }
+ }
+ }
+
+ // Increment retry count
+ pending.put(uuid, new PendingEntry(pe.animId, pe.retries + 1));
+ }
+ }
+
+ /**
+ * Clear all pending animations.
+ * Called on world unload.
+ */
+ public static void clearAll() {
+ pending.clear();
+ LOGGER.debug("Cleared all pending animations");
+ }
+
+ /**
+ * Record to store pending animation data.
+ */
+ private record PendingEntry(String animId, int retries) {}
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/StaticPoseApplier.java b/src/main/java/com/tiedup/remake/client/animation/StaticPoseApplier.java
new file mode 100644
index 0000000..4c496b5
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/StaticPoseApplier.java
@@ -0,0 +1,137 @@
+package com.tiedup.remake.client.animation;
+
+import com.tiedup.remake.items.base.PoseType;
+import net.minecraft.client.model.HumanoidModel;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Applies static bondage poses directly to HumanoidModel.
+ *
+ * Used for entities that don't support PlayerAnimator (e.g., MCA villagers).
+ * Directly modifies arm/leg rotations on the model.
+ *
+ *
Extracted from BondageAnimationManager to separate concerns:
+ * BondageAnimationManager handles PlayerAnimator layers,
+ * StaticPoseApplier handles raw model manipulation.
+ */
+@OnlyIn(Dist.CLIENT)
+public class StaticPoseApplier {
+
+ /**
+ * Apply a static bondage pose directly to a HumanoidModel.
+ *
+ * @param model The humanoid model to modify
+ * @param poseType The pose type (STANDARD, STRAITJACKET, WRAP, LATEX_SACK)
+ * @param armsBound whether ARMS region is occupied
+ * @param legsBound whether LEGS region is occupied
+ */
+ public static void applyStaticPose(
+ HumanoidModel> model,
+ PoseType poseType,
+ boolean armsBound,
+ boolean legsBound
+ ) {
+ if (model == null) {
+ return;
+ }
+
+ applyBodyPose(model, poseType);
+
+ if (armsBound) {
+ applyArmPose(model, poseType);
+ }
+
+ if (legsBound) {
+ applyLegPose(model, poseType);
+ }
+ }
+
+ /**
+ * Apply arm pose based on pose type.
+ * Values converted from animation JSON (degrees to radians).
+ */
+ private static void applyArmPose(
+ HumanoidModel> model,
+ PoseType poseType
+ ) {
+ switch (poseType) {
+ case STANDARD -> {
+ model.rightArm.xRot = 0.899f;
+ model.rightArm.yRot = 1.0f;
+ model.rightArm.zRot = 0f;
+ model.leftArm.xRot = 0.899f;
+ model.leftArm.yRot = -1.0f;
+ model.leftArm.zRot = 0f;
+ }
+ case STRAITJACKET -> {
+ model.rightArm.xRot = 0.764f;
+ model.rightArm.yRot = -0.84f;
+ model.rightArm.zRot = 0f;
+ model.leftArm.xRot = 0.764f;
+ model.leftArm.yRot = 0.84f;
+ model.leftArm.zRot = 0f;
+ }
+ case WRAP, LATEX_SACK -> {
+ model.rightArm.xRot = 0f;
+ model.rightArm.yRot = 0f;
+ model.rightArm.zRot = -0.087f;
+ model.leftArm.xRot = 0f;
+ model.leftArm.yRot = 0f;
+ model.leftArm.zRot = 0.087f;
+ }
+ case DOG -> {
+ model.rightArm.xRot = -2.094f;
+ model.rightArm.yRot = 0.175f;
+ model.rightArm.zRot = 0f;
+ model.leftArm.xRot = -2.094f;
+ model.leftArm.yRot = -0.175f;
+ model.leftArm.zRot = 0f;
+ }
+ case HUMAN_CHAIR -> {
+ model.rightArm.xRot = -2.094f;
+ model.rightArm.yRot = 0.175f;
+ model.rightArm.zRot = 0f;
+ model.leftArm.xRot = -2.094f;
+ model.leftArm.yRot = -0.175f;
+ model.leftArm.zRot = 0f;
+ }
+ }
+ }
+
+ /**
+ * Apply leg pose based on pose type.
+ */
+ private static void applyLegPose(
+ HumanoidModel> model,
+ PoseType poseType
+ ) {
+ if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
+ model.rightLeg.xRot = -1.047f;
+ model.rightLeg.yRot = 0.349f;
+ model.rightLeg.zRot = 0f;
+ model.leftLeg.xRot = -1.047f;
+ model.leftLeg.yRot = -0.349f;
+ model.leftLeg.zRot = 0f;
+ } else {
+ model.rightLeg.xRot = 0f;
+ model.rightLeg.yRot = 0f;
+ model.rightLeg.zRot = -0.1f;
+ model.leftLeg.xRot = 0f;
+ model.leftLeg.yRot = 0f;
+ model.leftLeg.zRot = 0.1f;
+ }
+ }
+
+ /**
+ * Apply body pose for DOG/HUMAN_CHAIR pose.
+ */
+ public static void applyBodyPose(
+ HumanoidModel> model,
+ PoseType poseType
+ ) {
+ if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
+ model.body.xRot = -1.571f;
+ }
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/context/AnimationContext.java b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContext.java
new file mode 100644
index 0000000..132b381
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContext.java
@@ -0,0 +1,93 @@
+package com.tiedup.remake.client.animation.context;
+
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Represents the current player/NPC posture and action state for animation selection.
+ * Determines which base body posture animation to play.
+ *
+ *
Each context maps to a GLB animation name via a prefix + variant scheme:
+ *
+ * - Prefix: "Sit", "Kneel", "Sneak", "Walk", or "" (standing)
+ * - Variant: "Idle" or "Struggle"
+ *
+ * The {@link GlbAnimationResolver} uses these to build a fallback chain
+ * (e.g., SitStruggle -> Struggle -> SitIdle -> Idle).
+ */
+@OnlyIn(Dist.CLIENT)
+public enum AnimationContext {
+
+ STAND_IDLE("stand_idle", false),
+ STAND_WALK("stand_walk", false),
+ STAND_SNEAK("stand_sneak", false),
+ STAND_STRUGGLE("stand_struggle", true),
+ SIT_IDLE("sit_idle", false),
+ SIT_STRUGGLE("sit_struggle", true),
+ KNEEL_IDLE("kneel_idle", false),
+ KNEEL_STRUGGLE("kneel_struggle", true),
+
+ // Movement style contexts
+ SHUFFLE_IDLE("shuffle_idle", false),
+ SHUFFLE_WALK("shuffle_walk", false),
+ HOP_IDLE("hop_idle", false),
+ HOP_WALK("hop_walk", false),
+ WADDLE_IDLE("waddle_idle", false),
+ WADDLE_WALK("waddle_walk", false),
+ CRAWL_IDLE("crawl_idle", false),
+ CRAWL_MOVE("crawl_move", false);
+
+ private final String animationSuffix;
+ private final boolean struggling;
+
+ AnimationContext(String animationSuffix, boolean struggling) {
+ this.animationSuffix = animationSuffix;
+ this.struggling = struggling;
+ }
+
+ /**
+ * Suffix used as key for context animation JSON files (e.g., "stand_idle").
+ */
+ public String getAnimationSuffix() {
+ return animationSuffix;
+ }
+
+ /**
+ * Whether this context represents an active struggle state.
+ */
+ public boolean isStruggling() {
+ return struggling;
+ }
+
+ /**
+ * Get the GLB animation name prefix for this context's posture.
+ * Used by the fallback chain in {@link GlbAnimationResolver}.
+ *
+ * @return "Sit", "Kneel", "Sneak", "Walk", or "" for standing
+ */
+ public String getGlbContextPrefix() {
+ return switch (this) {
+ case SIT_IDLE, SIT_STRUGGLE -> "Sit";
+ case KNEEL_IDLE, KNEEL_STRUGGLE -> "Kneel";
+ case STAND_SNEAK -> "Sneak";
+ case STAND_WALK -> "Walk";
+ case STAND_IDLE, STAND_STRUGGLE -> "";
+ case SHUFFLE_IDLE, SHUFFLE_WALK -> "Shuffle";
+ case HOP_IDLE, HOP_WALK -> "Hop";
+ case WADDLE_IDLE, WADDLE_WALK -> "Waddle";
+ case CRAWL_IDLE, CRAWL_MOVE -> "Crawl";
+ };
+ }
+
+ /**
+ * Get the GLB animation variant name: "Struggle" or "Idle".
+ */
+ public String getGlbVariant() {
+ return switch (this) {
+ case STAND_STRUGGLE, SIT_STRUGGLE, KNEEL_STRUGGLE -> "Struggle";
+ case STAND_WALK, SHUFFLE_WALK, HOP_WALK, WADDLE_WALK -> "Walk";
+ case CRAWL_MOVE -> "Move";
+ default -> "Idle";
+ };
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/context/AnimationContextResolver.java b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContextResolver.java
new file mode 100644
index 0000000..ea60996
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/context/AnimationContextResolver.java
@@ -0,0 +1,118 @@
+package com.tiedup.remake.client.animation.context;
+
+import com.tiedup.remake.client.state.PetBedClientState;
+import com.tiedup.remake.entities.AbstractTiedUpNpc;
+import com.tiedup.remake.state.PlayerBindState;
+import com.tiedup.remake.v2.bondage.movement.MovementStyle;
+import net.minecraft.world.entity.player.Player;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Resolves the current {@link AnimationContext} for players and NPCs based on their state.
+ *
+ * This is a pure function with no side effects -- it reads entity state and returns
+ * the appropriate animation context. The resolution priority is:
+ *
+ * - Sitting (pet bed for players, pose for NPCs) -- highest priority posture
+ * - Kneeling (NPCs only)
+ * - Struggling (standing struggle if not sitting/kneeling)
+ * - Sneaking (players only)
+ * - Walking (horizontal movement detected)
+ * - Standing idle (fallback)
+ *
+ *
+ * For players, the "sitting" state is determined by the client-side pet bed cache
+ * ({@link PetBedClientState}) rather than entity data, since pet bed state is not
+ * synced via entity data accessors.
+ */
+@OnlyIn(Dist.CLIENT)
+public final class AnimationContextResolver {
+
+ private AnimationContextResolver() {}
+
+ /**
+ * Resolve the animation context for a player based on their bind state and movement.
+ *
+ * Priority chain:
+ *
+ * - Sitting (pet bed/furniture) -- highest priority posture
+ * - Struggling -- standing struggle if not sitting
+ * - Movement style -- style-specific idle/walk based on movement
+ * - Sneaking
+ * - Walking
+ * - Standing idle -- fallback
+ *
+ *
+ * @param player the player entity (must not be null)
+ * @param state the player's bind state, or null if not bound
+ * @param activeStyle the active movement style from client state, or null
+ * @return the resolved animation context, never null
+ */
+ public static AnimationContext resolve(Player player, @Nullable PlayerBindState state,
+ @Nullable MovementStyle activeStyle) {
+ boolean sitting = PetBedClientState.get(player.getUUID()) != 0;
+ boolean struggling = state != null && state.isStruggling();
+ boolean sneaking = player.isCrouching();
+ boolean moving = player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
+
+ if (sitting) {
+ return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
+ }
+ if (struggling) {
+ return AnimationContext.STAND_STRUGGLE;
+ }
+ if (activeStyle != null) {
+ return resolveStyleContext(activeStyle, moving);
+ }
+ if (sneaking) {
+ return AnimationContext.STAND_SNEAK;
+ }
+ if (moving) {
+ return AnimationContext.STAND_WALK;
+ }
+ return AnimationContext.STAND_IDLE;
+ }
+
+ /**
+ * Map a movement style + moving flag to the appropriate AnimationContext.
+ */
+ private static AnimationContext resolveStyleContext(MovementStyle style, boolean moving) {
+ return switch (style) {
+ case SHUFFLE -> moving ? AnimationContext.SHUFFLE_WALK : AnimationContext.SHUFFLE_IDLE;
+ case HOP -> moving ? AnimationContext.HOP_WALK : AnimationContext.HOP_IDLE;
+ case WADDLE -> moving ? AnimationContext.WADDLE_WALK : AnimationContext.WADDLE_IDLE;
+ case CRAWL -> moving ? AnimationContext.CRAWL_MOVE : AnimationContext.CRAWL_IDLE;
+ };
+ }
+
+ /**
+ * Resolve the animation context for a Damsel NPC based on pose and movement.
+ *
+ * Unlike players, NPCs support kneeling as a distinct posture and do not sneak.
+ *
+ * @param entity the damsel entity (must not be null)
+ * @return the resolved animation context, never null
+ */
+ public static AnimationContext resolveNpc(AbstractTiedUpNpc entity) {
+ boolean sitting = entity.isSitting();
+ boolean kneeling = entity.isKneeling();
+ boolean struggling = entity.isStruggling();
+ boolean moving = entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
+
+ if (sitting) {
+ return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
+ }
+ if (kneeling) {
+ return struggling ? AnimationContext.KNEEL_STRUGGLE : AnimationContext.KNEEL_IDLE;
+ }
+ if (struggling) {
+ return AnimationContext.STAND_STRUGGLE;
+ }
+ if (moving) {
+ return AnimationContext.STAND_WALK;
+ }
+ return AnimationContext.STAND_IDLE;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/context/ContextAnimationFactory.java b/src/main/java/com/tiedup/remake/client/animation/context/ContextAnimationFactory.java
new file mode 100644
index 0000000..f326437
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/context/ContextAnimationFactory.java
@@ -0,0 +1,161 @@
+package com.tiedup.remake.client.animation.context;
+
+import com.mojang.logging.LogUtils;
+import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
+import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import org.jetbrains.annotations.Nullable;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+import org.slf4j.Logger;
+
+/**
+ * Builds context {@link KeyframeAnimation}s with item-owned body parts disabled.
+ *
+ * Context animations (loaded from {@code context_*.json} files in the PlayerAnimator
+ * registry) control the base body posture -- standing, sitting, walking, etc.
+ * When a V2 bondage item "owns" certain body parts (e.g., handcuffs own rightArm + leftArm),
+ * those parts must NOT be driven by the context animation because the item's own
+ * GLB animation controls them instead.
+ *
+ * This factory loads the base context animation, creates a mutable copy, disables
+ * the owned parts, and builds an immutable result. Results are cached by
+ * {@code contextSuffix|ownedPartsHash} to avoid repeated copies.
+ *
+ * Thread safety: the cache uses {@link ConcurrentHashMap}. All methods are
+ * called from the render thread, but the concurrent map avoids issues if
+ * resource reload triggers on a different thread.
+ *
+ * @see AnimationContext
+ * @see RegionBoneMapper#computeOwnedParts
+ */
+@OnlyIn(Dist.CLIENT)
+public final class ContextAnimationFactory {
+
+ private static final Logger LOGGER = LogUtils.getLogger();
+ private static final String NAMESPACE = "tiedup";
+
+ /**
+ * Cache keyed by "contextSuffix|ownedPartsHashCode".
+ * Null values are stored as sentinels for missing animations to avoid repeated lookups.
+ */
+ private static final Map CACHE = new ConcurrentHashMap<>();
+
+ /**
+ * Sentinel set used to track cache keys where the base animation was not found,
+ * so we don't log the same warning repeatedly.
+ */
+ private static final Set MISSING_WARNED = ConcurrentHashMap.newKeySet();
+
+ private ContextAnimationFactory() {}
+
+ /**
+ * Create (or retrieve from cache) a context animation with the given parts disabled.
+ *
+ * If no parts need disabling, the base animation is returned as-is (no copy needed).
+ * If the base animation is not found in the PlayerAnimator registry, returns null.
+ *
+ * @param context the current animation context (determines which context_*.json to load)
+ * @param disabledParts set of PlayerAnimator part names to disable on the context layer
+ * (e.g., {"rightArm", "leftArm"}), typically from
+ * {@link RegionBoneMapper.BoneOwnership#disabledOnContext()}
+ * @return the context animation with disabled parts suppressed, or null if not found
+ */
+ @Nullable
+ public static KeyframeAnimation create(AnimationContext context, Set disabledParts) {
+ String cacheKey = context.getAnimationSuffix() + "|" + String.join(",", new java.util.TreeSet<>(disabledParts));
+ // computeIfAbsent cannot store null values, so we handle the missing case
+ // by checking the MISSING_WARNED set to avoid redundant work.
+ KeyframeAnimation cached = CACHE.get(cacheKey);
+ if (cached != null) {
+ return cached;
+ }
+ if (MISSING_WARNED.contains(cacheKey)) {
+ return null;
+ }
+
+ KeyframeAnimation result = buildContextAnimation(context, disabledParts);
+ if (result != null) {
+ CACHE.put(cacheKey, result);
+ } else {
+ MISSING_WARNED.add(cacheKey);
+ }
+ return result;
+ }
+
+ /**
+ * Build a context animation with the specified parts disabled.
+ *
+ * Flow:
+ *
+ * - Check {@link ContextGlbRegistry} for a GLB-based context animation (takes priority)
+ * - Fall back to {@code tiedup:context_} in PlayerAnimationRegistry (JSON-based)
+ * - If no parts need disabling, return the base animation directly (immutable, shared)
+ * - Otherwise, create a mutable copy via {@link KeyframeAnimation#mutableCopy()}
+ * - Disable each part via {@link KeyframeAnimation.StateCollection#setEnabled(boolean)}
+ * - Build and return the new immutable animation
+ *
+ */
+ @Nullable
+ private static KeyframeAnimation buildContextAnimation(AnimationContext context,
+ Set disabledParts) {
+ String suffix = context.getAnimationSuffix();
+
+ // Priority 1: GLB-based context animation from ContextGlbRegistry
+ KeyframeAnimation baseAnim = ContextGlbRegistry.get(suffix);
+
+ // Priority 2: JSON-based context animation from PlayerAnimationRegistry
+ if (baseAnim == null) {
+ ResourceLocation animId = ResourceLocation.fromNamespaceAndPath(
+ NAMESPACE, "context_" + suffix
+ );
+ baseAnim = PlayerAnimationRegistry.getAnimation(animId);
+ }
+
+ if (baseAnim == null) {
+ LOGGER.warn("[V2Animation] Context animation not found for suffix: {}", suffix);
+ return null;
+ }
+
+ if (disabledParts.isEmpty()) {
+ return baseAnim;
+ }
+
+ // Create mutable copy so we can disable parts without affecting the registry/cache original
+ KeyframeAnimation.AnimationBuilder builder = baseAnim.mutableCopy();
+ disableParts(builder, disabledParts);
+ return builder.build();
+ }
+
+ /**
+ * Disable all animation axes on the specified parts.
+ *
+ * Uses {@link KeyframeAnimation.AnimationBuilder#getPart(String)} to look up parts
+ * by name, then {@link KeyframeAnimation.StateCollection#setEnabled(boolean)} to disable
+ * all axes (x, y, z, pitch, yaw, roll, and bend/bendDirection if applicable).
+ *
+ * Unknown part names are silently ignored -- this can happen if the disabled parts set
+ * includes future bone names not present in the current context animation.
+ */
+ private static void disableParts(KeyframeAnimation.AnimationBuilder builder,
+ Set disabledParts) {
+ for (String partName : disabledParts) {
+ KeyframeAnimation.StateCollection part = builder.getPart(partName);
+ if (part != null) {
+ part.setEnabled(false);
+ }
+ }
+ }
+
+ /**
+ * Clear all cached animations. Call this on resource reload or when equipped items change
+ * in a way that might invalidate cached part ownership.
+ */
+ public static void clearCache() {
+ CACHE.clear();
+ MISSING_WARNED.clear();
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/context/ContextGlbRegistry.java b/src/main/java/com/tiedup/remake/client/animation/context/ContextGlbRegistry.java
new file mode 100644
index 0000000..7035dbd
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/context/ContextGlbRegistry.java
@@ -0,0 +1,121 @@
+package com.tiedup.remake.client.animation.context;
+
+import com.tiedup.remake.client.gltf.GlbParser;
+import com.tiedup.remake.client.gltf.GltfData;
+import com.tiedup.remake.client.gltf.GltfPoseConverter;
+import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.jetbrains.annotations.Nullable;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.packs.resources.Resource;
+import net.minecraft.server.packs.resources.ResourceManager;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * Registry for context animations loaded from GLB files.
+ *
+ * Scans the {@code tiedup_contexts/} resource directory for {@code .glb} files,
+ * parses each one via {@link GlbParser}, converts to a {@link KeyframeAnimation}
+ * via {@link GltfPoseConverter#convert(GltfData)}, and stores the result keyed by
+ * the file name suffix (e.g., {@code "stand_walk"} from {@code tiedup_contexts/stand_walk.glb}).
+ *
+ * GLB context animations take priority over JSON-based PlayerAnimator context
+ * animations. This allows artists to author posture animations directly in Blender
+ * instead of hand-editing JSON keyframes.
+ *
+ * Reloaded on resource pack reload (F3+T) via the listener registered in
+ * {@link com.tiedup.remake.client.gltf.GltfClientSetup}.
+ *
+ * Thread safety: the registry field is a volatile reference to an unmodifiable map.
+ * {@link #reload} builds a new map on the reload thread then atomically swaps the
+ * reference, so the render thread never sees a partially populated registry.
+ */
+@OnlyIn(Dist.CLIENT)
+public final class ContextGlbRegistry {
+
+ private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
+
+ /** Resource directory containing context GLB files. */
+ private static final String DIRECTORY = "tiedup_contexts";
+
+ /**
+ * Registry keyed by context suffix (e.g., "stand_walk", "sit_idle").
+ * Values are fully converted KeyframeAnimations with all parts enabled.
+ *
+ * Volatile reference to an unmodifiable map. Reload builds a new map
+ * and swaps atomically; the render thread always sees a consistent snapshot.
+ */
+ private static volatile Map REGISTRY = Map.of();
+
+ private ContextGlbRegistry() {}
+
+ /**
+ * Reload all context GLB files from the resource manager.
+ *
+ * Scans {@code assets//tiedup_contexts/} for {@code .glb} files.
+ * Each file is parsed and converted to a full-body KeyframeAnimation.
+ * The context suffix is extracted from the file path:
+ * {@code tiedup_contexts/stand_walk.glb} becomes key {@code "stand_walk"}.
+ *
+ * GLB files without animation data or with parse errors are logged and skipped.
+ *
+ * @param resourceManager the current resource manager (from reload listener)
+ */
+ public static void reload(ResourceManager resourceManager) {
+ Map newRegistry = new HashMap<>();
+
+ Map resources = resourceManager.listResources(
+ DIRECTORY, loc -> loc.getPath().endsWith(".glb"));
+
+ for (Map.Entry entry : resources.entrySet()) {
+ ResourceLocation loc = entry.getKey();
+ Resource resource = entry.getValue();
+
+ // Extract suffix from path: "tiedup_contexts/stand_walk.glb" -> "stand_walk"
+ String path = loc.getPath();
+ String fileName = path.substring(path.lastIndexOf('/') + 1);
+ String suffix = fileName.substring(0, fileName.length() - 4); // strip ".glb"
+
+ try (InputStream is = resource.open()) {
+ GltfData data = GlbParser.parse(is, loc.toString());
+
+ // Convert to a full-body KeyframeAnimation (all parts enabled)
+ KeyframeAnimation anim = GltfPoseConverter.convert(data);
+ newRegistry.put(suffix, anim);
+
+ LOGGER.info("[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'", loc, suffix);
+ } catch (Exception e) {
+ LOGGER.error("[GltfPipeline] Failed to load context GLB: {}", loc, e);
+ }
+ }
+
+ // Atomic swap: render thread never sees a partially populated registry
+ REGISTRY = Collections.unmodifiableMap(newRegistry);
+ LOGGER.info("[ContextGlb] Loaded {} context GLB animations", newRegistry.size());
+ }
+
+ /**
+ * Get a context animation by suffix.
+ *
+ * @param contextSuffix the context suffix (e.g., "stand_walk", "sit_idle")
+ * @return the KeyframeAnimation, or null if no GLB was found for this suffix
+ */
+ @Nullable
+ public static KeyframeAnimation get(String contextSuffix) {
+ return REGISTRY.get(contextSuffix);
+ }
+
+ /**
+ * Clear all cached context animations.
+ * Called on resource reload and world unload.
+ */
+ public static void clear() {
+ REGISTRY = Map.of();
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java b/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java
new file mode 100644
index 0000000..73ff8d2
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/context/GlbAnimationResolver.java
@@ -0,0 +1,151 @@
+package com.tiedup.remake.client.animation.context;
+
+import com.tiedup.remake.client.gltf.GltfCache;
+import com.tiedup.remake.client.gltf.GltfData;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import org.jetbrains.annotations.Nullable;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Resolves which named animation to play from a GLB file based on the current
+ * {@link AnimationContext}. Implements three features:
+ *
+ *
+ * - Context-based resolution with fallback chain — tries progressively
+ * less specific animation names until one is found:
+ *
SitStruggle -> Struggle -> SitIdle -> Sit -> Idle -> null
+ * - Animation variants — if {@code Struggle.1}, {@code Struggle.2},
+ * {@code Struggle.3} exist in the GLB, one is picked at random each time
+ * - Shared animation templates — animations can come from a separate GLB
+ * file (passed as {@code animationSource} to {@link #resolveAnimationData})
+ *
+ *
+ * This class is stateless and thread-safe. All methods are static.
+ */
+@OnlyIn(Dist.CLIENT)
+public final class GlbAnimationResolver {
+
+ private GlbAnimationResolver() {}
+
+ /**
+ * Resolve the animation data source.
+ * If {@code animationSource} is non-null, load that GLB for animations
+ * (shared template). Otherwise use the item's own model GLB.
+ *
+ * @param itemModelLoc the item's GLB model resource location
+ * @param animationSource optional separate GLB containing shared animations
+ * @return parsed GLB data, or null if loading failed
+ */
+ @Nullable
+ public static GltfData resolveAnimationData(ResourceLocation itemModelLoc,
+ @Nullable ResourceLocation animationSource) {
+ ResourceLocation source = animationSource != null ? animationSource : itemModelLoc;
+ return GltfCache.get(source);
+ }
+
+ /**
+ * Resolve the best animation name from a GLB for the given context.
+ * Supports variant selection ({@code Struggle.1}, {@code Struggle.2} -> random pick)
+ * and full-body animations ({@code FullWalk}, {@code FullStruggle}).
+ *
+ * Fallback chain (Full variants checked first at each step):
+ *
+ * FullSitStruggle -> SitStruggle -> FullStruggle -> Struggle
+ * -> FullSitIdle -> SitIdle -> FullSit -> Sit
+ * -> FullIdle -> Idle -> null
+ *
+ *
+ * @param data the parsed GLB data containing named animations
+ * @param context the current animation context (posture + action)
+ * @return the animation name to use, or null to use the default (first) clip
+ */
+ @Nullable
+ public static String resolve(GltfData data, AnimationContext context) {
+ String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
+ String variant = context.getGlbVariant(); // "Idle" or "Struggle"
+
+ // 1. Exact match: "FullSitIdle" then "SitIdle" (with variants)
+ String exact = prefix + variant;
+ if (!exact.isEmpty()) {
+ String picked = pickWithVariants(data, "Full" + exact);
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, exact);
+ if (picked != null) return picked;
+ }
+
+ // 2. For struggles: try "FullStruggle" then "Struggle" (with variants)
+ if (context.isStruggling()) {
+ String picked = pickWithVariants(data, "FullStruggle");
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, "Struggle");
+ if (picked != null) return picked;
+ }
+
+ // 3. Context-only: "FullSit" then "Sit" (with variants)
+ if (!prefix.isEmpty()) {
+ String picked = pickWithVariants(data, "Full" + prefix);
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, prefix);
+ if (picked != null) return picked;
+ }
+
+ // 4. Variant-only: "FullIdle" then "Idle" (with variants)
+ {
+ String picked = pickWithVariants(data, "Full" + variant);
+ if (picked != null) return picked;
+ picked = pickWithVariants(data, variant);
+ if (picked != null) return picked;
+ }
+
+ // 5. Default: return null = use first animation clip in GLB
+ return null;
+ }
+
+ /**
+ * Look for an animation by base name, including numbered variants.
+ *
+ * - If "Struggle" exists alone, return "Struggle"
+ * - If "Struggle.1" and "Struggle.2" exist, pick one randomly
+ * - If both "Struggle" and "Struggle.1" exist, include all in the random pool
+ *
+ *
+ * Variant numbering starts at 1 and tolerates a missing {@code .1}
+ * (continues to check {@code .2}). Gaps after index 1 stop the scan.
+ * For example, {@code Struggle.1, Struggle.3} would only find
+ * {@code Struggle.1} because the gap at index 2 stops iteration.
+ * However, if only {@code Struggle.2} exists (no {@code .1}), it will
+ * still be found because the scan skips the first gap.
+ *
+ * @param data the parsed GLB data
+ * @param baseName the base animation name (e.g., "Struggle", "SitIdle")
+ * @return the selected animation name, or null if no match found
+ */
+ @Nullable
+ private static String pickWithVariants(GltfData data, String baseName) {
+ Map anims = data.namedAnimations();
+ List candidates = new ArrayList<>();
+
+ if (anims.containsKey(baseName)) {
+ candidates.add(baseName);
+ }
+
+ // Check numbered variants: baseName.1, baseName.2, ...
+ for (int i = 1; i <= 99; i++) {
+ String variantName = baseName + "." + i;
+ if (anims.containsKey(variantName)) {
+ candidates.add(variantName);
+ } else if (i > 1) {
+ break; // Stop at first gap after .1
+ }
+ }
+
+ if (candidates.isEmpty()) return null;
+ if (candidates.size() == 1) return candidates.get(0);
+ return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/client/animation/context/RegionBoneMapper.java b/src/main/java/com/tiedup/remake/client/animation/context/RegionBoneMapper.java
new file mode 100644
index 0000000..4ea84bf
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/client/animation/context/RegionBoneMapper.java
@@ -0,0 +1,344 @@
+package com.tiedup.remake.client.animation.context;
+
+import com.tiedup.remake.v2.BodyRegionV2;
+import com.tiedup.remake.v2.bondage.IV2BondageItem;
+import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
+import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
+import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
+import java.util.*;
+import org.jetbrains.annotations.Nullable;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.ItemStack;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Maps V2 body regions to PlayerAnimator part names.
+ * Bridge between gameplay regions and animation bones.
+ *
+ * PlayerAnimator uses 6 named parts: head, body, rightArm, leftArm, rightLeg, leftLeg.
+ * This mapper translates the 14 {@link BodyRegionV2} gameplay regions into those bone names,
+ * enabling the animation system to know which bones are "owned" by equipped bondage items.
+ *
+ * Regions without a direct bone mapping (NECK, FINGERS, TAIL, WINGS) return empty sets.
+ * These regions still affect gameplay (blocking, escape difficulty) but don't directly
+ * constrain animation bones.
+ */
+@OnlyIn(Dist.CLIENT)
+public final class RegionBoneMapper {
+
+ /** All PlayerAnimator part names for the player model. */
+ public static final Set ALL_PARTS = Set.of(
+ "head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
+ );
+
+ /**
+ * Describes bone ownership for a specific item in the context of all equipped items.
+ *
+ *
+ * - {@code thisParts} — parts owned exclusively by the winning item
+ * - {@code otherParts} — parts owned by other equipped items
+ * - {@link #freeParts()} — parts not owned by any item (available for animation)
+ * - {@link #enabledParts()} — parts the winning item may animate (owned + free)
+ *
+ *
+ * When both the winning item and another item claim the same bone,
+ * the other item takes precedence (the bone goes to {@code otherParts}).
+ */
+ public record BoneOwnership(Set thisParts, Set otherParts) {
+
+ /**
+ * Parts not owned by any item. These are "free" and can be animated
+ * by the winning item IF the GLB contains keyframes for them.
+ */
+ public Set freeParts() {
+ Set free = new HashSet<>(ALL_PARTS);
+ free.removeAll(thisParts);
+ free.removeAll(otherParts);
+ return Collections.unmodifiableSet(free);
+ }
+
+ /**
+ * Parts the winning item is allowed to animate: its own parts + free parts.
+ * Free parts are only actually enabled if the GLB has keyframes for them.
+ */
+ public Set enabledParts() {
+ Set enabled = new HashSet<>(thisParts);
+ enabled.addAll(freeParts());
+ return Collections.unmodifiableSet(enabled);
+ }
+
+ /**
+ * Parts that must be disabled on the context layer: parts owned by this item
+ * (handled by item layer) + parts owned by other items (handled by their layer).
+ * This equals ALL_PARTS minus freeParts.
+ */
+ public Set disabledOnContext() {
+ Set