Clean repo for open source release

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

View File

@@ -0,0 +1,127 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Map;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraftforge.common.capabilities.AutoRegisterCapability;
import org.jetbrains.annotations.Nullable;
/**
* Capability interface for V2 bondage equipment storage.
*
* Stores ItemStacks keyed by {@link BodyRegionV2}. Multi-region items
* share the same ItemStack reference across all occupied regions.
*
* CRITICAL: The {@link AutoRegisterCapability} annotation is required
* for Forge to register this capability automatically.
*/
@AutoRegisterCapability
public interface IV2BondageEquipment {
/**
* Get the item equipped in the given region.
* @return The ItemStack in that region, or {@link ItemStack#EMPTY} — never null.
*/
ItemStack getInRegion(BodyRegionV2 region);
/**
* Set the item in a specific region. Pass {@link ItemStack#EMPTY} to clear.
*/
void setInRegion(BodyRegionV2 region, ItemStack stack);
/**
* Get all equipped items, de-duplicated. Multi-region items appear once.
* @return Unmodifiable map from one representative region to the ItemStack.
*/
Map<BodyRegionV2, ItemStack> getAllEquipped();
/**
* Check if a region has an item directly equipped in it.
*/
boolean isRegionOccupied(BodyRegionV2 region);
/**
* Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}.
* For example, if a Hood (blocks EYES/EARS/MOUTH) is equipped, EYES is blocked.
*/
boolean isRegionBlocked(BodyRegionV2 region);
/**
* Count distinct equipped items (de-duplicated for multi-region items).
*/
int getEquippedCount();
/**
* Clear all regions, removing all equipped items.
*/
void clearAll();
/**
* Serialize all equipped items to NBT.
*/
CompoundTag serializeNBT();
/**
* Deserialize equipped items from NBT, replacing current state.
*/
void deserializeNBT(CompoundTag tag);
// ========================================
// Pole leash persistence
// ========================================
/**
* Whether the player was leashed to a pole when they disconnected.
*/
boolean wasLeashedToPole();
/**
* Get the saved pole position, or null if none.
*/
@Nullable BlockPos getSavedPolePosition();
/**
* Get the saved pole dimension, or null if none.
*/
@Nullable ResourceKey<Level> getSavedPoleDimension();
/**
* Save the pole leash state for restoration on reconnect.
*/
void savePoleLeash(BlockPos pos, ResourceKey<Level> dimension);
/**
* Clear saved pole leash state.
*/
void clearSavedPoleLeash();
// ========================================
// Captor persistence
// ========================================
/**
* Whether the player had a saved captor when they disconnected.
*/
boolean hasSavedCaptor();
/**
* Get the saved captor UUID, or null if none.
*/
@Nullable UUID getSavedCaptorUUID();
/**
* Save the captor UUID for restoration on reconnect.
*/
void saveCaptorUUID(UUID uuid);
/**
* Clear saved captor state.
*/
void clearSavedCaptor();
}

View File

@@ -0,0 +1,167 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Core interface for V2 bondage items.
*
* Implemented by Item classes that can be equipped into V2 body regions.
* Provides region occupation, 3D model info, pose/animation data,
* and lifecycle hooks.
*/
public interface IV2BondageItem {
// ===== REGIONS =====
/**
* Which body regions this item occupies.
* Example: Armbinder returns {ARMS, HANDS, TORSO}.
*/
Set<BodyRegionV2> getOccupiedRegions();
/**
* Stack-aware variant. Data-driven items override this to read regions from NBT/registry.
* Default delegates to the no-arg version (backward compatible for hardcoded items).
*/
default Set<BodyRegionV2> getOccupiedRegions(ItemStack stack) {
return getOccupiedRegions();
}
/**
* Which regions this item blocks from having other items.
* Usually same as occupied, but could differ.
* Example: Hood occupies HEAD but blocks {HEAD, EYES, EARS, MOUTH}.
* Defaults to same as {@link #getOccupiedRegions()}.
*/
default Set<BodyRegionV2> getBlockedRegions() {
return getOccupiedRegions();
}
/**
* Stack-aware variant. Data-driven items override this to read blocked regions from NBT/registry.
*/
default Set<BodyRegionV2> getBlockedRegions(ItemStack stack) {
return getBlockedRegions();
}
// ===== 3D MODELS =====
/**
* Get the glTF model location (.glb file).
* Returns null for items without a 3D model (e.g., clothes-only items).
*/
@Nullable
ResourceLocation getModelLocation();
/**
* Stack-aware variant. Data-driven items override this to read model from NBT/registry.
*/
@Nullable
default ResourceLocation getModelLocation(ItemStack stack) {
return getModelLocation();
}
// ===== POSES & ANIMATIONS =====
/**
* Priority for pose conflicts. Higher value wins.
* Example: Fullbind (100) > Armbinder (50) > Handcuffs (30) > Collar (10) > None (0).
*/
int getPosePriority();
/**
* Stack-aware variant for pose priority.
*/
default int getPosePriority(ItemStack stack) {
return getPosePriority();
}
// ===== ITEM STATE =====
/**
* Difficulty to escape (for struggle minigame). Higher = harder.
*/
int getEscapeDifficulty();
/**
* Stack-aware variant for escape difficulty.
*/
default int getEscapeDifficulty(ItemStack stack) {
return getEscapeDifficulty();
}
// ===== RENDERING =====
/**
* Whether this item supports color variants (texture_red.png, etc.).
*/
boolean supportsColor();
/**
* Stack-aware variant for color support.
*/
default boolean supportsColor(ItemStack stack) {
return supportsColor();
}
/**
* Whether this item supports a slim model variant (Alex-style 3px arms).
*/
boolean supportsSlimModel();
/**
* Stack-aware variant for slim model support.
*/
default boolean supportsSlimModel(ItemStack stack) {
return supportsSlimModel();
}
/**
* Get the slim model location (.glb) for Alex-style players.
* Only meaningful if {@link #supportsSlimModel()} returns true.
*/
@Nullable
default ResourceLocation getSlimModelLocation() {
return null;
}
/**
* Stack-aware variant for slim model location.
*/
@Nullable
default ResourceLocation getSlimModelLocation(ItemStack stack) {
return getSlimModelLocation();
}
// ===== LIFECYCLE HOOKS =====
/**
* Called when this item is equipped onto an entity.
*/
default void onEquipped(ItemStack stack, LivingEntity entity) {}
/**
* Called when this item is unequipped from an entity.
*/
default void onUnequipped(ItemStack stack, LivingEntity entity) {}
/**
* Whether this item can be equipped on the given entity right now.
*/
default boolean canEquip(ItemStack stack, LivingEntity entity) {
return true;
}
/**
* Whether this item can be unequipped from the given entity right now.
*/
default boolean canUnequip(ItemStack stack, LivingEntity entity) {
return true;
}
}

View File

@@ -0,0 +1,26 @@
package com.tiedup.remake.v2.bondage;
/**
* Interface for entities that hold V2 bondage equipment internally
* (not via Forge capabilities).
*
* Implemented by EntityDamsel (Epic 4B) to allow V2EquipmentHelper
* to dispatch equipment operations without a circular dependency
* between v2.bondage and entities packages.
*
* Players use Forge capabilities instead — they do NOT implement this.
*/
public interface IV2EquipmentHolder {
/**
* Get the V2 equipment storage for this entity.
* @return The equipment instance, never null.
*/
IV2BondageEquipment getV2Equipment();
/**
* Sync the internal V2 equipment state to the entity's SynchedEntityData.
* Called by V2EquipmentHelper after write operations (equip/unequip/clear).
*/
void syncEquipmentToData();
}

View File

@@ -0,0 +1,43 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.items.V2Handcuffs;
import com.tiedup.remake.v2.furniture.FurniturePlacerItem;
import net.minecraft.world.item.Item;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
/**
* DeferredRegister for V2 bondage items.
* Separate from V2Items (which registers block items).
*/
public class V2BondageItems {
public static final DeferredRegister<Item> ITEMS = DeferredRegister.create(
ForgeRegistries.ITEMS, TiedUpMod.MOD_ID
);
public static final RegistryObject<Item> V2_HANDCUFFS = ITEMS.register(
"v2_handcuffs", V2Handcuffs::new
);
/**
* Generic data-driven bondage item. A single registered Item whose
* behavior varies per-stack via the {@code tiedup_item_id} NBT tag.
*/
public static final RegistryObject<Item> DATA_DRIVEN_ITEM = ITEMS.register(
"data_driven_item", DataDrivenBondageItem::new
);
/**
* Furniture placer item. A single registered Item that spawns
* {@link com.tiedup.remake.v2.furniture.EntityFurniture} on right-click.
* The specific furniture type is determined by the {@code tiedup_furniture_id}
* NBT tag on each stack.
*/
public static final RegistryObject<Item> FURNITURE_PLACER = ITEMS.register(
"furniture_placer", FurniturePlacerItem::new
);
}

View File

@@ -0,0 +1,41 @@
package com.tiedup.remake.v2.bondage;
import java.util.List;
import net.minecraft.world.item.ItemStack;
/**
* Result of attempting to equip a V2 bondage item.
* Carries displaced stacks for swap/supersede cases.
*/
public record V2EquipResult(Type type, List<ItemStack> displaced) {
public enum Type {
/** Item equipped successfully into empty regions. */
SUCCESS,
/** Single conflicting item was swapped out. */
SWAPPED,
/** Global item superseded multiple sub-region items. */
SUPERSEDED,
/** Item could not be equipped due to unresolvable conflicts. */
BLOCKED
}
/** Convenience: check if equip was blocked. */
public boolean isBlocked() { return type == Type.BLOCKED; }
/** Convenience: check if equip succeeded (any non-blocked result). */
public boolean isSuccess() { return type != Type.BLOCKED; }
// ===== Factory methods =====
public static final V2EquipResult SUCCESS = new V2EquipResult(Type.SUCCESS, List.of());
public static final V2EquipResult BLOCKED = new V2EquipResult(Type.BLOCKED, List.of());
public static V2EquipResult swapped(ItemStack displaced) {
return new V2EquipResult(Type.SWAPPED, List.of(displaced));
}
public static V2EquipResult superseded(List<ItemStack> displaced) {
return new V2EquipResult(Type.SUPERSEDED, List.copyOf(displaced));
}
}

View File

@@ -0,0 +1,211 @@
package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.ApiStatus;
/**
* Static utility for V2 equipment conflict resolution and pose management.
*
* Conflict rules:
* 1. One item per region — cannot equip two items on the same region
* 2. Items declare which regions they block via getBlockedRegions()
* 3. Swap: single conflict -> auto-swap if canUnequip
* 4. Supersede: new item with a global region replaces all conflicting items
*
* NOTE: Global regions do NOT automatically block sub-regions.
* Blocking is always explicit via getBlockedRegions().
* Example: Hood blocks {HEAD, EYES, EARS, MOUTH}. Handcuffs block {ARMS} only.
*/
public final class V2EquipmentManager {
private V2EquipmentManager() {}
/**
* A conflict between a new item and an existing equipped item.
*/
public record ConflictEntry(BodyRegionV2 region, ItemStack stack) {}
/**
* Check if an item can be equipped without any conflicts (stack-aware).
*/
public static boolean canEquip(IV2BondageEquipment equip, IV2BondageItem item, ItemStack newStack) {
return findAllConflicts(equip, item, newStack).isEmpty();
}
/**
* Find all conflicts that would occur if the given item were equipped.
* Checks direct occupation, existing items' blocked regions, and new item's blocked regions.
*
* @param equip The equipment capability
* @param item The V2 bondage item interface
* @param newStack The ItemStack being equipped (used for stack-aware property lookups)
*/
public static List<ConflictEntry> findAllConflicts(
IV2BondageEquipment equip,
IV2BondageItem item,
ItemStack newStack
) {
List<ConflictEntry> conflicts = new ArrayList<>();
IdentityHashMap<ItemStack, Boolean> seen = new IdentityHashMap<>();
// 1. Direct occupation conflict: new item's regions vs existing items
for (BodyRegionV2 region : item.getOccupiedRegions(newStack)) {
ItemStack existing = equip.getInRegion(region);
if (!existing.isEmpty() && !seen.containsKey(existing)) {
seen.put(existing, Boolean.TRUE);
conflicts.add(new ConflictEntry(region, existing));
}
}
// 2. Existing items' getBlockedRegions() block new item's regions
for (Map.Entry<BodyRegionV2, ItemStack> entry : equip.getAllEquipped().entrySet()) {
ItemStack equipped = entry.getValue();
if (seen.containsKey(equipped)) continue;
if (equipped.getItem() instanceof IV2BondageItem equippedItem) {
for (BodyRegionV2 newRegion : item.getOccupiedRegions(newStack)) {
if (equippedItem.getBlockedRegions(equipped).contains(newRegion)) {
seen.put(equipped, Boolean.TRUE);
conflicts.add(new ConflictEntry(entry.getKey(), equipped));
break; // One conflict per item is enough
}
}
}
}
// 3. New item's getBlockedRegions() conflict with existing items
for (BodyRegionV2 blocked : item.getBlockedRegions(newStack)) {
ItemStack existing = equip.getInRegion(blocked);
if (!existing.isEmpty() && !seen.containsKey(existing)) {
seen.put(existing, Boolean.TRUE);
conflicts.add(new ConflictEntry(blocked, existing));
}
}
return conflicts;
}
/**
* Attempt to equip an item, handling conflicts via swap or supersede.
*
* @param equip The equipment capability
* @param item The V2 bondage item interface
* @param stack The ItemStack being equipped
* @param entity The entity being equipped (never null)
* @return The result of the equip attempt
*/
@ApiStatus.Internal
public static V2EquipResult tryEquip(
IV2BondageEquipment equip,
IV2BondageItem item,
ItemStack stack,
LivingEntity entity
) {
// Fast path: no conflicts
List<ConflictEntry> conflicts = findAllConflicts(equip, item, stack);
if (conflicts.isEmpty()) {
doEquip(equip, item, stack);
return V2EquipResult.SUCCESS;
}
// De-duplicate conflicts by stack identity
IdentityHashMap<ItemStack, ConflictEntry> uniqueConflicts = new IdentityHashMap<>();
for (ConflictEntry c : conflicts) {
uniqueConflicts.putIfAbsent(c.stack(), c);
}
// Single conflict -> attempt swap
if (uniqueConflicts.size() == 1) {
ConflictEntry conflict = uniqueConflicts.values().iterator().next();
ItemStack conflictStack = conflict.stack();
if (conflictStack.getItem() instanceof IV2BondageItem conflictItem) {
if (conflictItem.canUnequip(conflictStack, entity)) {
removeAllRegionsOf(equip, conflictStack);
conflictItem.onUnequipped(conflictStack, entity);
doEquip(equip, item, stack);
return V2EquipResult.swapped(conflictStack);
}
} else {
// Non-V2 item in region — log warning and remove
TiedUpMod.LOGGER.warn(
"V2EquipmentManager: swapping out non-V2 item {} from equipment",
conflictStack
);
removeAllRegionsOf(equip, conflictStack);
doEquip(equip, item, stack);
return V2EquipResult.swapped(conflictStack);
}
return V2EquipResult.BLOCKED;
}
// Multiple conflicts + new item occupies a global region -> supersede
boolean newItemHasGlobal = false;
for (BodyRegionV2 region : item.getOccupiedRegions(stack)) {
if (region.isGlobal()) {
newItemHasGlobal = true;
break;
}
}
if (newItemHasGlobal) {
// Check all conflicting items can be unequipped
for (ConflictEntry c : uniqueConflicts.values()) {
if (c.stack().getItem() instanceof IV2BondageItem ci) {
if (!ci.canUnequip(c.stack(), entity)) {
return V2EquipResult.BLOCKED;
}
}
}
// Collect displaced stacks, then unequip all conflicts
List<ItemStack> displaced = new ArrayList<>();
for (ConflictEntry c : uniqueConflicts.values()) {
ItemStack cs = c.stack();
removeAllRegionsOf(equip, cs);
if (cs.getItem() instanceof IV2BondageItem ci) {
ci.onUnequipped(cs, entity);
} else {
TiedUpMod.LOGGER.warn("[V2] Supersede removed non-V2 item {} from equipment", cs);
}
displaced.add(cs);
}
doEquip(equip, item, stack);
return V2EquipResult.superseded(displaced);
}
return V2EquipResult.BLOCKED;
}
/**
* Place an item into all its occupied regions.
*/
private static void doEquip(
IV2BondageEquipment equip,
IV2BondageItem item,
ItemStack stack
) {
for (BodyRegionV2 region : item.getOccupiedRegions(stack)) {
equip.setInRegion(region, stack);
}
}
/**
* Remove an item from all regions by identity scan.
* Uses full BodyRegionV2.values() scan to prevent orphan stacks.
*/
public static void removeAllRegionsOf(IV2BondageEquipment equip, ItemStack stack) {
for (BodyRegionV2 region : BodyRegionV2.values()) {
//noinspection ObjectEquality — intentional identity comparison
if (equip.getInRegion(region) == stack) {
equip.setInRegion(region, ItemStack.EMPTY);
}
}
}
}

View File

@@ -0,0 +1,321 @@
package com.tiedup.remake.v2.bondage.capability;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import java.util.Collections;
import java.util.EnumMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.StringTag;
import net.minecraft.nbt.Tag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* Concrete implementation of {@link IV2BondageEquipment}.
*
* Storage: EnumMap with 14 regions, each initialized to {@link ItemStack#EMPTY}.
* Multi-region items share the same ItemStack reference across all occupied regions.
*
* NBT format:
* - Root key: "V2BondageRegions" CompoundTag
* - Per item: "REGION_NAME" -> ItemStack NBT
* - Multi-region secondary slots: "REGION_NAME_also" -> ListTag of StringTag (other region names)
* - Only non-empty regions are persisted
*/
public class V2BondageEquipment implements IV2BondageEquipment {
private static final String NBT_ROOT_KEY = "V2BondageRegions";
private static final String NBT_ALSO_SUFFIX = "_also";
private final EnumMap<BodyRegionV2, ItemStack> regions;
// Pole leash persistence
@Nullable private BlockPos savedPolePosition;
@Nullable private ResourceKey<Level> savedPoleDimension;
// Captor persistence
@Nullable private UUID savedCaptorUUID;
public V2BondageEquipment() {
this.regions = new EnumMap<>(BodyRegionV2.class);
for (BodyRegionV2 region : BodyRegionV2.values()) {
regions.put(region, ItemStack.EMPTY);
}
}
@Override
public ItemStack getInRegion(BodyRegionV2 region) {
if (region == null) return ItemStack.EMPTY;
ItemStack stack = regions.get(region);
return stack != null ? stack : ItemStack.EMPTY;
}
@Override
public void setInRegion(BodyRegionV2 region, ItemStack stack) {
if (region == null) return;
regions.put(region, stack == null ? ItemStack.EMPTY : stack);
}
@Override
public Map<BodyRegionV2, ItemStack> getAllEquipped() {
// De-duplicate: multi-region items should appear only once.
// Uses IdentityHashMap to track already-seen stack references.
IdentityHashMap<ItemStack, BodyRegionV2> seen = new IdentityHashMap<>();
Map<BodyRegionV2, ItemStack> result = new LinkedHashMap<>();
for (BodyRegionV2 region : BodyRegionV2.values()) {
ItemStack stack = regions.get(region);
if (stack != null && !stack.isEmpty() && !seen.containsKey(stack)) {
seen.put(stack, region);
result.put(region, stack);
}
}
return Collections.unmodifiableMap(result);
}
@Override
public boolean isRegionOccupied(BodyRegionV2 region) {
if (region == null) return false;
ItemStack stack = regions.get(region);
return stack != null && !stack.isEmpty();
}
/**
* Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}.
* <p>
* Scans all equipped items and returns true if any item blocks this region
* (excluding self-blocking via the item's own occupied regions).
* This replaces the old O(1) parent-global hierarchy check.
*/
@Override
public boolean isRegionBlocked(BodyRegionV2 region) {
if (region == null) return false;
// Check if any equipped item's getBlockedRegions() includes this region
for (Map.Entry<BodyRegionV2, ItemStack> entry : getAllEquipped().entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem item) {
if (item.getBlockedRegions(stack).contains(region)
&& !item.getOccupiedRegions(stack).contains(region)) {
// Blocked by another item (not self-blocking via occupation)
return true;
}
}
}
return false;
}
@Override
public int getEquippedCount() {
// Count unique non-empty stacks directly, avoiding the 2-map allocation
// of getAllEquipped(). Uses identity-based dedup for multi-region items.
IdentityHashMap<ItemStack, Boolean> seen = new IdentityHashMap<>();
for (ItemStack stack : regions.values()) {
if (stack != null && !stack.isEmpty()) {
seen.put(stack, Boolean.TRUE);
}
}
return seen.size();
}
@Override
public void clearAll() {
for (BodyRegionV2 region : BodyRegionV2.values()) {
regions.put(region, ItemStack.EMPTY);
}
}
// ========================================
// Pole leash persistence
// ========================================
@Override
public boolean wasLeashedToPole() {
return savedPolePosition != null && savedPoleDimension != null;
}
@Override
@Nullable
public BlockPos getSavedPolePosition() {
return savedPolePosition;
}
@Override
@Nullable
public ResourceKey<Level> getSavedPoleDimension() {
return savedPoleDimension;
}
@Override
public void savePoleLeash(BlockPos pos, ResourceKey<Level> dimension) {
this.savedPolePosition = pos;
this.savedPoleDimension = dimension;
}
@Override
public void clearSavedPoleLeash() {
this.savedPolePosition = null;
this.savedPoleDimension = null;
}
// ========================================
// Captor persistence
// ========================================
@Override
public boolean hasSavedCaptor() {
return savedCaptorUUID != null;
}
@Override
@Nullable
public UUID getSavedCaptorUUID() {
return savedCaptorUUID;
}
@Override
public void saveCaptorUUID(UUID uuid) {
this.savedCaptorUUID = uuid;
}
@Override
public void clearSavedCaptor() {
this.savedCaptorUUID = null;
}
// ========================================
// NBT serialization
// ========================================
@Override
public CompoundTag serializeNBT() {
CompoundTag root = new CompoundTag();
CompoundTag regionsTag = new CompoundTag();
// Track which stacks we've already serialized (identity-based)
IdentityHashMap<ItemStack, String> serialized = new IdentityHashMap<>();
for (BodyRegionV2 region : BodyRegionV2.values()) {
ItemStack stack = regions.get(region);
if (stack == null || stack.isEmpty()) continue;
String regionName = region.name();
if (serialized.containsKey(stack)) {
// This stack was already serialized under another region.
// Add this region to the _also list of the primary region.
String primaryRegion = serialized.get(stack);
String alsoKey = primaryRegion + NBT_ALSO_SUFFIX;
ListTag alsoList;
if (regionsTag.contains(alsoKey, Tag.TAG_LIST)) {
alsoList = regionsTag.getList(alsoKey, Tag.TAG_STRING);
} else {
alsoList = new ListTag();
regionsTag.put(alsoKey, alsoList);
}
alsoList.add(StringTag.valueOf(regionName));
} else {
// First time seeing this stack — serialize it
regionsTag.put(regionName, stack.save(new CompoundTag()));
serialized.put(stack, regionName);
}
}
root.put(NBT_ROOT_KEY, regionsTag);
// Pole leash persistence
if (savedPolePosition != null && savedPoleDimension != null) {
root.putLong("pole_position", savedPolePosition.asLong());
root.putString("pole_dimension", savedPoleDimension.location().toString());
}
// Captor persistence
if (savedCaptorUUID != null) {
root.putUUID("captor_uuid", savedCaptorUUID);
}
return root;
}
@Override
public void deserializeNBT(CompoundTag tag) {
clearAll();
if (!tag.contains(NBT_ROOT_KEY, Tag.TAG_COMPOUND)) return;
CompoundTag regionsTag = tag.getCompound(NBT_ROOT_KEY);
// First pass: load primary region stacks
Set<String> allKeys = regionsTag.getAllKeys();
Map<String, ItemStack> loadedStacks = new LinkedHashMap<>();
for (String key : allKeys) {
if (key.endsWith(NBT_ALSO_SUFFIX)) continue; // Handle in second pass
BodyRegionV2 region = BodyRegionV2.fromName(key);
if (region == null) continue;
CompoundTag stackTag = regionsTag.getCompound(key);
ItemStack stack = ItemStack.of(stackTag);
if (stack.isEmpty()) continue;
regions.put(region, stack);
loadedStacks.put(key, stack);
}
// Second pass: process _also entries to share the same ItemStack reference
for (String key : allKeys) {
if (!key.endsWith(NBT_ALSO_SUFFIX)) continue;
String primaryRegionName = key.substring(
0, key.length() - NBT_ALSO_SUFFIX.length()
);
ItemStack primaryStack = loadedStacks.get(primaryRegionName);
if (primaryStack == null) continue;
ListTag alsoList = regionsTag.getList(key, Tag.TAG_STRING);
for (int i = 0; i < alsoList.size(); i++) {
String alsoRegionName = alsoList.getString(i);
BodyRegionV2 alsoRegion = BodyRegionV2.fromName(alsoRegionName);
if (alsoRegion != null) {
regions.put(alsoRegion, primaryStack); // Same reference
}
}
}
// Pole leash persistence
if (tag.contains("pole_position") && tag.contains("pole_dimension")) {
try {
savedPolePosition = BlockPos.of(tag.getLong("pole_position"));
savedPoleDimension = ResourceKey.create(Registries.DIMENSION,
new ResourceLocation(tag.getString("pole_dimension")));
} catch (net.minecraft.ResourceLocationException e) {
com.tiedup.remake.core.TiedUpMod.LOGGER.warn(
"Invalid pole dimension in NBT, clearing saved pole data: {}", e.getMessage());
savedPolePosition = null;
savedPoleDimension = null;
}
} else {
savedPolePosition = null;
savedPoleDimension = null;
}
// Captor persistence
if (tag.hasUUID("captor_uuid")) {
savedCaptorUUID = tag.getUUID("captor_uuid");
} else {
savedCaptorUUID = null;
}
}
}

View File

@@ -0,0 +1,62 @@
package com.tiedup.remake.v2.bondage.capability;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraftforge.common.capabilities.Capability;
import net.minecraftforge.common.capabilities.CapabilityManager;
import net.minecraftforge.common.capabilities.CapabilityToken;
import net.minecraftforge.common.capabilities.ICapabilitySerializable;
import net.minecraftforge.common.util.LazyOptional;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Forge capability provider for V2 bondage equipment.
* Handles capability token, lazy optional lifecycle, and NBT serialization.
*/
public class V2BondageEquipmentProvider
implements ICapabilitySerializable<CompoundTag>
{
public static final Capability<IV2BondageEquipment> V2_BONDAGE_EQUIPMENT =
CapabilityManager.get(new CapabilityToken<>() {});
private final V2BondageEquipment equipment;
private final LazyOptional<IV2BondageEquipment> optional;
public V2BondageEquipmentProvider() {
this.equipment = new V2BondageEquipment();
this.optional = LazyOptional.of(() -> equipment);
}
@Override
public @NotNull <T> LazyOptional<T> getCapability(
@NotNull Capability<T> cap,
@Nullable Direction side
) {
if (cap == V2_BONDAGE_EQUIPMENT) {
return optional.cast();
}
return LazyOptional.empty();
}
@Override
@SuppressWarnings("null")
public @NotNull CompoundTag serializeNBT() {
return equipment.serializeNBT();
}
@Override
public void deserializeNBT(CompoundTag tag) {
equipment.deserializeNBT(tag);
}
/**
* Invalidate the LazyOptional. Call when the entity is removed
* or during player clone to prevent memory leaks.
*/
public void invalidate() {
optional.invalidate();
}
}

View File

@@ -0,0 +1,257 @@
package com.tiedup.remake.v2.bondage.capability;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.V2EquipResult;
import com.tiedup.remake.v2.bondage.V2EquipmentManager;
import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
import com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment;
import java.util.Map;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Static API for V2 bondage equipment operations.
*
* READ methods work on any side. WRITE methods are server-only and return
* early if called on the client. This prevents desync and ensures the server
* is the authority for equipment state.
*
* Currently dispatches only for Players (via Forge capability).
* Phase 6 will add Damsel/ArmorStand support.
*/
public final class V2EquipmentHelper {
private V2EquipmentHelper() {}
// ==================== READ ====================
/**
* Get the V2 equipment capability for an entity.
* @return The capability, or null if the entity doesn't support V2 equipment.
*/
@Nullable
public static IV2BondageEquipment getEquipment(LivingEntity entity) {
if (entity == null) return null;
if (entity instanceof Player player) {
return player.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.orElse(null);
}
// V2 equipment holders (EntityDamsel, etc.)
if (entity instanceof IV2EquipmentHolder holder) {
return holder.getV2Equipment();
}
return null;
}
/**
* Get the item in a specific region for an entity.
* @return The ItemStack, or {@link ItemStack#EMPTY} if empty or entity unsupported.
*/
public static ItemStack getInRegion(LivingEntity entity, BodyRegionV2 region) {
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return ItemStack.EMPTY;
return equip.getInRegion(region);
}
/**
* Check if a region is directly occupied on the given entity.
*/
public static boolean isRegionOccupied(LivingEntity entity, BodyRegionV2 region) {
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return false;
return equip.isRegionOccupied(region);
}
/**
* Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}.
*/
public static boolean isRegionBlocked(LivingEntity entity, BodyRegionV2 region) {
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return false;
return equip.isRegionBlocked(region);
}
/**
* Get all equipped items (de-duplicated) for an entity.
* @return Unmodifiable map, or empty map if entity unsupported.
*/
public static Map<BodyRegionV2, ItemStack> getAllEquipped(LivingEntity entity) {
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return Map.of();
return equip.getAllEquipped();
}
/**
* Check if the entity has any V2 equipment at all.
*/
public static boolean hasAnyEquipment(LivingEntity entity) {
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return false;
return equip.getEquippedCount() > 0;
}
// ==================== WRITE (server-only) ====================
/**
* Equip a V2 bondage item onto an entity.
*
* Validates the item implements {@link IV2BondageItem}, copies the stack,
* runs conflict resolution via {@link V2EquipmentManager#tryEquip},
* and fires lifecycle hooks.
*
* @param entity The target entity (must be server-side)
* @param stack The ItemStack to equip (must implement IV2BondageItem)
* @return The equip result, or {@link V2EquipResult#BLOCKED} if invalid
*/
public static V2EquipResult equipItem(LivingEntity entity, ItemStack stack) {
if (entity.level().isClientSide) return V2EquipResult.BLOCKED;
if (stack == null || stack.isEmpty()) return V2EquipResult.BLOCKED;
if (!(stack.getItem() instanceof IV2BondageItem item)) return V2EquipResult.BLOCKED;
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return V2EquipResult.BLOCKED;
if (!item.canEquip(stack, entity)) return V2EquipResult.BLOCKED;
// Copy the stack so the original isn't mutated
ItemStack equipCopy = stack.copy();
V2EquipResult result = V2EquipmentManager.tryEquip(equip, item, equipCopy, entity);
if (result.isSuccess()) {
item.onEquipped(equipCopy, entity);
sync(entity);
}
return result;
}
/**
* Unequip the item from a region, respecting canUnequip.
*
* @param entity The target entity (must be server-side)
* @param region The region to unequip from
* @return The removed ItemStack, or {@link ItemStack#EMPTY} if nothing removed
*/
public static ItemStack unequipFromRegion(LivingEntity entity, BodyRegionV2 region) {
return unequipFromRegion(entity, region, false);
}
/**
* Unequip the item from a region, optionally forcing removal.
*
* @param entity The target entity (must be server-side)
* @param region The region to unequip from
* @param force If true, bypass canUnequip check
* @return The removed ItemStack, or {@link ItemStack#EMPTY} if nothing removed
*/
public static ItemStack unequipFromRegion(
LivingEntity entity,
BodyRegionV2 region,
boolean force
) {
if (entity.level().isClientSide) return ItemStack.EMPTY;
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return ItemStack.EMPTY;
ItemStack stack = equip.getInRegion(region);
if (stack.isEmpty()) return ItemStack.EMPTY;
if (!force && stack.getItem() instanceof IV2BondageItem item) {
if (!item.canUnequip(stack, entity)) {
return ItemStack.EMPTY;
}
}
// Full scan to remove all region references to this stack (identity-based)
V2EquipmentManager.removeAllRegionsOf(equip, stack);
if (stack.getItem() instanceof IV2BondageItem item) {
item.onUnequipped(stack, entity);
}
sync(entity);
return stack;
}
/**
* Clear all V2 equipment from an entity, firing onUnequipped for each.
*
* @param entity The target entity (must be server-side)
*/
public static void clearAll(LivingEntity entity) {
if (entity.level().isClientSide) return;
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return;
// Fire lifecycle hooks for each unique item before clearing
Map<BodyRegionV2, ItemStack> equipped = equip.getAllEquipped();
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem item) {
item.onUnequipped(stack, entity);
}
}
equip.clearAll();
sync(entity);
}
// ==================== SYNC ====================
/**
* Sync V2 equipment state to tracking clients.
* Sends the full serialized capability to the player and all trackers.
*/
public static void sync(LivingEntity entity) {
if (entity.level().isClientSide) return;
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return;
// IV2EquipmentHolder entities (Damsels) sync via EntityDataAccessor,
// not via packet. Trigger their EntityData sync instead.
if (entity instanceof IV2EquipmentHolder holder) {
holder.syncEquipmentToData();
return;
}
PacketSyncV2Equipment packet = new PacketSyncV2Equipment(
entity.getId(), equip.serializeNBT()
);
if (entity instanceof ServerPlayer serverPlayer) {
ModNetwork.sendToAllTrackingAndSelf(packet, serverPlayer);
} else {
// Phase 6: NPC support — send to all tracking the entity
ModNetwork.sendToAllTrackingEntity(packet, entity);
}
}
/**
* Sync V2 equipment to a specific player (used on login/start-tracking).
*/
public static void syncTo(LivingEntity entity, ServerPlayer target) {
// IV2EquipmentHolder entities sync via SynchedEntityData, not packets
if (entity instanceof IV2EquipmentHolder holder) {
holder.syncEquipmentToData();
return;
}
IV2BondageEquipment equip = getEquipment(entity);
if (equip == null) return;
PacketSyncV2Equipment packet = new PacketSyncV2Equipment(
entity.getId(), equip.serializeNBT()
);
ModNetwork.sendToPlayer(packet, target);
}
}

View File

@@ -0,0 +1,56 @@
package com.tiedup.remake.v2.bondage.client;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.util.LinkedHashMap;
import java.util.Map;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.Tag;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Resolves per-item tint colors by merging definition defaults with NBT overrides.
*
* <p>Priority (highest wins):
* <ol>
* <li>NBT tag {@code tint_colors} on the ItemStack (player dye overrides)</li>
* <li>Default tint channels from the {@link DataDrivenItemDefinition}</li>
* </ol>
*
* <p>Returns a map of tint channel name (e.g. "tintable_0") to RGB int (0xRRGGBB).
* An empty map means no tint — the renderer should use white (no color modification).</p>
*/
@OnlyIn(Dist.CLIENT)
public final class TintColorResolver {
private TintColorResolver() {}
/**
* Resolve tint colors for an ItemStack.
*
* @param stack the equipped bondage item
* @return channel-to-color map; empty if no tint channels defined or found
*/
public static Map<String, Integer> resolve(ItemStack stack) {
Map<String, Integer> result = new LinkedHashMap<>();
// 1. Load defaults from DataDrivenItemDefinition
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null && def.tintChannels() != null) {
result.putAll(def.tintChannels());
}
// 2. Override with NBT "tint_colors" (player dye overrides)
CompoundTag tag = stack.getTag();
if (tag != null && tag.contains("tint_colors", Tag.TAG_COMPOUND)) {
CompoundTag tints = tag.getCompound("tint_colors");
for (String key : tints.getAllKeys()) {
result.put(key, tints.getInt(key));
}
}
return result;
}
}

View File

@@ -0,0 +1,188 @@
package com.tiedup.remake.v2.bondage.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.tiedup.remake.client.gltf.GltfCache;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfLiveBoneReader;
import com.tiedup.remake.client.gltf.GltfMeshRenderer;
import com.tiedup.remake.client.gltf.GltfSkinningEngine;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.Map;
import java.util.Set;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.entity.LivingEntityRenderer;
import net.minecraft.client.renderer.entity.RenderLayerParent;
import net.minecraft.client.renderer.entity.layers.RenderLayer;
import net.minecraft.resources.ResourceLocation;
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.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.joml.Matrix4f;
/**
* Production render layer for V2 bondage equipment.
* Renders ALL equipped V2 bondage items as GLB meshes on any entity
* with a {@link HumanoidModel}.
*
* <p>Works for both players and NPCs. For players, reads the V2 bondage
* equipment capability. For NPCs implementing {@link IV2EquipmentHolder}
* (e.g., EntityDamsel), reads directly from the holder.
*
* <p>Each equipped item with a non-null model location gets its own
* pushPose/popPose pair. Joint matrices are computed per-GLB-model
* because different GLB models have different skeletons.
*
* <p>Unlike {@link com.tiedup.remake.client.gltf.GltfRenderLayer},
* this layer is always active (no F9 toggle guard) and renders on
* ALL entities (no local-player-only guard).
*/
@OnlyIn(Dist.CLIENT)
public class V2BondageRenderLayer<T extends LivingEntity, M extends HumanoidModel<T>>
extends RenderLayer<T, M> {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
/**
* Y alignment offset to place glTF meshes in the MC PoseStack.
* After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0),
* the PoseStack origin is at model top (1.501 blocks above feet), Y-down.
* Translating by 1.501 maps glTF feet to PoseStack feet.
*/
private static final float ALIGNMENT_Y = 1.501f;
public V2BondageRenderLayer(RenderLayerParent<T, M> renderer) {
super(renderer);
}
@Override
public void render(
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
T entity,
float limbSwing,
float limbSwingAmount,
float partialTick,
float ageInTicks,
float netHeadYaw,
float headPitch
) {
// Get V2 equipment via capability (Players) or IV2EquipmentHolder (Damsels)
IV2BondageEquipment equipment = null;
if (entity instanceof Player player) {
equipment = player.getCapability(
V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT
).orElse(null);
} else if (entity instanceof IV2EquipmentHolder holder) {
equipment = holder.getV2Equipment();
}
if (equipment == null) {
return;
}
// Get all equipped items (de-duplicated map)
Map<BodyRegionV2, ItemStack> equipped = equipment.getAllEquipped();
if (equipped.isEmpty()) {
return;
}
// Skip rendering items in regions blocked by furniture/seat provider
Set<BodyRegionV2> furnitureBlocked = Set.of();
if (entity.isPassenger() && entity.getVehicle() instanceof ISeatProvider provider) {
SeatDefinition seat = provider.getSeatForPassenger(entity);
if (seat != null) {
furnitureBlocked = seat.blockedRegions();
}
}
M parentModel = this.getParentModel();
int packedOverlay = LivingEntityRenderer.getOverlayCoords(entity, 0.0f);
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.isEmpty()) continue;
// Furniture blocks this region — skip rendering
if (furnitureBlocked.contains(entry.getKey())) continue;
// Check if the item implements IV2BondageItem
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) {
continue;
}
// Select slim model variant for Alex-style players or slim Damsels
boolean isSlim;
if (entity instanceof AbstractClientPlayer acp) {
isSlim = "slim".equals(acp.getModelName());
} else if (entity instanceof AbstractTiedUpNpc npc) {
isSlim = npc.hasSlimArms();
} else {
isSlim = false;
}
ResourceLocation modelLocation = (isSlim && bondageItem.supportsSlimModel(stack))
? bondageItem.getSlimModelLocation(stack)
: null;
if (modelLocation == null) {
modelLocation = bondageItem.getModelLocation(stack);
}
if (modelLocation == null) {
continue;
}
// Load GLB data from cache
GltfData data = GltfCache.get(modelLocation);
if (data == null) {
LOGGER.debug("[V2Render] Failed to load GLB for item {}: {}",
stack.getItem(), modelLocation);
continue;
}
// Compute joint matrices for this specific GLB model
// Each GLB has its own skeleton, so matrices are per-item
Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel(
parentModel, data, entity
);
if (joints == null) {
// Fallback to GLB-internal skinning
joints = GltfSkinningEngine.computeJointMatrices(data);
}
// Render this item's mesh
poseStack.pushPose();
poseStack.translate(0, ALIGNMENT_Y, 0);
// Check for tint channels — use per-primitive tinted rendering if present
Map<String, Integer> tintColors = TintColorResolver.resolve(stack);
if (!tintColors.isEmpty() && data.primitives().size() > 1) {
// Multi-primitive mesh with tint data: render per-primitive with colors
RenderType renderType = GltfMeshRenderer.getRenderTypeForDefaultTexture();
GltfMeshRenderer.renderSkinnedTinted(
data, joints, poseStack, buffer,
packedLight, packedOverlay, renderType, tintColors
);
} else {
// Standard path: single primitive or no tint data
GltfMeshRenderer.renderSkinned(
data, joints, poseStack, buffer,
packedLight, packedOverlay
);
}
poseStack.popPose();
}
}
}

View File

@@ -0,0 +1,195 @@
package com.tiedup.remake.v2.bondage.datadriven;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.V2BondageItems;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.items.AbstractV2BondageItem;
import java.util.Map;
import java.util.Set;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Generic Item class for all data-driven bondage items.
*
* <p>A single Forge-registered Item. Each ItemStack carries a {@code tiedup_item_id}
* NBT tag that points to a {@link DataDrivenItemDefinition} in the
* {@link DataDrivenItemRegistry}. All property methods are overridden to read
* from the definition via the stack-aware interface methods.</p>
*
* <p>The no-arg methods return safe defaults because the singleton item cannot
* know which definition to use without an ItemStack. The real values come
* exclusively from the stack-aware overrides.</p>
*/
public class DataDrivenBondageItem extends AbstractV2BondageItem {
public DataDrivenBondageItem() {
super(new Properties().stacksTo(1));
}
// ===== REGIONS (stack-aware overrides) =====
@Override
public Set<BodyRegionV2> getOccupiedRegions() {
// Safe default for the singleton — real value comes from stack-aware override
return Set.of();
}
@Override
public Set<BodyRegionV2> getOccupiedRegions(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null ? def.occupiedRegions() : Set.of();
}
@Override
public Set<BodyRegionV2> getBlockedRegions(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null ? def.blockedRegions() : Set.of();
}
// ===== 3D MODELS (stack-aware overrides) =====
@Override
@Nullable
public ResourceLocation getModelLocation() {
return null; // Safe default
}
@Override
@Nullable
public ResourceLocation getModelLocation(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null ? def.modelLocation() : null;
}
@Override
@Nullable
public ResourceLocation getSlimModelLocation(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null ? def.slimModelLocation() : null;
}
@Override
public boolean supportsSlimModel(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null && def.slimModelLocation() != null;
}
// ===== POSES & ANIMATIONS (stack-aware overrides) =====
@Override
public int getPosePriority() {
return 0; // Safe default
}
@Override
public int getPosePriority(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null ? def.posePriority() : 0;
}
// ===== ITEM STATE (stack-aware overrides) =====
@Override
public int getEscapeDifficulty(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null ? def.escapeDifficulty() : 0;
}
@Override
public boolean supportsColor(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null && def.supportsColor();
}
// ===== IHasResistance IMPLEMENTATION =====
@Override
public String getResistanceId() {
// Safe default for the singleton -- the real resistance comes from
// getBaseResistance() which bypasses the GameRules switch entirely.
return "data_driven";
}
/**
* Bypass the GameRules switch lookup entirely for data-driven items.
*
* <p>The default IHasResistance implementation calls
* {@code ModGameRules.getResistance(gameRules, getResistanceId())} which has
* a hardcoded switch for "rope", "gag", "blindfold", "collar" and defaults
* to 100 for everything else. This makes the JSON {@code escape_difficulty}
* field useless.</p>
*
* <p>Instead, we scan the entity's equipped items to find ALL data-driven items
* and return the MAX escape difficulty. This is because IHasResistance has no
* ItemStack parameter, so we cannot distinguish which specific data-driven item
* is being queried when multiple are equipped (they all share the same Item
* singleton). Returning the MAX is the safe choice: it prevents the struggle
* system from underestimating resistance.</p>
*/
@Override
public int getBaseResistance(LivingEntity entity) {
if (entity != null) {
IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(entity);
if (equip != null) {
int maxDifficulty = -1;
for (Map.Entry<BodyRegionV2, ItemStack> entry : equip.getAllEquipped().entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() == this) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null) {
maxDifficulty = Math.max(maxDifficulty, def.escapeDifficulty());
}
}
}
if (maxDifficulty >= 0) {
return maxDifficulty;
}
}
}
return 100; // safe fallback
}
@Override
public void notifyStruggle(LivingEntity entity) {
// Play a generic chain sound for data-driven items
entity.level().playSound(
null, entity.getX(), entity.getY(), entity.getZ(),
net.minecraft.sounds.SoundEvents.CHAIN_STEP,
net.minecraft.sounds.SoundSource.PLAYERS,
0.4f, 1.0f
);
}
// ===== DISPLAY NAME =====
@Override
public Component getName(ItemStack stack) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def == null) return super.getName(stack);
if (def.translationKey() != null) {
return Component.translatable(def.translationKey());
}
return Component.literal(def.displayName());
}
// ===== FACTORY =====
/**
* Create an ItemStack for a data-driven bondage item.
*
* @param itemId the definition ID (must exist in {@link DataDrivenItemRegistry})
* @return a new ItemStack with the {@code tiedup_item_id} NBT tag set,
* or {@link ItemStack#EMPTY} if the item is not registered in Forge
*/
public static ItemStack createStack(ResourceLocation itemId) {
if (V2BondageItems.DATA_DRIVEN_ITEM == null) return ItemStack.EMPTY;
ItemStack stack = new ItemStack(V2BondageItems.DATA_DRIVEN_ITEM.get());
stack.getOrCreateTag().putString(DataDrivenItemRegistry.NBT_ITEM_ID, itemId.toString());
return stack;
}
}

View File

@@ -0,0 +1,97 @@
package com.tiedup.remake.v2.bondage.datadriven;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Immutable definition for a data-driven bondage item.
*
* <p>Loaded from JSON files in {@code assets/<namespace>/tiedup_items/}.
* Each definition describes the properties of a bondage item variant
* that can be instantiated as an ItemStack with the {@code tiedup_item_id} NBT tag.</p>
*
* <p>All rendering and gameplay properties are read from this record at runtime
* via {@link DataDrivenBondageItem}'s stack-aware method overrides.</p>
*/
public record DataDrivenItemDefinition(
/** Unique identifier for this item definition (e.g., "tiedup:leather_armbinder"). */
ResourceLocation id,
/** Human-readable display name (fallback if no translation key). */
String displayName,
/** Optional translation key for localized display name. */
@Nullable String translationKey,
/** Resource location of the GLB model file. */
ResourceLocation modelLocation,
/** Optional slim (Alex-style) model variant. */
@Nullable ResourceLocation slimModelLocation,
/** Optional base texture path for color variant resolution. */
@Nullable ResourceLocation texturePath,
/** Optional separate GLB for animations (shared template). */
@Nullable ResourceLocation animationSource,
/** Body regions this item occupies. Never empty. */
Set<BodyRegionV2> occupiedRegions,
/** Body regions this item blocks. Defaults to occupiedRegions if not specified. */
Set<BodyRegionV2> blockedRegions,
/** Pose priority for conflict resolution. Higher wins. */
int posePriority,
/** Escape difficulty for the struggle minigame. */
int escapeDifficulty,
/** Whether this item can be locked with a padlock. */
boolean lockable,
/** Whether this item supports color variants. */
boolean supportsColor,
/** Default tint colors per channel (e.g. "tintable_0" -> 0x8B4513). Empty map if none. */
Map<String, Integer> tintChannels,
/**
* Optional inventory icon model location (e.g., "tiedup:item/armbinder").
*
* <p>Points to a standard {@code item/generated} model JSON that will be used
* as the inventory sprite for this data-driven item variant. When null, the
* default {@code tiedup:item/data_driven_item} model is used.</p>
*/
@Nullable ResourceLocation icon,
/**
* Optional movement style that changes how a bound player physically moves.
* Determines server-side speed reduction, jump suppression, and client animation.
*/
@Nullable com.tiedup.remake.v2.bondage.movement.MovementStyle movementStyle,
/**
* Optional per-item overrides for the movement style's default values.
* Requires {@code movementStyle} to be non-null (ignored otherwise).
*/
@Nullable com.tiedup.remake.v2.bondage.movement.MovementModifier movementModifier,
/**
* Per-animation bone whitelist. Maps animation name (e.g. "idle", "struggle")
* to the set of PlayerAnimator bone names this item is allowed to animate.
*
* <p>Valid bone names: head, body, rightArm, leftArm, rightLeg, leftLeg.</p>
*
* <p>At animation time, the effective parts for a given clip are computed as
* {@code intersection(animationBones[clipName], ownedParts)}. If the clip name
* is not present in this map (or null), the item falls back to its full
* {@code ownedParts}.</p>
*
* <p>This field is required in the JSON definition. Never null, never empty.</p>
*/
Map<String, Set<String>> animationBones
) {}

View File

@@ -0,0 +1,422 @@
package com.tiedup.remake.v2.bondage.datadriven;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.movement.MovementModifier;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
/**
* Parses JSON files into {@link DataDrivenItemDefinition} instances.
*
* <p>Uses manual field extraction (not Gson deserialization) for validation control.
* Invalid fields are logged as warnings; critical errors (missing type, empty regions,
* missing model) cause the entire definition to be skipped.</p>
*
* <p>Expected JSON format:
* <pre>{@code
* {
* "type": "tiedup:bondage_item",
* "display_name": "Leather Armbinder",
* "translation_key": "item.tiedup.leather_armbinder",
* "model": "tiedup:models/gltf/v2/armbinder/armbinder.glb",
* "slim_model": "tiedup:models/gltf/v2/armbinder/armbinder_slim.glb",
* "texture": "tiedup:textures/item/armbinder",
* "animation_source": "tiedup:models/gltf/v2/armbinder/armbinder_anim.glb",
* "regions": ["ARMS", "HANDS", "TORSO"],
* "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"],
* "pose_type": "STANDARD",
* "pose_priority": 50,
* "escape_difficulty": 150,
* "resistance_id": "armbinder",
* "lockable": true,
* "supports_color": false,
* "color_variants": []
* }
* }</pre>
*/
public final class DataDrivenItemParser {
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
private DataDrivenItemParser() {}
/**
* Parse a JSON input stream into a DataDrivenItemDefinition.
*
* @param input the JSON input stream
* @param fileId the resource location of the file (for error messages)
* @return the parsed definition, or null if the file is invalid
*/
@Nullable
public static DataDrivenItemDefinition parse(InputStream input, ResourceLocation fileId) {
try {
JsonObject root = JsonParser.parseReader(
new InputStreamReader(input, StandardCharsets.UTF_8)
).getAsJsonObject();
return parseObject(root, fileId);
} catch (Exception e) {
LOGGER.error("[DataDrivenItems] Failed to parse JSON {}: {}", fileId, e.getMessage());
return null;
}
}
/**
* Parse a JsonObject into a DataDrivenItemDefinition.
*
* @param root the parsed JSON object
* @param fileId the resource location of the file (for error messages)
* @return the parsed definition, or null if validation fails
*/
@Nullable
public static DataDrivenItemDefinition parseObject(JsonObject root, ResourceLocation fileId) {
// Validate type field
String type = getStringOrNull(root, "type");
if (!"tiedup:bondage_item".equals(type)) {
LOGGER.error("[DataDrivenItems] Skipping {}: invalid or missing type '{}' (expected 'tiedup:bondage_item')",
fileId, type);
return null;
}
// Required: display_name
String displayName = getStringOrNull(root, "display_name");
if (displayName == null || displayName.isEmpty()) {
LOGGER.error("[DataDrivenItems] Skipping {}: missing 'display_name'", fileId);
return null;
}
// Optional: translation_key
String translationKey = getStringOrNull(root, "translation_key");
// Required: model
String modelStr = getStringOrNull(root, "model");
if (modelStr == null || modelStr.isEmpty()) {
LOGGER.error("[DataDrivenItems] Skipping {}: missing 'model'", fileId);
return null;
}
ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
if (modelLocation == null) {
LOGGER.error("[DataDrivenItems] Skipping {}: invalid model ResourceLocation '{}'", fileId, modelStr);
return null;
}
// Optional: slim_model
ResourceLocation slimModelLocation = parseOptionalResourceLocation(root, "slim_model", fileId);
// Optional: texture
ResourceLocation texturePath = parseOptionalResourceLocation(root, "texture", fileId);
// Optional: animation_source
ResourceLocation animationSource = parseOptionalResourceLocation(root, "animation_source", fileId);
// Required: regions (non-empty)
Set<BodyRegionV2> occupiedRegions = parseRegions(root, "regions", fileId);
if (occupiedRegions == null || occupiedRegions.isEmpty()) {
LOGGER.error("[DataDrivenItems] Skipping {}: missing or empty 'regions'", fileId);
return null;
}
// Optional: blocked_regions (defaults to regions)
Set<BodyRegionV2> blockedRegions = parseRegions(root, "blocked_regions", fileId);
if (blockedRegions == null || blockedRegions.isEmpty()) {
blockedRegions = occupiedRegions;
}
// Optional: pose_priority (default 0)
int posePriority = getIntOrDefault(root, "pose_priority", 0);
// Optional: escape_difficulty (default 0)
int escapeDifficulty = getIntOrDefault(root, "escape_difficulty", 0);
// Optional: lockable (default true)
boolean lockable = getBooleanOrDefault(root, "lockable", true);
// Optional: supports_color (default false)
boolean supportsColor = getBooleanOrDefault(root, "supports_color", false);
// Optional: tint_channels (default empty)
Map<String, Integer> tintChannels = parseTintChannels(root, "tint_channels", fileId);
// Optional: icon (item model ResourceLocation for inventory sprite)
ResourceLocation icon = parseOptionalResourceLocation(root, "icon", fileId);
// Optional: movement_style (requires valid MovementStyle name)
MovementStyle movementStyle = null;
String movementStyleStr = getStringOrNull(root, "movement_style");
if (movementStyleStr != null && !movementStyleStr.isEmpty()) {
movementStyle = MovementStyle.fromName(movementStyleStr);
if (movementStyle == null) {
LOGGER.warn("[DataDrivenItems] In {}: unknown movement_style '{}', ignoring",
fileId, movementStyleStr);
}
}
// Optional: movement_modifier (requires movement_style to be set)
MovementModifier movementModifier = null;
if (movementStyle != null && root.has("movement_modifier") && root.get("movement_modifier").isJsonObject()) {
JsonObject modObj = root.getAsJsonObject("movement_modifier");
Float speedMul = getFloatOrNull(modObj, "speed_multiplier");
Boolean jumpDis = getBooleanOrNull(modObj, "jump_disabled");
if (speedMul != null || jumpDis != null) {
movementModifier = new MovementModifier(speedMul, jumpDis);
}
} else if (movementStyle == null && root.has("movement_modifier")) {
LOGGER.warn("[DataDrivenItems] In {}: movement_modifier ignored because movement_style is absent",
fileId);
}
// Required: animation_bones (per-animation bone whitelist)
Map<String, Set<String>> animationBones = parseAnimationBones(root, fileId);
if (animationBones == null) {
LOGGER.error("[DataDrivenItems] Skipping {}: missing or invalid 'animation_bones'", fileId);
return null;
}
// Build the item ID from the file path
// fileId is like "tiedup:tiedup_items/leather_armbinder.json"
// We want "tiedup:leather_armbinder"
String idPath = fileId.getPath();
// Strip "tiedup_items/" prefix
if (idPath.startsWith("tiedup_items/")) {
idPath = idPath.substring("tiedup_items/".length());
}
// Strip ".json" suffix
if (idPath.endsWith(".json")) {
idPath = idPath.substring(0, idPath.length() - 5);
}
ResourceLocation id = new ResourceLocation(fileId.getNamespace(), idPath);
return new DataDrivenItemDefinition(
id, displayName, translationKey, modelLocation, slimModelLocation,
texturePath, animationSource, occupiedRegions, blockedRegions,
posePriority, escapeDifficulty,
lockable, supportsColor, tintChannels, icon,
movementStyle, movementModifier, animationBones
);
}
// ===== Helper Methods =====
@Nullable
private static String getStringOrNull(JsonObject obj, String key) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
try {
return obj.get(key).getAsString();
} catch (Exception e) {
return null;
}
}
private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
return obj.get(key).getAsInt();
} catch (Exception e) {
return defaultValue;
}
}
private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
return obj.get(key).getAsBoolean();
} catch (Exception e) {
return defaultValue;
}
}
@Nullable
private static Float getFloatOrNull(JsonObject obj, String key) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
try {
return obj.get(key).getAsFloat();
} catch (Exception e) {
return null;
}
}
@Nullable
private static Boolean getBooleanOrNull(JsonObject obj, String key) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
try {
return obj.get(key).getAsBoolean();
} catch (Exception e) {
return null;
}
}
@Nullable
private static ResourceLocation parseOptionalResourceLocation(
JsonObject obj, String key, ResourceLocation fileId
) {
String value = getStringOrNull(obj, key);
if (value == null || value.isEmpty()) return null;
ResourceLocation loc = ResourceLocation.tryParse(value);
if (loc == null) {
LOGGER.warn("[DataDrivenItems] In {}: invalid ResourceLocation for '{}': '{}'", fileId, key, value);
}
return loc;
}
/**
* Parse a JSON string array into an EnumSet of BodyRegionV2.
* Unknown region names are logged as warnings and skipped.
*/
@Nullable
private static Set<BodyRegionV2> parseRegions(JsonObject obj, String key, ResourceLocation fileId) {
if (!obj.has(key) || !obj.get(key).isJsonArray()) return null;
JsonArray arr = obj.getAsJsonArray(key);
if (arr.isEmpty()) return null;
EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
for (JsonElement elem : arr) {
try {
String name = elem.getAsString().toUpperCase();
BodyRegionV2 region = BodyRegionV2.fromName(name);
if (region != null) {
regions.add(region);
} else {
LOGGER.warn("[DataDrivenItems] In {}: unknown region '{}' in '{}', skipping",
fileId, name, key);
}
} catch (Exception e) {
LOGGER.warn("[DataDrivenItems] In {}: invalid element in '{}': {}",
fileId, key, e.getMessage());
}
}
return regions.isEmpty() ? null : Collections.unmodifiableSet(regions);
}
/**
* Parse a tint_channels JSON object mapping channel names to hex color strings.
*
* <p>Example JSON:
* <pre>{@code
* "tint_channels": {
* "tintable_0": "#8B4513",
* "tintable_1": "#FF0000"
* }
* }</pre>
*
* @param obj the parent JSON object
* @param key the field name to parse
* @param fileId the source file for error messages
* @return an unmodifiable map of channel names to RGB ints, or empty map if absent
*/
private static Map<String, Integer> parseTintChannels(JsonObject obj, String key, ResourceLocation fileId) {
if (!obj.has(key) || !obj.get(key).isJsonObject()) return Map.of();
JsonObject channels = obj.getAsJsonObject(key);
Map<String, Integer> result = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
try {
String hex = entry.getValue().getAsString();
int color = Integer.parseInt(hex.startsWith("#") ? hex.substring(1) : hex, 16);
result.put(entry.getKey(), color);
} catch (NumberFormatException e) {
LOGGER.warn("[DataDrivenItems] In {}: invalid hex color '{}' for tint channel '{}'",
fileId, entry.getValue(), entry.getKey());
}
}
return Collections.unmodifiableMap(result);
}
/** Valid PlayerAnimator bone names for animation_bones validation. */
private static final Set<String> VALID_BONE_NAMES = Set.of(
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
);
/**
* Parse the {@code animation_bones} JSON object.
*
* <p>Format:
* <pre>{@code
* "animation_bones": {
* "idle": ["rightArm", "leftArm"],
* "struggle": ["rightArm", "leftArm", "body"]
* }
* }</pre>
*
* <p>Each key is an animation name, each value is a JSON array of bone name strings.
* Bone names are validated against the 6 PlayerAnimator parts. Invalid bone names
* are logged as warnings and skipped. Empty arrays or unknown-only arrays cause the
* entire animation entry to be skipped.</p>
*
* @param obj the parent JSON object
* @param fileId the source file for error messages
* @return unmodifiable map of animation name to bone set, or null if absent/invalid
*/
@Nullable
private static Map<String, Set<String>> parseAnimationBones(JsonObject obj, ResourceLocation fileId) {
if (!obj.has("animation_bones") || !obj.get("animation_bones").isJsonObject()) {
return null;
}
JsonObject bonesObj = obj.getAsJsonObject("animation_bones");
if (bonesObj.size() == 0) {
LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' is empty", fileId);
return null;
}
Map<String, Set<String>> result = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : bonesObj.entrySet()) {
String animName = entry.getKey();
JsonElement value = entry.getValue();
if (!value.isJsonArray()) {
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] is not an array, skipping",
fileId, animName);
continue;
}
JsonArray boneArray = value.getAsJsonArray();
Set<String> bones = new HashSet<>();
for (JsonElement boneElem : boneArray) {
try {
String boneName = boneElem.getAsString();
if (VALID_BONE_NAMES.contains(boneName)) {
bones.add(boneName);
} else {
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] contains unknown bone '{}', skipping",
fileId, animName, boneName);
}
} catch (Exception e) {
LOGGER.warn("[DataDrivenItems] In {}: invalid element in animation_bones['{}']",
fileId, animName);
}
}
if (!bones.isEmpty()) {
result.put(animName, Collections.unmodifiableSet(bones));
} else {
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] resolved to empty set, skipping",
fileId, animName);
}
}
if (result.isEmpty()) {
LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' has no valid entries", fileId);
return null;
}
return Collections.unmodifiableMap(result);
}
}

View File

@@ -0,0 +1,104 @@
package com.tiedup.remake.v2.bondage.datadriven;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Thread-safe registry for data-driven bondage item definitions.
*
* <p>Populated by the reload listener that scans {@code tiedup_items/} JSON files.
* Uses volatile atomic swap (same pattern as {@link
* com.tiedup.remake.client.animation.context.ContextGlbRegistry}) to ensure
* the render thread always sees a consistent snapshot.</p>
*
* <p>Lookup methods accept either a {@link ResourceLocation} ID directly
* or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).</p>
*/
public final class DataDrivenItemRegistry {
/** NBT key storing the data-driven item ID on ItemStacks. */
public static final String NBT_ITEM_ID = "tiedup_item_id";
/**
* Volatile reference to an unmodifiable map. Reload builds a new map
* and swaps atomically; consumer threads always see a consistent snapshot.
*/
private static volatile Map<ResourceLocation, DataDrivenItemDefinition> DEFINITIONS = Map.of();
private DataDrivenItemRegistry() {}
/**
* Atomically replace all definitions with a new set.
* Called by the reload listener after parsing all JSON files.
*
* @param newDefs the new definitions map (will be defensively copied)
*/
public static void reload(Map<ResourceLocation, DataDrivenItemDefinition> newDefs) {
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
}
/**
* Atomically merge new definitions into the existing registry.
*
* <p>On an integrated server, both the client (assets/) and server (data/) reload
* listeners populate this registry. Using {@link #reload} would cause the second
* listener to overwrite the first's definitions. This method builds a new map
* from the existing snapshot + the new entries, then swaps atomically.</p>
*
* @param newDefs the definitions to merge (will overwrite existing entries with same key)
*/
public static void mergeAll(Map<ResourceLocation, DataDrivenItemDefinition> newDefs) {
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(DEFINITIONS);
merged.putAll(newDefs);
DEFINITIONS = Collections.unmodifiableMap(merged);
}
/**
* Get a definition by its unique ID.
*
* @param id the definition ID (e.g., "tiedup:leather_armbinder")
* @return the definition, or null if not found
*/
@Nullable
public static DataDrivenItemDefinition get(ResourceLocation id) {
return DEFINITIONS.get(id);
}
/**
* Get a definition from an ItemStack by reading the {@code tiedup_item_id} NBT tag.
*
* @param stack the ItemStack to inspect
* @return the definition, or null if the stack is empty, has no tag, or the ID is unknown
*/
@Nullable
public static DataDrivenItemDefinition get(ItemStack stack) {
if (stack.isEmpty()) return null;
CompoundTag tag = stack.getTag();
if (tag == null || !tag.contains(NBT_ITEM_ID)) return null;
ResourceLocation id = ResourceLocation.tryParse(tag.getString(NBT_ITEM_ID));
if (id == null) return null;
return DEFINITIONS.get(id);
}
/**
* Get all registered definitions.
*
* @return unmodifiable collection of all definitions
*/
public static Collection<DataDrivenItemDefinition> getAll() {
return DEFINITIONS.values();
}
/**
* Clear all definitions. Called on world unload or for testing.
*/
public static void clear() {
DEFINITIONS = Map.of();
}
}

View File

@@ -0,0 +1,79 @@
package com.tiedup.remake.v2.bondage.datadriven;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Resource reload listener that scans {@code assets/<namespace>/tiedup_items/}
* for JSON files and populates the {@link DataDrivenItemRegistry}.
*
* <p>Registered via {@link net.minecraftforge.client.event.RegisterClientReloadListenersEvent}
* in {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
*
* <p>Follows the same pattern as {@link com.tiedup.remake.client.animation.context.ContextGlbRegistry}:
* prepare phase is a no-op, apply phase scans + parses + atomic-swaps the registry.</p>
*/
public class DataDrivenItemReloadListener extends SimplePreparableReloadListener<Void> {
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
/** Resource directory containing item definition JSON files. */
private static final String DIRECTORY = "tiedup_items";
@Override
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
// No preparation needed — parsing happens in apply phase
return null;
}
@Override
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
Map<ResourceLocation, DataDrivenItemDefinition> newDefs = new HashMap<>();
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
DIRECTORY, loc -> loc.getPath().endsWith(".json")
);
int skipped = 0;
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
ResourceLocation fileId = entry.getKey();
Resource resource = entry.getValue();
try (InputStream input = resource.open()) {
DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId);
if (def != null) {
// Check for duplicate IDs
if (newDefs.containsKey(def.id())) {
LOGGER.warn("[DataDrivenItems] Duplicate item ID '{}' from file '{}' — overwriting previous definition",
def.id(), fileId);
}
newDefs.put(def.id(), def);
LOGGER.debug("[DataDrivenItems] Loaded: {} -> '{}'", def.id(), def.displayName());
} else {
skipped++;
}
} catch (Exception e) {
LOGGER.error("[DataDrivenItems] Failed to read resource {}: {}", fileId, e.getMessage());
skipped++;
}
}
// Merge into the registry (not replace) so the server listener doesn't
// overwrite client-only definitions on integrated server
DataDrivenItemRegistry.mergeAll(newDefs);
LOGGER.info("[DataDrivenItems] Loaded {} item definitions ({} skipped) from {} JSON files",
newDefs.size(), skipped, resources.size());
}
}

View File

@@ -0,0 +1,81 @@
package com.tiedup.remake.v2.bondage.datadriven;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Server-side resource reload listener that scans {@code data/<namespace>/tiedup_items/}
* for JSON files and populates the {@link DataDrivenItemRegistry}.
*
* <p>This is the server counterpart to {@link DataDrivenItemReloadListener} (client-side,
* which scans {@code assets/}). On a dedicated server, only this listener runs.
* On an integrated server (singleplayer), both listeners run -- the last one to apply
* wins the atomic swap, but they parse identical JSON content so the result is the same.</p>
*
* <p>Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in
* {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.</p>
*/
public class DataDrivenItemServerReloadListener extends SimplePreparableReloadListener<Void> {
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
/** Resource directory containing item definition JSON files (under data/). */
private static final String DIRECTORY = "tiedup_items";
@Override
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
// No preparation needed -- parsing happens in apply phase
return null;
}
@Override
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
Map<ResourceLocation, DataDrivenItemDefinition> newDefs = new HashMap<>();
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
DIRECTORY, loc -> loc.getPath().endsWith(".json")
);
int skipped = 0;
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
ResourceLocation fileId = entry.getKey();
Resource resource = entry.getValue();
try (InputStream input = resource.open()) {
DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId);
if (def != null) {
// Check for duplicate IDs
if (newDefs.containsKey(def.id())) {
LOGGER.warn("[DataDrivenItems] Server: Duplicate item ID '{}' from file '{}' -- overwriting previous definition",
def.id(), fileId);
}
newDefs.put(def.id(), def);
LOGGER.debug("[DataDrivenItems] Server loaded: {} -> '{}'", def.id(), def.displayName());
} else {
skipped++;
}
} catch (Exception e) {
LOGGER.error("[DataDrivenItems] Server: Failed to read resource {}: {}", fileId, e.getMessage());
skipped++;
}
}
// Merge into the registry (not replace) so the client listener's
// definitions aren't overwritten on integrated server
DataDrivenItemRegistry.mergeAll(newDefs);
LOGGER.info("[DataDrivenItems] Server loaded {} item definitions ({} skipped) from {} JSON files",
newDefs.size(), skipped, resources.size());
}
}

View File

@@ -0,0 +1,120 @@
package com.tiedup.remake.v2.bondage.items;
import com.tiedup.remake.items.base.IHasResistance;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.V2EquipResult;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
* Base class for V2 bondage items.
*
* Provides:
* - Self-equip via right-click in air (use())
* - Equip on target via right-click on entity (interactLivingEntity())
* - Lock-aware canUnequip() bridging IV2BondageItem and ILockable
* - Lock/resistance tooltips
*
* Subclasses implement: getOccupiedRegions(), getModelLocation(), getPosePriority(),
* getResistanceId(), notifyStruggle().
*/
public abstract class AbstractV2BondageItem extends Item
implements IV2BondageItem, ILockable, IHasResistance {
protected AbstractV2BondageItem(Properties properties) {
super(properties);
}
// ===== EQUIP: SELF (left-click hold with tying duration) =====
// Self-equip is handled by SelfBondageInputHandler (left-click hold) which sends
// PacketSelfBondage, routed to handleV2SelfBondage() with tying progress bar.
// Right-click in air does nothing for self-equip — consistent with V1 behavior.
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
return InteractionResultHolder.pass(player.getItemInHand(hand));
}
// ===== EQUIP: ON TARGET (right-click on entity) =====
@Override
public InteractionResult interactLivingEntity(
ItemStack stack, Player player, LivingEntity target, InteractionHand hand
) {
// Client returns SUCCESS for arm swing animation. Server may reject —
// minor visual desync is accepted Forge pattern (same as vanilla food/bow).
if (player.level().isClientSide) {
return InteractionResult.SUCCESS;
}
// Cannot equip if player's arms are restrained
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) {
return InteractionResult.PASS;
}
// Distance + line-of-sight validation
if (player.distanceTo(target) > 4.0 || !player.hasLineOfSight(target)) {
return InteractionResult.PASS;
}
V2EquipResult result = V2EquipmentHelper.equipItem(target, stack);
if (result.isSuccess()) {
// Drop displaced items at target's feet
for (ItemStack displaced : result.displaced()) {
target.spawnAtLocation(displaced);
}
stack.shrink(1);
return InteractionResult.SUCCESS;
}
return InteractionResult.PASS;
}
// ===== LOCK-AWARE CANUNEQUIP =====
@Override
public boolean canUnequip(ItemStack stack, LivingEntity entity) {
return !isLocked(stack);
}
// ===== TOOLTIPS =====
@Override
public void appendHoverText(
ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag
) {
super.appendHoverText(stack, level, tooltip, flag);
// Lock status from ILockable
appendLockTooltip(stack, tooltip);
// Escape difficulty
int difficulty = getEscapeDifficulty(stack);
if (difficulty > 0) {
tooltip.add(Component.translatable("item.tiedup.tooltip.escape_difficulty", difficulty)
.withStyle(ChatFormatting.GRAY));
}
}
// ===== IV2BondageItem DEFAULTS =====
@Override
public int getEscapeDifficulty() { return 0; }
@Override
public boolean supportsColor() { return false; }
@Override
public boolean supportsSlimModel() { return false; }
}

View File

@@ -0,0 +1,54 @@
package com.tiedup.remake.v2.bondage.items;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
/**
* V2 Handcuffs — first V2 bondage item.
*
* Occupies ARMS only. Mittens (HANDS) can coexist on top.
* Uses existing cuffs_prototype.glb for 3D rendering.
*/
public class V2Handcuffs extends AbstractV2BondageItem {
private static final Set<BodyRegionV2> REGIONS =
Collections.unmodifiableSet(EnumSet.of(BodyRegionV2.ARMS));
private static final ResourceLocation MODEL = new ResourceLocation(
TiedUpMod.MOD_ID, "models/gltf/v2/handcuffs/cuffs_prototype.glb"
);
public V2Handcuffs() {
super(new Properties().stacksTo(1));
}
@Override
public Set<BodyRegionV2> getOccupiedRegions() { return REGIONS; }
@Override
public ResourceLocation getModelLocation() { return MODEL; }
@Override
public int getPosePriority() { return 30; }
@Override
public int getEscapeDifficulty() { return 100; }
@Override
public String getResistanceId() { return "handcuffs"; }
@Override
public void notifyStruggle(LivingEntity entity) {
entity.level().playSound(
null, entity.getX(), entity.getY(), entity.getZ(),
net.minecraft.sounds.SoundEvents.CHAIN_STEP,
net.minecraft.sounds.SoundSource.PLAYERS,
0.4f, 1.0f
);
}
}

View File

@@ -0,0 +1,21 @@
package com.tiedup.remake.v2.bondage.movement;
import org.jetbrains.annotations.Nullable;
/**
* Optional per-item overrides for movement style defaults.
* Parsed from the {@code "movement_modifier"} JSON object.
*
* <p>Null fields fall back to the style's defaults. Only the winning item's
* modifier is used (lower-severity items' modifiers are ignored).</p>
*
* <p>Requires a {@code movement_style} to be set on the same item definition.
* The parser ignores {@code movement_modifier} if {@code movement_style} is absent.</p>
*/
public record MovementModifier(
/** Override speed multiplier, or null to use style default. */
@Nullable Float speedMultiplier,
/** Override jump disabled flag, or null to use style default. */
@Nullable Boolean jumpDisabled
) {}

View File

@@ -0,0 +1,72 @@
package com.tiedup.remake.v2.bondage.movement;
import org.jetbrains.annotations.Nullable;
import com.tiedup.remake.v2.BodyRegionV2;
/**
* Movement styles that change how a bound player physically moves.
* Each style has a severity (higher = more constraining), default speed multiplier,
* and default jump-disabled flag.
*
* <p>When multiple styled items are worn, the style with the highest severity wins.
* If two items share the same severity, the item on the region with the lowest
* {@link com.tiedup.remake.v2.BodyRegionV2#ordinal()} wins.</p>
*
* <p>This enum is shared (server + client). It does NOT contain handler references
* to avoid pulling server-only classes into client code.</p>
*/
public enum MovementStyle {
/** Swaying side-to-side gait, visual zigzag via animation. Jump allowed. */
WADDLE(1, 0.6f, false),
/** Tiny dragging steps, heavy speed reduction. Jump disabled. */
SHUFFLE(2, 0.4f, true),
/** Automatic small hops when moving forward. Jump disabled (auto-hop replaces it). */
HOP(3, 0.35f, true),
/** On all fours, swim-like hitbox (0.6 high). Jump disabled. */
CRAWL(4, 0.2f, true);
private final int severity;
private final float defaultSpeedMultiplier;
private final boolean defaultJumpDisabled;
MovementStyle(int severity, float defaultSpeedMultiplier, boolean defaultJumpDisabled) {
this.severity = severity;
this.defaultSpeedMultiplier = defaultSpeedMultiplier;
this.defaultJumpDisabled = defaultJumpDisabled;
}
/** Higher severity = more constraining. Used for resolution tiebreaking. */
public int getSeverity() {
return severity;
}
/** Default speed multiplier (0.0-1.0) applied via MULTIPLY_BASE AttributeModifier. */
public float getDefaultSpeedMultiplier() {
return defaultSpeedMultiplier;
}
/** Whether jumping is disabled by default for this style. */
public boolean isDefaultJumpDisabled() {
return defaultJumpDisabled;
}
/**
* Safe valueOf that returns null instead of throwing on unknown names.
*
* @param name the style name (case-insensitive)
* @return the style, or null if not recognized
*/
@Nullable
public static MovementStyle fromName(String name) {
if (name == null) return null;
try {
return valueOf(name.toUpperCase());
} catch (IllegalArgumentException e) {
return null;
}
}
}

View File

@@ -0,0 +1,507 @@
package com.tiedup.remake.v2.bondage.movement;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.sync.PacketSyncMovementStyle;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.Map;
import java.util.UUID;
import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
import net.minecraft.world.entity.ai.attributes.AttributeModifier;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.living.LivingEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Server-side manager for movement style mechanics.
*
* <p>Hooks into two events:
* <ul>
* <li>{@code PlayerTickEvent(Phase.END)} at HIGH priority -- resolves style,
* manages lifecycle transitions, dispatches per-style tick logic. Runs after
* vanilla {@code travel()} so velocity modifications apply correctly.</li>
* <li>{@code LivingJumpEvent} -- suppresses jump for styles with jump disabled.
* {@code LivingJumpEvent} is NOT cancelable; jump is neutralized by subtracting
* the jump impulse from Y velocity.</li>
* </ul>
*
* <p>Per-player state lives on {@link PlayerBindState} to piggyback on existing
* lifecycle cleanup hooks (death, logout, dimension change).</p>
*
* @see MovementStyleResolver for resolution logic
* @see MovementStyle for style definitions
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class MovementStyleManager {
private static final Logger LOGGER = LogManager.getLogger("MovementStyles");
// --- V1 legacy modifier UUID (H6 cleanup) ---
// Source of truth: RestraintEffectUtils.BIND_SPEED_MODIFIER_UUID (same value).
// RestraintEffectUtils used this UUID with ADDITION operation and addPermanentModifier().
// Players upgrading from V1 may still have this modifier saved in their NBT.
// Removed on tick to prevent double stacking with V2 MULTIPLY_BASE modifiers.
private static final UUID V1_BIND_SPEED_MODIFIER_UUID =
UUID.fromString("7f3c7c8e-9d4e-4c7a-8e5f-1a2b3c4d5e6f");
// --- Unique UUIDs for AttributeModifiers (one per style to allow clean removal) ---
private static final UUID WADDLE_SPEED_UUID =
UUID.fromString("d7a1c001-0000-0000-0000-000000000001");
private static final UUID SHUFFLE_SPEED_UUID =
UUID.fromString("d7a1c001-0000-0000-0000-000000000002");
private static final UUID HOP_SPEED_UUID =
UUID.fromString("d7a1c001-0000-0000-0000-000000000003");
private static final UUID CRAWL_SPEED_UUID =
UUID.fromString("d7a1c001-0000-0000-0000-000000000004");
// --- Hop tuning constants ---
private static final double HOP_Y_IMPULSE = 0.28;
private static final double HOP_FORWARD_IMPULSE = 0.18;
private static final int HOP_COOLDOWN_TICKS = 10;
private static final int HOP_STARTUP_DELAY_TICKS = 4;
// --- Movement detection threshold (squared distance) ---
private static final double MOVEMENT_THRESHOLD_SQ = 0.001;
// --- Number of consecutive non-moving ticks before hop startup resets ---
private static final int HOP_STARTUP_RESET_TICKS = 2;
// ==================== Tick Event ====================
/**
* Per-tick movement style processing. Runs at HIGH priority at Phase.END
* so it executes before {@code BondageItemRestrictionHandler} (default priority).
*
* <p>Tick flow:
* <ol>
* <li>Skip conditions: passenger, dead, struggling</li>
* <li>Pending pose restore (crawl deactivated but can't stand yet)</li>
* <li>Resolve current style from equipped items</li>
* <li>Compare with active style, handle transitions</li>
* <li>Dispatch to style-specific tick (unless on ladder)</li>
* <li>Update last position for next tick's movement detection</li>
* </ol>
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
if (event.side.isClient() || event.phase != TickEvent.Phase.END) {
return;
}
if (!(event.player instanceof ServerPlayer player)) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
// --- Skip conditions ---
// Update last position even when suspended to prevent false movement
// detection on resume (e.g., teleport while riding)
if (player.isPassenger() || player.isDeadOrDying() || state.isStruggling()) {
state.lastX = player.getX();
state.lastY = player.getY();
state.lastZ = player.getZ();
return;
}
// --- Pending pose restore (crawl deactivated but can't stand) ---
if (state.pendingPoseRestore) {
tryRestoreStandingPose(player, state);
}
// --- H6: Remove stale V1 permanent modifier if present ---
// Players upgrading from V1 may have a permanent ADDITION modifier saved in NBT.
// This one-time cleanup prevents double stacking with the V2 MULTIPLY_BASE modifier.
cleanupV1Modifier(player);
// --- Resolve current style from equipped items ---
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player);
Map<BodyRegionV2, ItemStack> equipped = equipment != null
? equipment.getAllEquipped() : Map.of();
ResolvedMovement resolved = MovementStyleResolver.resolve(equipped);
// --- Compare with current active style ---
MovementStyle newStyle = resolved.style();
MovementStyle oldStyle = state.getActiveMovementStyle();
if (newStyle != oldStyle) {
// Style changed: deactivate old, activate new
if (oldStyle != null) {
onDeactivate(player, state, oldStyle);
}
if (newStyle != null) {
state.setResolvedMovementSpeed(resolved.speedMultiplier());
state.setResolvedJumpDisabled(resolved.jumpDisabled());
onActivate(player, state, newStyle);
} else {
state.setResolvedMovementSpeed(1.0f);
state.setResolvedJumpDisabled(false);
}
state.setActiveMovementStyle(newStyle);
// Sync to all tracking clients (animation + crawl pose)
ModNetwork.sendToAllTrackingAndSelf(
new PacketSyncMovementStyle(player.getUUID(), newStyle), player);
}
// --- Per-style tick ---
if (state.getActiveMovementStyle() != null) {
// Ladder suspension: skip style tick when on ladder
// (ladder movement is controlled by BondageItemRestrictionHandler)
if (player.onClimbable()) {
state.lastX = player.getX();
state.lastY = player.getY();
state.lastZ = player.getZ();
return;
}
tickStyle(player, state);
}
// Update last position for next tick's movement detection
state.lastX = player.getX();
state.lastY = player.getY();
state.lastZ = player.getZ();
}
// ==================== Jump Suppression ====================
/**
* Suppress jumps for styles with jump disabled.
*
* <p>{@code LivingJumpEvent} is NOT cancelable. Standard approach: subtract
* the known jump impulse from Y velocity, preserving knockback and other
* sources of Y motion.</p>
*
* <p>A {@link ClientboundSetEntityMotionPacket} is sent to minimize the
* client-side 1-frame bounce artifact.</p>
*/
@SubscribeEvent
public static void onLivingJump(LivingEvent.LivingJumpEvent event) {
if (!(event.getEntity() instanceof ServerPlayer player)) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null || !state.isResolvedJumpDisabled()) {
return;
}
// Subtract vanilla jump impulse, preserving other Y velocity (knockback, etc.)
// Vanilla: jumpPower = 0.42 + (amplifier + 1) * 0.1 = 0.42 * factor
double jumpVelocity = 0.42 * getJumpBoostFactor(player);
Vec3 motion = player.getDeltaMovement();
player.setDeltaMovement(motion.x, motion.y - jumpVelocity, motion.z);
// Sync to client to minimize visual bounce artifact
player.connection.send(new ClientboundSetEntityMotionPacket(player));
}
/**
* Calculate the Jump Boost potion factor.
* Vanilla adds {@code (amplifier + 1) * 0.1} to the base 0.42 jump height.
* We express this as a multiplicative factor on 0.42 for clean subtraction.
*
* @return 1.0 with no Jump Boost, higher with Jump Boost active
*/
private static double getJumpBoostFactor(Player player) {
var jumpBoost = player.getEffect(MobEffects.JUMP);
if (jumpBoost != null) {
return 1.0 + (jumpBoost.getAmplifier() + 1) * 0.1 / 0.42;
}
return 1.0;
}
// ==================== Lifecycle ====================
private static void onActivate(ServerPlayer player, PlayerBindState state,
MovementStyle style) {
switch (style) {
case WADDLE -> activateWaddle(player, state);
case SHUFFLE -> activateShuffle(player, state);
case HOP -> activateHop(player, state);
case CRAWL -> activateCrawl(player, state);
}
}
private static void onDeactivate(ServerPlayer player, PlayerBindState state,
MovementStyle style) {
switch (style) {
case WADDLE -> deactivateWaddle(player, state);
case SHUFFLE -> deactivateShuffle(player, state);
case HOP -> deactivateHop(player, state);
case CRAWL -> deactivateCrawl(player, state);
}
}
private static void tickStyle(ServerPlayer player, PlayerBindState state) {
switch (state.getActiveMovementStyle()) {
case WADDLE -> tickWaddle(player, state);
case SHUFFLE -> tickShuffle(player, state);
case HOP -> tickHop(player, state);
case CRAWL -> tickCrawl(player, state);
}
}
// ==================== Waddle ====================
private static void activateWaddle(ServerPlayer player, PlayerBindState state) {
applySpeedModifier(player, WADDLE_SPEED_UUID, "tiedup.waddle_speed",
state.getResolvedMovementSpeed());
}
private static void deactivateWaddle(ServerPlayer player, PlayerBindState state) {
removeSpeedModifier(player, WADDLE_SPEED_UUID);
}
private static void tickWaddle(ServerPlayer player, PlayerBindState state) {
// Waddle is animation-only on the server. No velocity manipulation.
// The visual zigzag is handled by the context animation on the client.
}
// ==================== Shuffle ====================
private static void activateShuffle(ServerPlayer player, PlayerBindState state) {
applySpeedModifier(player, SHUFFLE_SPEED_UUID, "tiedup.shuffle_speed",
state.getResolvedMovementSpeed());
}
private static void deactivateShuffle(ServerPlayer player, PlayerBindState state) {
removeSpeedModifier(player, SHUFFLE_SPEED_UUID);
}
private static void tickShuffle(ServerPlayer player, PlayerBindState state) {
// Shuffle: speed reduction via attribute is sufficient. No per-tick work.
}
// ==================== Hop ====================
private static void activateHop(ServerPlayer player, PlayerBindState state) {
// Apply base speed reduction (~15% base speed between hops)
applySpeedModifier(player, HOP_SPEED_UUID, "tiedup.hop_speed",
state.getResolvedMovementSpeed());
state.hopCooldown = 0;
state.hopStartupPending = true;
state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS;
}
private static void deactivateHop(ServerPlayer player, PlayerBindState state) {
removeSpeedModifier(player, HOP_SPEED_UUID);
state.hopCooldown = 0;
state.hopStartupPending = false;
state.hopStartupTicks = 0;
state.hopNotMovingTicks = 0;
}
/**
* Hop tick logic:
* <ul>
* <li>Detect movement via position delta (not player.zza/xxa)</li>
* <li>If moving + on ground + cooldown expired: execute hop (with startup delay on first hop)</li>
* <li>If not moving for >= 2 ticks: reset startup pending</li>
* <li>Decrement cooldown each tick</li>
* </ul>
*/
private static void tickHop(ServerPlayer player, PlayerBindState state) {
boolean isMoving = player.distanceToSqr(state.lastX, state.lastY, state.lastZ)
> MOVEMENT_THRESHOLD_SQ;
// Decrement cooldown
if (state.hopCooldown > 0) {
state.hopCooldown--;
}
if (isMoving && player.onGround() && state.hopCooldown <= 0) {
if (state.hopStartupPending) {
// Startup delay: decrement and wait (latched: completes even if
// player briefly releases input during these 4 ticks)
state.hopStartupTicks--;
if (state.hopStartupTicks <= 0) {
// Startup complete: execute first hop
state.hopStartupPending = false;
executeHop(player, state);
}
} else {
// Normal hop
executeHop(player, state);
}
state.hopNotMovingTicks = 0;
} else if (!isMoving) {
state.hopNotMovingTicks++;
// Reset startup if not moving for >= 2 consecutive ticks
if (state.hopNotMovingTicks >= HOP_STARTUP_RESET_TICKS
&& !state.hopStartupPending) {
state.hopStartupPending = true;
state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS;
}
} else {
// Moving but not on ground or cooldown active — reset not-moving counter
state.hopNotMovingTicks = 0;
}
}
/**
* Execute a single hop: apply Y impulse + forward impulse along look direction.
* Sends {@link ClientboundSetEntityMotionPacket} to sync velocity to client.
*/
private static void executeHop(ServerPlayer player, PlayerBindState state) {
Vec3 look = player.getLookAngle();
// Project look onto horizontal plane and normalize (safe: zero vec normalizes to zero)
Vec3 forward = new Vec3(look.x, 0, look.z).normalize();
Vec3 currentMotion = player.getDeltaMovement();
player.setDeltaMovement(
currentMotion.x + forward.x * HOP_FORWARD_IMPULSE,
HOP_Y_IMPULSE,
currentMotion.z + forward.z * HOP_FORWARD_IMPULSE
);
state.hopCooldown = HOP_COOLDOWN_TICKS;
// Sync velocity to client to prevent rubber-banding
player.connection.send(new ClientboundSetEntityMotionPacket(player));
}
// ==================== Crawl ====================
private static void activateCrawl(ServerPlayer player, PlayerBindState state) {
applySpeedModifier(player, CRAWL_SPEED_UUID, "tiedup.crawl_speed",
state.getResolvedMovementSpeed());
player.setForcedPose(Pose.SWIMMING);
player.refreshDimensions();
state.pendingPoseRestore = false;
}
private static void deactivateCrawl(ServerPlayer player, PlayerBindState state) {
removeSpeedModifier(player, CRAWL_SPEED_UUID);
// Space check: can the player stand up?
EntityDimensions standDims = player.getDimensions(Pose.STANDING);
AABB standBox = standDims.makeBoundingBox(player.position());
boolean canStand = player.level().noCollision(player, standBox);
if (canStand) {
player.setForcedPose(null);
player.refreshDimensions();
} else {
// Can't stand yet -- flag for periodic retry in tick flow (step 2)
state.pendingPoseRestore = true;
}
}
private static void tickCrawl(ServerPlayer player, PlayerBindState state) {
// Guard re-assertion: only re-apply if something cleared the forced pose
// (avoids unnecessary per-tick SynchedEntityData dirty-marking)
if (player.getForcedPose() != Pose.SWIMMING) {
player.setForcedPose(Pose.SWIMMING);
player.refreshDimensions();
}
}
// ==================== Pending Pose Restore ====================
/**
* Try to restore standing pose after crawl deactivation.
* Called every tick regardless of active style (step 2 in tick flow).
* Retries until space is available for the player to stand.
*/
private static void tryRestoreStandingPose(ServerPlayer player,
PlayerBindState state) {
EntityDimensions standDims = player.getDimensions(Pose.STANDING);
AABB standBox = standDims.makeBoundingBox(player.position());
boolean canStand = player.level().noCollision(player, standBox);
if (canStand) {
player.setForcedPose(null);
player.refreshDimensions();
state.pendingPoseRestore = false;
LOGGER.debug("Restored standing pose for {} (pending pose restore cleared)",
player.getName().getString());
}
}
// ==================== V1 Legacy Cleanup (H6) ====================
/**
* Remove the legacy V1 {@code RestraintEffectUtils} speed modifier if present.
*
* <p>V1 used {@code addPermanentModifier()} with UUID {@code 7f3c7c8e-...} and
* {@link AttributeModifier.Operation#ADDITION}. Because permanent modifiers are
* serialized to player NBT, players upgrading mid-session or loading old saves
* may still carry this modifier. Removing it here ensures only the V2
* {@code MULTIPLY_BASE} modifier is active.</p>
*
* <p>This is a no-op if the modifier is not present (cheap UUID lookup).</p>
*/
private static void cleanupV1Modifier(ServerPlayer player) {
AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED);
if (attr != null && attr.getModifier(V1_BIND_SPEED_MODIFIER_UUID) != null) {
attr.removeModifier(V1_BIND_SPEED_MODIFIER_UUID);
LOGGER.info("Removed stale V1 speed modifier from player {}",
player.getName().getString());
}
}
// ==================== Attribute Modifier Helpers ====================
/**
* Apply a transient {@code MULTIPLY_BASE} speed modifier.
* Always removes any existing modifier with the same UUID first, because
* {@code addTransientModifier} throws {@link IllegalArgumentException}
* if a modifier with the same UUID already exists.
*
* <p>{@code MULTIPLY_BASE} means the modifier value is added to 1.0 and
* multiplied with the base value. A multiplier of 0.4 requires a modifier
* value of -0.6: {@code base * (1 + (-0.6)) = base * 0.4}.</p>
*
* @param player the target player
* @param uuid unique modifier UUID per style
* @param name modifier name (for debugging in F3 screen)
* @param multiplier the desired speed fraction (0.0-1.0)
*/
private static void applySpeedModifier(ServerPlayer player, UUID uuid, String name,
float multiplier) {
AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED);
if (attr == null) return;
// Remove existing modifier first (no-op if not present)
attr.removeModifier(uuid);
// MULTIPLY_BASE: value of -(1 - multiplier) reduces base speed to multiplier fraction
double value = -(1.0 - multiplier);
attr.addTransientModifier(new AttributeModifier(uuid, name,
value, AttributeModifier.Operation.MULTIPLY_BASE));
}
/**
* Remove a speed modifier by UUID. Safe to call even if no modifier
* with this UUID is present.
*/
private static void removeSpeedModifier(ServerPlayer player, UUID uuid) {
AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED);
if (attr == null) return;
attr.removeModifier(uuid);
}
}

View File

@@ -0,0 +1,154 @@
package com.tiedup.remake.v2.bondage.movement;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.util.Map;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Resolves the winning movement style from a player's equipped bondage items.
*
* <p>Shared class (client + server). Deterministic: same items produce the same result.
* The highest-severity style wins. Tiebreaker: lowest {@link BodyRegionV2#ordinal()}.</p>
*
* <p>The winning item's {@link MovementModifier} (if present) overrides the style's
* default speed/jump values. Modifiers from lower-severity items are ignored.</p>
*
* <h3>V1 Compatibility (H6 fix)</h3>
* <p>V1 items ({@link ItemBind}) stored in V2 capability
* do not have data-driven definitions. This resolver provides a fallback that
* maps V1 bind mode + pose type to a {@link MovementStyle} with speed values matching
* the original V1 behavior, preventing double stacking between the legacy
* {@code RestraintEffectUtils} attribute modifier and the V2 modifier.</p>
*/
public final class MovementStyleResolver {
private MovementStyleResolver() {}
// --- V1 fallback speed values ---
// V1 used ADDITION(-0.09) on base 0.10 = 0.01 effective = 10% speed
// Expressed as MULTIPLY_BASE fraction: 0.10
private static final float V1_STANDARD_SPEED = 0.10f;
// V1 used ADDITION(-0.10) on base 0.10 = 0.00 effective = 0% speed
// Expressed as MULTIPLY_BASE fraction: 0.0 (fully immobile)
private static final float V1_IMMOBILIZED_SPEED = 0.0f;
/**
* Resolve the winning movement style from all equipped items.
*
* <p>Checks V2 data-driven definitions first, then falls back to V1 {@link ItemBind}
* introspection for items without data-driven definitions.</p>
*
* @param equipped map of region to ItemStack (from {@code IV2BondageEquipment.getAllEquipped()})
* @return the resolved movement, or {@link ResolvedMovement#NONE} if no styled items
*/
public static ResolvedMovement resolve(Map<BodyRegionV2, ItemStack> equipped) {
if (equipped == null || equipped.isEmpty()) {
return ResolvedMovement.NONE;
}
MovementStyle bestStyle = null;
float bestSpeed = 1.0f;
boolean bestJumpDisabled = false;
int bestSeverity = -1;
int bestRegionOrdinal = Integer.MAX_VALUE;
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
BodyRegionV2 region = entry.getKey();
ItemStack stack = entry.getValue();
if (stack.isEmpty()) continue;
// --- Try V2 data-driven definition first ---
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null && def.movementStyle() != null) {
MovementStyle style = def.movementStyle();
int severity = style.getSeverity();
int regionOrdinal = region.ordinal();
if (severity > bestSeverity
|| (severity == bestSeverity && regionOrdinal < bestRegionOrdinal)) {
bestStyle = style;
MovementModifier mod = def.movementModifier();
bestSpeed = (mod != null && mod.speedMultiplier() != null)
? mod.speedMultiplier()
: style.getDefaultSpeedMultiplier();
bestJumpDisabled = (mod != null && mod.jumpDisabled() != null)
? mod.jumpDisabled()
: style.isDefaultJumpDisabled();
bestSeverity = severity;
bestRegionOrdinal = regionOrdinal;
}
continue;
}
// --- V1 fallback: ItemBind without data-driven definition ---
V1Fallback fallback = resolveV1Fallback(stack);
if (fallback != null) {
int severity = fallback.style.getSeverity();
int regionOrdinal = region.ordinal();
if (severity > bestSeverity
|| (severity == bestSeverity && regionOrdinal < bestRegionOrdinal)) {
bestStyle = fallback.style;
bestSpeed = fallback.speed;
bestJumpDisabled = fallback.jumpDisabled;
bestSeverity = severity;
bestRegionOrdinal = regionOrdinal;
}
}
}
if (bestStyle == null) {
return ResolvedMovement.NONE;
}
return new ResolvedMovement(bestStyle, bestSpeed, bestJumpDisabled);
}
// ==================== V1 Fallback ====================
/**
* Attempt to derive a movement style from a V1 {@link ItemBind} item.
*
* <p>Only items with legs bound produce a movement style. The mapping preserves
* the original V1 speed values:</p>
* <ul>
* <li>WRAP / LATEX_SACK: SHUFFLE at 0% speed (full immobilization), jump disabled</li>
* <li>DOG / HUMAN_CHAIR: CRAWL at V1 standard speed (10%), jump disabled</li>
* <li>STANDARD / STRAITJACKET: SHUFFLE at 10% speed, jump disabled</li>
* </ul>
*
* @param stack the ItemStack to inspect
* @return fallback resolution, or null if the item is not a V1 bind or legs are not bound
*/
@Nullable
private static V1Fallback resolveV1Fallback(ItemStack stack) {
if (!(stack.getItem() instanceof ItemBind bindItem)) {
return null;
}
if (!ItemBind.hasLegsBound(stack)) {
return null;
}
PoseType poseType = bindItem.getPoseType();
return switch (poseType) {
case WRAP, LATEX_SACK ->
new V1Fallback(MovementStyle.SHUFFLE, V1_IMMOBILIZED_SPEED, true);
case DOG, HUMAN_CHAIR ->
new V1Fallback(MovementStyle.CRAWL, V1_STANDARD_SPEED, true);
default ->
// STANDARD, STRAITJACKET: shuffle at V1 standard speed
new V1Fallback(MovementStyle.SHUFFLE, V1_STANDARD_SPEED, true);
};
}
/** Internal holder for V1 fallback resolution result. */
private record V1Fallback(MovementStyle style, float speed, boolean jumpDisabled) {}
}

View File

@@ -0,0 +1,24 @@
package com.tiedup.remake.v2.bondage.movement;
import org.jetbrains.annotations.Nullable;
/**
* Result of resolving the winning movement style from all equipped items.
* Contains the final computed values (style defaults merged with item overrides).
*
* <p>A null instance or null style means no movement restriction applies.</p>
*/
public record ResolvedMovement(
/** The winning movement style, or null if no styled items are equipped. */
@Nullable MovementStyle style,
/** Final speed multiplier (style default or item override). */
float speedMultiplier,
/** Final jump-disabled flag (style default or item override). */
boolean jumpDisabled
) {
/** Sentinel for "no movement style active". */
public static final ResolvedMovement NONE = new ResolvedMovement(null, 1.0f, false);
}

View File

@@ -0,0 +1,81 @@
package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import java.util.function.Supplier;
import net.minecraft.client.Minecraft;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.level.Level;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.fml.loading.FMLEnvironment;
import net.minecraftforge.network.NetworkEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Server-to-client packet that syncs V2 bondage equipment state.
*
* Sent when equipment changes (equip/unequip/clear) and on player login
* or start-tracking. The client deserializes into its local capability
* so the render layer has data to display.
*/
public class PacketSyncV2Equipment {
private static final Logger LOGGER = LogManager.getLogger("PacketSyncV2Equipment");
private final int entityId;
private final CompoundTag data;
public PacketSyncV2Equipment(int entityId, CompoundTag data) {
this.entityId = entityId;
this.data = data != null ? data : new CompoundTag();
}
// ==================== Codec ====================
public static void encode(PacketSyncV2Equipment msg, FriendlyByteBuf buf) {
buf.writeInt(msg.entityId);
buf.writeNbt(msg.data);
}
public static PacketSyncV2Equipment decode(FriendlyByteBuf buf) {
int entityId = buf.readInt();
CompoundTag data = buf.readNbt();
return new PacketSyncV2Equipment(entityId, data != null ? data : new CompoundTag());
}
// ==================== Handler ====================
public static void handle(PacketSyncV2Equipment msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
if (FMLEnvironment.dist == Dist.CLIENT) {
handleOnClient(msg);
}
});
ctx.setPacketHandled(true);
}
@OnlyIn(Dist.CLIENT)
private static void handleOnClient(PacketSyncV2Equipment msg) {
Level level = Minecraft.getInstance().level;
if (level == null) return;
Entity entity = level.getEntity(msg.entityId);
if (entity instanceof LivingEntity living) {
// IV2EquipmentHolder entities (e.g., Damsels) sync via SynchedEntityData,
// not via this packet. If we receive one for such an entity, deserialize
// directly into their internal equipment storage.
if (living instanceof IV2EquipmentHolder holder) {
holder.getV2Equipment().deserializeNBT(msg.data);
return;
}
living.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(equip -> equip.deserializeNBT(msg.data));
}
}
}

View File

@@ -0,0 +1,143 @@
package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemKey;
import com.tiedup.remake.items.ItemMasterKey;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.network.NetworkEvent;
/**
* C2S packet: player locks or unlocks a V2 bondage item on a target entity.
*
* <p>The server checks the player's hands for a key -- no key data is sent by
* the client. This prevents spoofed key UUIDs.
*
* <p>Security model:
* <ul>
* <li>Distance check: sender must be within 4 blocks</li>
* <li>Line-of-sight check: sender must see the target</li>
* <li>Key authority: server reads key from sender's hands, never from packet</li>
* <li>Master key can only UNLOCK (not lock)</li>
* <li>Regular key must match the lock UUID to unlock</li>
* </ul>
*
* <p>Rate limited under the "action" bucket (10 tokens, 2/sec refill).
*/
public class PacketV2LockToggle {
public enum Action { LOCK, UNLOCK }
private final int targetEntityId;
private final BodyRegionV2 region;
private final Action action;
public PacketV2LockToggle(int targetEntityId, BodyRegionV2 region, Action action) {
this.targetEntityId = targetEntityId;
this.region = region;
this.action = action;
}
public static void encode(PacketV2LockToggle msg, FriendlyByteBuf buf) {
buf.writeInt(msg.targetEntityId);
buf.writeEnum(msg.region);
buf.writeEnum(msg.action);
}
public static PacketV2LockToggle decode(FriendlyByteBuf buf) {
return new PacketV2LockToggle(
buf.readInt(),
buf.readEnum(BodyRegionV2.class),
buf.readEnum(Action.class)
);
}
public static void handle(PacketV2LockToggle msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
if (!PacketRateLimiter.allowPacket(sender, "action")) return;
handleServer(sender, msg.targetEntityId, msg.region, msg.action);
});
ctx.setPacketHandled(true);
}
private static void handleServer(
ServerPlayer sender, int targetEntityId, BodyRegionV2 region, Action action
) {
Entity rawTarget = sender.level().getEntity(targetEntityId);
if (!(rawTarget instanceof LivingEntity target)) return;
// Distance + line-of-sight validation
if (sender.distanceTo(target) > 4.0 || !sender.hasLineOfSight(target)) return;
ItemStack stack = V2EquipmentHelper.getInRegion(target, region);
if (stack.isEmpty()) return;
if (!(stack.getItem() instanceof ILockable lockable)) return;
// Find key in sender's hands -- server-authoritative, never from packet
ItemStack mainHand = sender.getItemInHand(InteractionHand.MAIN_HAND);
ItemStack offHand = sender.getItemInHand(InteractionHand.OFF_HAND);
boolean hasMasterKey = mainHand.getItem() instanceof ItemMasterKey
|| offHand.getItem() instanceof ItemMasterKey;
ItemKey heldKey = null;
ItemStack heldKeyStack = ItemStack.EMPTY;
if (mainHand.getItem() instanceof ItemKey key) {
heldKey = key;
heldKeyStack = mainHand;
} else if (offHand.getItem() instanceof ItemKey key) {
heldKey = key;
heldKeyStack = offHand;
}
switch (action) {
case LOCK -> {
if (lockable.isLocked(stack)) return;
if (!lockable.isLockable(stack)) return;
if (hasMasterKey) return; // master key cannot lock
if (heldKey == null) return;
UUID keyUUID = heldKey.getKeyUUID(heldKeyStack);
lockable.setLockedByKeyUUID(stack, keyUUID);
lockable.initializeLockResistance(stack);
TiedUpMod.LOGGER.debug("[V2LockToggle] Locked region {} on entity {}",
region.name(), target.getName().getString());
}
case UNLOCK -> {
if (!lockable.isLocked(stack)) return;
if (hasMasterKey) {
lockable.setLockedByKeyUUID(stack, null);
lockable.clearLockResistance(stack);
} else if (heldKey != null) {
UUID keyUUID = heldKey.getKeyUUID(heldKeyStack);
if (!lockable.matchesKey(stack, keyUUID)) return;
lockable.setLockedByKeyUUID(stack, null);
lockable.clearLockResistance(stack);
} else {
return;
}
TiedUpMod.LOGGER.debug("[V2LockToggle] Unlocked region {} on entity {}",
region.name(), target.getName().getString());
}
}
V2EquipmentHelper.sync(target);
}
}

View File

@@ -0,0 +1,91 @@
package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.V2EquipResult;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.network.NetworkEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Client→Server: Player equips a bondage item from their own inventory onto a body region.
*/
public class PacketV2SelfEquip {
private static final Logger LOGGER = LogManager.getLogger("PacketV2SelfEquip");
private final BodyRegionV2 region;
private final int inventorySlot;
public PacketV2SelfEquip(BodyRegionV2 region, int inventorySlot) {
this.region = region;
this.inventorySlot = inventorySlot;
}
public static void encode(PacketV2SelfEquip msg, FriendlyByteBuf buf) {
buf.writeEnum(msg.region);
buf.writeVarInt(msg.inventorySlot);
}
public static PacketV2SelfEquip decode(FriendlyByteBuf buf) {
return new PacketV2SelfEquip(buf.readEnum(BodyRegionV2.class), buf.readVarInt());
}
public static void handle(PacketV2SelfEquip msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
ServerPlayer player = ctx.getSender();
if (player == null) return;
if (!PacketRateLimiter.allowPacket(player, "action")) return;
// Validate slot index
if (msg.inventorySlot < 0 || msg.inventorySlot >= player.getInventory().getContainerSize()) return;
ItemStack stack = player.getInventory().getItem(msg.inventorySlot);
if (stack.isEmpty()) return;
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) return;
// Warn if data-driven item has no definition (missing JSON or reload issue)
if (bondageItem instanceof DataDrivenBondageItem && DataDrivenItemRegistry.get(stack) == null) {
LOGGER.warn("[V2SelfEquip] Data-driven item in slot {} has no definition — equip blocked. Stack NBT: {}",
msg.inventorySlot, stack.getTag());
return;
}
// Validate item targets this region
if (!bondageItem.getOccupiedRegions(stack).contains(msg.region)) return;
// Furniture seat blocks this region
if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) {
com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player);
if (seat != null && seat.blockedRegions().contains(msg.region)) {
return; // Region blocked by furniture
}
}
// Try equip (handles conflict resolution)
V2EquipResult result = V2EquipmentHelper.equipItem(player, stack);
if (result.isSuccess()) {
// Remove from inventory (or reduce count)
player.getInventory().removeItem(msg.inventorySlot, 1);
// Return any displaced items to inventory
if (result.displaced() != null) {
for (ItemStack displaced : result.displaced()) {
if (!displaced.isEmpty()) {
player.getInventory().placeItemBackInInventory(displaced);
}
}
}
}
});
ctx.setPacketHandled(true);
}
}

View File

@@ -0,0 +1,79 @@
package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.items.ItemKey;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.network.NetworkEvent;
/**
* Client→Server: Player locks their own equipped item using a key from inventory.
*/
public class PacketV2SelfLock {
private final BodyRegionV2 region;
public PacketV2SelfLock(BodyRegionV2 region) {
this.region = region;
}
public static void encode(PacketV2SelfLock msg, FriendlyByteBuf buf) {
buf.writeEnum(msg.region);
}
public static PacketV2SelfLock decode(FriendlyByteBuf buf) {
return new PacketV2SelfLock(buf.readEnum(BodyRegionV2.class));
}
public static void handle(PacketV2SelfLock msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
ServerPlayer player = ctx.getSender();
if (player == null) return;
if (!PacketRateLimiter.allowPacket(player, "action")) return;
// Arms must be free to self-lock
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) return;
ItemStack equipped = V2EquipmentHelper.getInRegion(player, msg.region);
if (equipped.isEmpty()) return;
if (!(equipped.getItem() instanceof ILockable lockable)) return;
if (!lockable.isLockable(equipped) || lockable.isLocked(equipped)) return;
// Furniture seat blocks this region
if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) {
com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player);
if (seat != null && seat.blockedRegions().contains(msg.region)) {
return; // Region blocked by furniture
}
}
// Find a key in inventory
ItemStack keyStack = findKeyInInventory(player);
if (keyStack.isEmpty()) return;
if (!(keyStack.getItem() instanceof ItemKey key)) return;
UUID keyUUID = key.getKeyUUID(keyStack);
lockable.setLockedByKeyUUID(equipped, keyUUID);
V2EquipmentHelper.sync(player);
});
ctx.setPacketHandled(true);
}
/** Find the first ItemKey in the player's inventory. Returns ItemStack.EMPTY if none. */
private static ItemStack findKeyInInventory(ServerPlayer player) {
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
ItemStack s = player.getInventory().getItem(i);
if (!s.isEmpty() && s.getItem() instanceof ItemKey) {
return s;
}
}
return ItemStack.EMPTY;
}
}

View File

@@ -0,0 +1,90 @@
package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.network.NetworkEvent;
/**
* C2S packet: player requests removal of a V2 bondage item from themselves.
*
* Validates:
* - Region is occupied
* - Item canUnequip (not locked)
* - ARMS not occupied (can't manipulate buckles with bound arms)
* - Item does not occupy ARMS (arm restraints require struggle, not manual removal)
*/
public class PacketV2SelfRemove {
private final BodyRegionV2 region;
public PacketV2SelfRemove(BodyRegionV2 region) {
this.region = region;
}
public static void encode(PacketV2SelfRemove msg, FriendlyByteBuf buf) {
buf.writeEnum(msg.region);
}
public static PacketV2SelfRemove decode(FriendlyByteBuf buf) {
return new PacketV2SelfRemove(buf.readEnum(BodyRegionV2.class));
}
public static void handle(PacketV2SelfRemove msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
ServerPlayer player = ctx.getSender();
if (player == null) return;
if (!PacketRateLimiter.allowPacket(player, "action")) return;
handleServer(player, msg.region);
});
ctx.setPacketHandled(true);
}
private static void handleServer(ServerPlayer player, BodyRegionV2 region) {
ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
if (stack.isEmpty()) return;
if (!(stack.getItem() instanceof IV2BondageItem item)) return;
// Arm restraints cannot be self-removed — must use struggle
if (item.getOccupiedRegions(stack).contains(BodyRegionV2.ARMS)) {
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: item occupies ARMS, must struggle");
return;
}
// Cannot manipulate buckles/clasps with bound arms
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) {
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: player's ARMS are occupied");
return;
}
// Cannot manipulate buckles/clasps with covered hands
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS)) {
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: player's HANDS are occupied");
return;
}
// Check item allows unequip (not locked)
if (!item.canUnequip(stack, player)) {
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: item canUnequip=false (locked?)");
return;
}
// Remove and give to inventory
ItemStack removed = V2EquipmentHelper.unequipFromRegion(player, region);
if (!removed.isEmpty()) {
if (!player.getInventory().add(removed)) {
player.drop(removed, false);
}
}
// sync() is called inside unequipFromRegion
}
}

View File

@@ -0,0 +1,88 @@
package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.items.ItemKey;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.network.NetworkEvent;
/**
* Client→Server: Player unlocks their own equipped item using the matching key.
*/
public class PacketV2SelfUnlock {
private final BodyRegionV2 region;
public PacketV2SelfUnlock(BodyRegionV2 region) {
this.region = region;
}
public static void encode(PacketV2SelfUnlock msg, FriendlyByteBuf buf) {
buf.writeEnum(msg.region);
}
public static PacketV2SelfUnlock decode(FriendlyByteBuf buf) {
return new PacketV2SelfUnlock(buf.readEnum(BodyRegionV2.class));
}
public static void handle(PacketV2SelfUnlock msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
ServerPlayer player = ctx.getSender();
if (player == null) return;
if (!PacketRateLimiter.allowPacket(player, "action")) return;
// Arms must be free to self-unlock
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) return;
ItemStack equipped = V2EquipmentHelper.getInRegion(player, msg.region);
if (equipped.isEmpty()) return;
if (!(equipped.getItem() instanceof ILockable lockable)) return;
if (!lockable.isLocked(equipped)) return;
// Furniture seat blocks this region
if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) {
com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player);
if (seat != null && seat.blockedRegions().contains(msg.region)) {
return; // Region blocked by furniture
}
}
// Find matching key in inventory
UUID lockedByUUID = lockable.getLockedByKeyUUID(equipped);
ItemStack keyStack = findMatchingKeyInInventory(player, lockedByUUID);
if (keyStack.isEmpty()) return;
lockable.setLockedByKeyUUID(equipped, null);
V2EquipmentHelper.sync(player);
});
ctx.setPacketHandled(true);
}
/**
* Find a key in the player's inventory that matches the lock UUID,
* or a master key that unlocks anything.
*/
private static ItemStack findMatchingKeyInInventory(ServerPlayer player, UUID lockedByUUID) {
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
ItemStack s = player.getInventory().getItem(i);
if (s.isEmpty()) continue;
// Master key unlocks everything
if (s.is(ModItems.MASTER_KEY.get())) return s;
// Regular key: must match the lock UUID
if (s.getItem() instanceof ItemKey key) {
if (lockedByUUID != null && lockedByUUID.equals(key.getKeyUUID(s))) {
return s;
}
}
}
return ItemStack.EMPTY;
}
}

View File

@@ -0,0 +1,108 @@
package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.items.base.IHasResistance;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState;
import com.tiedup.remake.minigame.StruggleSessionManager;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.network.minigame.PacketContinuousStruggleState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.network.NetworkEvent;
/**
* C2S packet: player starts a struggle minigame against a V2 item in a region.
*
* <p>Flow:
* <ol>
* <li>Client sends this packet with the target {@link BodyRegionV2}</li>
* <li>Server validates: region occupied, item has resistance</li>
* <li>Server creates a {@link ContinuousStruggleMiniGameState} via
* {@link MiniGameSessionManager#startV2StruggleSession}</li>
* <li>Server sends {@link PacketContinuousStruggleState}(START) back to open the minigame GUI</li>
* </ol>
*
* <p>Rate limited under the "ui" bucket (3 tokens, 0.5/sec refill) since this is
* a screen-opening action, not a per-tick input.
*/
public class PacketV2StruggleStart {
private final BodyRegionV2 region;
public PacketV2StruggleStart(BodyRegionV2 region) {
this.region = region;
}
public BodyRegionV2 getRegion() {
return region;
}
public static void encode(PacketV2StruggleStart msg, FriendlyByteBuf buf) {
buf.writeEnum(msg.region);
}
public static PacketV2StruggleStart decode(FriendlyByteBuf buf) {
return new PacketV2StruggleStart(buf.readEnum(BodyRegionV2.class));
}
public static void handle(PacketV2StruggleStart msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
ServerPlayer player = ctx.getSender();
if (player == null) return;
if (!PacketRateLimiter.allowPacket(player, "ui")) return;
handleServer(player, msg.region);
});
ctx.setPacketHandled(true);
}
private static void handleServer(ServerPlayer player, BodyRegionV2 region) {
ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
if (stack.isEmpty()) return;
if (!(stack.getItem() instanceof IHasResistance resistanceItem)) return;
// BUG-002 fix: respect canBeStruggledOut flag
if (!resistanceItem.canBeStruggledOut(stack)) return;
// RISK-002 fix: respect server config
if (!com.tiedup.remake.core.ModConfig.SERVER.struggleMiniGameEnabled.get()) return;
int resistance = resistanceItem.getCurrentResistance(stack, player);
boolean isLocked = false;
if (stack.getItem() instanceof ILockable lockable) {
isLocked = lockable.isLocked(stack);
if (isLocked) {
resistance += lockable.getCurrentLockResistance(stack);
}
}
// RISK-003 fix: no point starting a session with 0 resistance
if (resistance <= 0) return;
StruggleSessionManager manager = StruggleSessionManager.getInstance();
ContinuousStruggleMiniGameState session = manager.startV2StruggleSession(
player, region, resistance, isLocked
);
if (session != null) {
ModNetwork.sendToPlayer(
new PacketContinuousStruggleState(
session.getSessionId(),
ContinuousStruggleMiniGameState.UpdateType.START,
session.getCurrentDirection().getIndex(),
session.getCurrentResistance(),
session.getMaxResistance(),
isLocked
),
player
);
}
}
}