diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md
index abbe78b..e35be24 100644
--- a/docs/ARTIST_GUIDE.md
+++ b/docs/ARTIST_GUIDE.md
@@ -1589,7 +1589,7 @@ Two players can be locked side by side. The mod picks the seat nearest to where
### Monster Seat System (Planned)
-The furniture system is built on a universal `ISeatProvider` interface that is **not limited to static furniture**. Any living entity (monster, NPC) can implement the same interface to hold players in constrained poses using the same mechanics: blocked regions, forced animations, lock/escape.
+The furniture system is built on an `ISeatProvider` interface currently implemented only by `EntityFurniture`. The design intent is that any living entity (monster, NPC) could implement the same interface to hold players in constrained poses using the same mechanics: blocked regions, forced animations, lock/escape — but no second implementation exists yet.
**Example use case:** A tentacle monster that grabs a player on attack — the player "rides" the monster, gets a forced pose (arms restrained), and must struggle to escape. The monster's GLB would contain a `Player_grab` armature with `Player_grab|Idle` and `Player_grab|Struggle` animations, following the exact same convention as furniture seats.
diff --git a/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java b/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java
index 9df304e..4160468 100644
--- a/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java
+++ b/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java
@@ -137,10 +137,8 @@ public class AnimationTickHandler {
);
if (retries < MAX_FURNITURE_RETRIES) {
furnitureRetryCounters.put(playerUuid, retries + 1);
- com.tiedup.remake.v2.furniture.EntityFurniture.startFurnitureAnimationClient(
- furniture,
- player
- );
+ com.tiedup.remake.v2.furniture.client.FurnitureClientAnimator
+ .start(furniture, player);
if (retries + 1 == MAX_FURNITURE_RETRIES) {
LOGGER.debug(
"[FurnitureAnim] Giving up on furniture animation retry for {} after {} attempts — GLB likely has no Player_* armature.",
diff --git a/src/main/java/com/tiedup/remake/client/animation/util/AnimationIdBuilder.java b/src/main/java/com/tiedup/remake/client/animation/util/AnimationIdBuilder.java
index d1216b2..e78f4a1 100644
--- a/src/main/java/com/tiedup/remake/client/animation/util/AnimationIdBuilder.java
+++ b/src/main/java/com/tiedup/remake/client/animation/util/AnimationIdBuilder.java
@@ -6,26 +6,24 @@ import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
- * Utility class for building animation ResourceLocation IDs.
+ * Builds legacy JSON-animation ResourceLocation IDs from pose / bind-mode
+ * / variant components.
*
- *
Centralizes the logic for constructing animation file names.
- * Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
- *
- *
Animation naming convention:
- *
- * {poseType}_{bindMode}_{variant}.json
- *
- * poseType: tied_up_basic | straitjacket | wrap | latex_sack
- * bindMode: (empty for FULL) | _arms | _legs
- * variant: _idle | _struggle | (empty for static)
- *
- *
- * Examples:
+ *
Legacy path. The V2 player pipeline resolves animations from
+ * GLB models via {@code GltfAnimationApplier.applyV2Animation} +
+ * {@code RegionBoneMapper} and does not touch this class. Only two
+ * callers remain:
*
- * - tiedup:tied_up_basic_idle - STANDARD + FULL + idle
- * - tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle
- * - tiedup:wrap_idle - WRAP + FULL + idle
+ * - {@code NpcAnimationTickHandler} — JSON fallback when a tied
+ * NPC has no GLB-bearing item equipped.
+ * - {@code MixinVillagerEntityBaseModelMCA} — MCA villagers use
+ * their own capability system and don't flow through the V2
+ * item registry, so they stay on the JSON animation path.
*
+ *
+ * New code should not depend on this class. Animation naming:
+ * {@code {poseType}_{bindMode}_{variant}} (e.g.
+ * {@code tiedup:straitjacket_arms_struggle}).
*/
@OnlyIn(Dist.CLIENT)
public final class AnimationIdBuilder {
diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
index c623811..079c81e 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
@@ -2,15 +2,12 @@ package com.tiedup.remake.client.gltf;
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
import com.tiedup.remake.client.animation.context.ContextGlbRegistry;
-import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
-import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
-import net.minecraft.client.renderer.entity.player.PlayerRenderer;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.api.distmarker.Dist;
-import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
+import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
@@ -18,8 +15,9 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
- * Forge event registration for the glTF pipeline.
- * Registers render layers and animation factory.
+ * MOD-bus setup for the generic glTF pipeline — init + cache-invalidation
+ * reload listener. Bondage-specific registrations (render layer, item-aware
+ * reload listeners) live in {@code V2ClientSetup}.
*/
public final class GltfClientSetup {
@@ -27,9 +25,6 @@ public final class GltfClientSetup {
private GltfClientSetup() {}
- /**
- * MOD bus event subscribers (FMLClientSetupEvent, AddLayers).
- */
@Mod.EventBusSubscriber(
modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.MOD,
@@ -46,83 +41,28 @@ public final class GltfClientSetup {
});
}
- @SuppressWarnings("unchecked")
- @SubscribeEvent
- public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
- var defaultRenderer = event.getSkin("default");
- if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
- playerRenderer.addLayer(
- new V2BondageRenderLayer<>(playerRenderer)
- );
- LOGGER.info(
- "[GltfPipeline] Render layers added to 'default' player renderer"
- );
- }
-
- // Add V2 layer to slim player renderer (Alex)
- var slimRenderer = event.getSkin("slim");
- if (slimRenderer instanceof PlayerRenderer playerRenderer) {
- playerRenderer.addLayer(
- new V2BondageRenderLayer<>(playerRenderer)
- );
- LOGGER.info(
- "[GltfPipeline] Render layers added to 'slim' player renderer"
- );
- }
- }
-
/**
- * Register resource reload listeners in the order required by the
- * cache/consumer dependency graph.
- *
- * ORDER MATTERS — do not rearrange without checking the
- * invariants below. Forge does not guarantee parallel-safe
- * ordering between listeners registered on the same event; we rely
- * on {@code apply()} running sequentially in the order of
- * {@code registerReloadListener} calls.
+ * Register the generic GLB cache-clear reload listener.
*
+ * {@code HIGH} priority so it fires before any downstream
+ * listener that consumes GLB state. Within this single {@code apply}
+ * block, the sub-order matters:
*
- * - GLB cache clear (inline listener below) — must run
- * first. Inside this single listener's {@code apply()}:
- *
- * - Blow away the raw GLB byte caches
- * ({@code GltfCache.clearCache},
- * {@code GltfAnimationApplier.invalidateCache},
- * {@code GltfMeshRenderer.clearRenderTypeCache}).
- * These three caches are mutually independent — none
- * of them reads from the others — so their relative
- * order is not load-bearing.
- * - Reload {@code ContextGlbRegistry} from the new
- * resource packs before clearing
- * {@code ContextAnimationFactory.clearCache()} — if
- * the order is swapped, the next factory lookup will
- * lazily rebuild clips against the stale registry
- * (which is still populated at that moment), cache
- * them, and keep serving old data until the next
- * reload.
- * - Clear {@code FurnitureGltfCache} last, after the GLB
- * layer has repopulated its registry but before any
- * downstream item listener queries furniture models.
- *
- *
- * - Data-driven item reload
- * ({@code DataDrivenItemReloadListener}) — consumes the
- * reloaded GLB registry indirectly via item JSON references.
- * Must run after the GLB cache clear so any item that
- * reaches into the GLB layer during load picks up fresh data.
- * - GLB validation
- * ({@code GlbValidationReloadListener}) — runs last. It walks
- * both the item registry and the GLB cache to surface
- * authoring issues via toast. If it ran earlier, missing
- * items would falsely trip the "referenced but not found"
- * diagnostic.
+ * - Drop the three mutually-independent byte caches
+ * ({@code GltfCache}, {@code GltfAnimationApplier.invalidateCache},
+ * {@code GltfMeshRenderer.clearRenderTypeCache}).
+ * - Reload {@code ContextGlbRegistry} before clearing
+ * {@code ContextAnimationFactory.clearCache()} — otherwise
+ * the next factory lookup rebuilds clips against the stale
+ * registry and caches the wrong data.
+ * - Clear {@code FurnitureGltfCache} last.
*
*
- * When adding a new listener: decide where it sits in this
- * producer/consumer chain. If you're not sure, add it at the end
- * (the safest position — the rest of the graph is already built).
+ * Bondage-specific listeners register at {@code LOW} priority from
+ * {@code V2ClientSetup.onRegisterReloadListeners}, so the cache-clear
+ * here is guaranteed to land first.
*/
- @SubscribeEvent
+ @SubscribeEvent(priority = EventPriority.HIGH)
public static void onRegisterReloadListeners(
RegisterClientReloadListenersEvent event
) {
@@ -145,9 +85,6 @@ public final class GltfClientSetup {
GltfCache.clearCache();
GltfAnimationApplier.invalidateCache();
GltfMeshRenderer.clearRenderTypeCache();
- // Reload context GLB animations from resource packs FIRST,
- // then clear the factory cache so it rebuilds against the
- // new GLB registry (prevents stale JSON fallback caching).
ContextGlbRegistry.reload(resourceManager);
ContextAnimationFactory.clearCache();
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
@@ -157,17 +94,6 @@ public final class GltfClientSetup {
}
}
);
- LOGGER.info("[GltfPipeline] Resource reload listener registered");
-
- // Data-driven bondage item definitions (tiedup_items/*.json)
- event.registerReloadListener(new DataDrivenItemReloadListener());
- LOGGER.info(
- "[GltfPipeline] Data-driven item reload listener registered"
- );
-
- // GLB structural validation (runs after item definitions are loaded)
- event.registerReloadListener(new com.tiedup.remake.client.gltf.diagnostic.GlbValidationReloadListener());
- LOGGER.info("[GltfPipeline] GLB validation reload listener registered");
}
}
diff --git a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java
index 243ce8b..4e73d83 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java
+++ b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java
@@ -5,13 +5,11 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.client.gltf.GltfBoneMapper;
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic.Severity;
-import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
@@ -77,22 +75,6 @@ public final class GlbValidator {
return GlbValidationResult.of(source, diagnostics);
}
- /**
- * Cross-reference a validation result against an item definition.
- * Stub for future checks (e.g. region/bone coverage, animation name
- * matching against definition-declared poses).
- *
- * @param result the prior structural validation result
- * @param def the item definition to cross-reference
- * @return additional diagnostics (currently empty)
- */
- public static List validateAgainstDefinition(
- GlbValidationResult result,
- DataDrivenItemDefinition def
- ) {
- return Collections.emptyList();
- }
-
// ------------------------------------------------------------------ //
// Header + JSON chunk extraction //
// ------------------------------------------------------------------ //
diff --git a/src/main/java/com/tiedup/remake/minigame/LockpickMiniGameState.java b/src/main/java/com/tiedup/remake/minigame/LockpickMiniGameState.java
index 782cac7..b3f7c83 100644
--- a/src/main/java/com/tiedup/remake/minigame/LockpickMiniGameState.java
+++ b/src/main/java/com/tiedup/remake/minigame/LockpickMiniGameState.java
@@ -16,7 +16,8 @@ public class LockpickMiniGameState {
private final UUID sessionId;
private final UUID playerId;
private final long createdAt;
- private final int targetSlot;
+ private final LockpickTargetKind targetKind;
+ private final int targetData;
/**
* Session timeout in milliseconds (2 minutes)
@@ -58,12 +59,14 @@ public class LockpickMiniGameState {
public LockpickMiniGameState(
UUID playerId,
- int targetSlot,
+ LockpickTargetKind targetKind,
+ int targetData,
float sweetSpotWidth
) {
this.sessionId = UUID.randomUUID();
this.playerId = playerId;
- this.targetSlot = targetSlot;
+ this.targetKind = targetKind;
+ this.targetData = targetData;
this.createdAt = System.currentTimeMillis();
// Generate random sweet spot position
@@ -91,8 +94,12 @@ public class LockpickMiniGameState {
return playerId;
}
- public int getTargetSlot() {
- return targetSlot;
+ public LockpickTargetKind getTargetKind() {
+ return targetKind;
+ }
+
+ public int getTargetData() {
+ return targetData;
}
public float getSweetSpotCenter() {
@@ -269,10 +276,11 @@ public class LockpickMiniGameState {
@Override
public String toString() {
return String.format(
- "LockpickMiniGameState{session=%s, player=%s, slot=%d, pos=%.2f, sweet=%.2f(w=%.2f), uses=%d}",
+ "LockpickMiniGameState{session=%s, player=%s, kind=%s, data=%d, pos=%.2f, sweet=%.2f(w=%.2f), uses=%d}",
sessionId.toString().substring(0, 8),
playerId.toString().substring(0, 8),
- targetSlot,
+ targetKind,
+ targetData,
currentPosition,
sweetSpotCenter,
sweetSpotWidth,
diff --git a/src/main/java/com/tiedup/remake/minigame/LockpickSessionManager.java b/src/main/java/com/tiedup/remake/minigame/LockpickSessionManager.java
index a8e6a81..38abe29 100644
--- a/src/main/java/com/tiedup/remake/minigame/LockpickSessionManager.java
+++ b/src/main/java/com/tiedup/remake/minigame/LockpickSessionManager.java
@@ -33,17 +33,19 @@ public class LockpickSessionManager {
}
/**
- * Start a new lockpick session for a player.
- * If player already has an active session, it will be replaced (handles ESC cancel case).
+ * Start a new lockpick session for a player. Replaces any existing
+ * session (ESC-cancel case).
*
- * @param player The server player
- * @param targetSlot The bondage slot being picked
- * @param sweetSpotWidth The width of the sweet spot (based on tool)
+ * @param player The server player
+ * @param targetKind Whether the target is a body region or a seat
+ * @param targetData BodyRegionV2 ordinal or entity id, depending on kind
+ * @param sweetSpotWidth Width of the sweet spot (based on tool)
* @return The new session
*/
public LockpickMiniGameState startLockpickSession(
ServerPlayer player,
- int targetSlot,
+ LockpickTargetKind targetKind,
+ int targetData,
float sweetSpotWidth
) {
UUID playerId = player.getUUID();
@@ -61,16 +63,18 @@ public class LockpickSessionManager {
// Create new session
LockpickMiniGameState session = new LockpickMiniGameState(
playerId,
- targetSlot,
+ targetKind,
+ targetData,
sweetSpotWidth
);
lockpickSessions.put(playerId, session);
TiedUpMod.LOGGER.info(
- "[LockpickSessionManager] Started lockpick session {} for {} (slot: {}, width: {}%)",
+ "[LockpickSessionManager] Started lockpick session {} for {} (kind: {}, data: {}, width: {}%)",
session.getSessionId().toString().substring(0, 8),
player.getName().getString(),
- targetSlot,
+ targetKind,
+ targetData,
(int) (sweetSpotWidth * 100)
);
diff --git a/src/main/java/com/tiedup/remake/minigame/LockpickTargetKind.java b/src/main/java/com/tiedup/remake/minigame/LockpickTargetKind.java
new file mode 100644
index 0000000..0463a8c
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/minigame/LockpickTargetKind.java
@@ -0,0 +1,13 @@
+package com.tiedup.remake.minigame;
+
+/**
+ * Discriminator for {@link LockpickMiniGameState#getTargetKind()}. The
+ * accompanying {@code targetData} int is interpreted as a {@code BodyRegionV2}
+ * ordinal or as an entity id depending on this kind.
+ */
+public enum LockpickTargetKind {
+ /** {@code targetData} is a {@code BodyRegionV2} ordinal. */
+ BODY_REGION,
+ /** {@code targetData} is an entity id (a furniture entity). */
+ FURNITURE_SEAT,
+}
diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java
index 512206a..fa44989 100644
--- a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java
+++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java
@@ -6,6 +6,7 @@ import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.minigame.LockpickMiniGameState;
import com.tiedup.remake.minigame.LockpickMiniGameState.PickAttemptResult;
import com.tiedup.remake.minigame.LockpickSessionManager;
+import com.tiedup.remake.minigame.LockpickTargetKind;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.network.sync.SyncManager;
@@ -115,41 +116,40 @@ public class PacketLockpickAttempt {
ServerPlayer player,
LockpickMiniGameState session
) {
- // Furniture seat lockpick path: presence of furniture_id AND a
- // session_id matching the current session. A ctx without the nonce
- // (or with a foreign nonce) is rejected — this is the branch a
- // stale-ctx bug could otherwise mis-route into.
- CompoundTag furnitureCtx = player
- .getPersistentData()
- .getCompound("tiedup_furniture_lockpick_ctx");
- boolean ctxValid =
- furnitureCtx != null
- && furnitureCtx.contains("furniture_id")
- && furnitureCtx.hasUUID("session_id")
- && furnitureCtx.getUUID("session_id").equals(session.getSessionId());
- if (ctxValid) {
- // Distance check BEFORE endLockpickSession — consuming a
- // session without applying the reward (player walked away)
- // would burn the session with no visible effect.
- int furnitureId = furnitureCtx.getInt("furniture_id");
- Entity furnitureEntity = player.level().getEntity(furnitureId);
+ if (session.getTargetKind() == LockpickTargetKind.FURNITURE_SEAT) {
+ // Validate EVERY input before any side effect. Consuming the
+ // session and damaging the lockpick before verifying that the
+ // unlock will succeed would show "Lock picked!" to the player
+ // while nothing actually unlocks.
+ Entity furnitureEntity = player.level().getEntity(session.getTargetData());
if (
furnitureEntity == null ||
player.distanceTo(furnitureEntity) > 10.0
) {
return;
}
+ CompoundTag furnitureCtx = player
+ .getPersistentData()
+ .getCompound("tiedup_furniture_lockpick_ctx");
+ String seatId = furnitureCtx.contains("seat_id", net.minecraft.nbt.Tag.TAG_STRING)
+ ? furnitureCtx.getString("seat_id")
+ : "";
+ if (seatId.isEmpty()) {
+ TiedUpMod.LOGGER.warn(
+ "[PacketLockpickAttempt] Furniture lockpick ctx missing seat_id for {} — aborting without consuming session",
+ player.getName().getString()
+ );
+ return;
+ }
- // Session validated — now end it
LockpickSessionManager.getInstance().endLockpickSession(
player.getUUID(),
true
);
- handleFurnitureLockpickSuccess(player, furnitureCtx);
+ handleFurnitureLockpickSuccess(player, furnitureEntity, seatId);
player.getPersistentData().remove("tiedup_furniture_lockpick_ctx");
damageLockpick(player);
- // Send result to client
ModNetwork.sendToPlayer(
new PacketLockpickMiniGameResult(
session.getSessionId(),
@@ -167,9 +167,8 @@ public class PacketLockpickAttempt {
true
);
- // Body item lockpick path: targetSlot stores BodyRegionV2 ordinal
BodyRegionV2 targetRegion =
- BodyRegionV2.values()[session.getTargetSlot()];
+ BodyRegionV2.values()[session.getTargetData()];
ItemStack targetStack = V2EquipmentHelper.getInRegion(
player,
targetRegion
@@ -212,24 +211,21 @@ public class PacketLockpickAttempt {
/**
* Handle a successful furniture seat lockpick: unlock the seat, dismount
* the passenger, play the unlock sound, and broadcast the updated state.
+ * Caller is responsible for validating inputs before firing side effects.
*/
private void handleFurnitureLockpickSuccess(
ServerPlayer player,
- CompoundTag ctx
+ Entity furnitureEntity,
+ String seatId
) {
- int furnitureEntityId = ctx.getInt("furniture_id");
- String seatId = ctx.getString("seat_id");
-
- Entity entity = player.level().getEntity(furnitureEntityId);
- if (!(entity instanceof EntityFurniture furniture)) {
+ if (!(furnitureEntity instanceof EntityFurniture furniture)) {
TiedUpMod.LOGGER.warn(
- "[PacketLockpickAttempt] Furniture entity {} not found or wrong type for lockpick success",
- furnitureEntityId
+ "[PacketLockpickAttempt] Lockpick target {} is not an EntityFurniture",
+ furnitureEntity.getId()
);
return;
}
- // Unlock the seat
furniture.setSeatLocked(seatId, false);
// Dismount the passenger in that seat
@@ -244,16 +240,15 @@ public class PacketLockpickAttempt {
passenger.stopRiding();
}
- // Play unlock sound from the furniture definition
FurnitureDefinition def = furniture.getDefinition();
if (def != null && def.feedback().unlockSound() != null) {
player
.level()
.playSound(
null,
- entity.getX(),
- entity.getY(),
- entity.getZ(),
+ furniture.getX(),
+ furniture.getY(),
+ furniture.getZ(),
SoundEvent.createVariableRangeEvent(
def.feedback().unlockSound()
),
@@ -263,13 +258,12 @@ public class PacketLockpickAttempt {
);
}
- // Broadcast updated lock/anim state to all tracking clients
PacketSyncFurnitureState.sendToTracking(furniture);
TiedUpMod.LOGGER.info(
"[PacketLockpickAttempt] Player {} picked furniture lock on entity {} seat '{}'",
player.getName().getString(),
- furnitureEntityId,
+ furniture.getId(),
seatId
);
}
@@ -330,18 +324,13 @@ public class PacketLockpickAttempt {
// Jam mechanic (5%) only applies to body-item sessions — seat locks
// have no ILockable stack to jam.
boolean jammed = false;
- CompoundTag sessionCtx = player
- .getPersistentData()
- .getCompound("tiedup_furniture_lockpick_ctx");
- boolean isFurnitureSession =
- sessionCtx.contains("furniture_id")
- && sessionCtx.hasUUID("session_id")
- && sessionCtx.getUUID("session_id").equals(session.getSessionId());
+ boolean isBodySession =
+ session.getTargetKind() == LockpickTargetKind.BODY_REGION;
- if (!isFurnitureSession && player.getRandom().nextFloat() < 0.05f) {
- int targetSlot = session.getTargetSlot();
- if (targetSlot >= 0 && targetSlot < BodyRegionV2.values().length) {
- BodyRegionV2 targetRegion = BodyRegionV2.values()[targetSlot];
+ if (isBodySession && player.getRandom().nextFloat() < 0.05f) {
+ int targetOrdinal = session.getTargetData();
+ if (targetOrdinal >= 0 && targetOrdinal < BodyRegionV2.values().length) {
+ BodyRegionV2 targetRegion = BodyRegionV2.values()[targetOrdinal];
ItemStack targetStack = V2EquipmentHelper.getInRegion(
player,
targetRegion
diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameStart.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameStart.java
index bafac97..a73c6d8 100644
--- a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameStart.java
+++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickMiniGameStart.java
@@ -122,6 +122,7 @@ public class PacketLockpickMiniGameStart {
LockpickSessionManager manager = LockpickSessionManager.getInstance();
LockpickMiniGameState session = manager.startLockpickSession(
player,
+ com.tiedup.remake.minigame.LockpickTargetKind.BODY_REGION,
targetRegion.ordinal(),
sweetSpotWidth
);
diff --git a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java b/src/main/java/com/tiedup/remake/v2/bondage/client/diagnostic/GlbValidationReloadListener.java
similarity index 94%
rename from src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
rename to src/main/java/com/tiedup/remake/v2/bondage/client/diagnostic/GlbValidationReloadListener.java
index 346303d..7e3a016 100644
--- a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidationReloadListener.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/client/diagnostic/GlbValidationReloadListener.java
@@ -1,5 +1,9 @@
-package com.tiedup.remake.client.gltf.diagnostic;
+package com.tiedup.remake.v2.bondage.client.diagnostic;
+import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic;
+import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnosticRegistry;
+import com.tiedup.remake.client.gltf.diagnostic.GlbValidationResult;
+import com.tiedup.remake.client.gltf.diagnostic.GlbValidator;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.io.InputStream;
diff --git a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java
index 7fcafcf..8ed3b06 100644
--- a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java
+++ b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java
@@ -5,19 +5,25 @@ import com.tiedup.remake.client.model.CellCoreBakedModel;
import com.tiedup.remake.client.renderer.CellCoreRenderer;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.V2BlockEntities;
+import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
+import com.tiedup.remake.v2.bondage.client.diagnostic.GlbValidationReloadListener;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
+import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
+import net.minecraft.client.renderer.entity.player.PlayerRenderer;
import net.minecraft.client.resources.model.BakedModel;
import net.minecraft.client.resources.model.ModelResourceLocation;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.ModelEvent;
+import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
+import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.registries.ForgeRegistries;
@@ -72,6 +78,49 @@ public class V2ClientSetup {
);
}
+ /**
+ * Attach the V2 bondage item render layer to both player model variants.
+ * The layer queries the equipment capability on every player render, so
+ * registering it once covers all players (local + remote).
+ */
+ @SuppressWarnings("unchecked")
+ @SubscribeEvent
+ public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
+ var defaultRenderer = event.getSkin("default");
+ if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
+ playerRenderer.addLayer(
+ new V2BondageRenderLayer<>(playerRenderer)
+ );
+ }
+ var slimRenderer = event.getSkin("slim");
+ if (slimRenderer instanceof PlayerRenderer playerRenderer) {
+ playerRenderer.addLayer(
+ new V2BondageRenderLayer<>(playerRenderer)
+ );
+ }
+ TiedUpMod.LOGGER.info(
+ "[V2ClientSetup] V2 bondage render layer attached to player renderers"
+ );
+ }
+
+ /**
+ * Register bondage-specific reload listeners. Uses {@code LOW} priority so
+ * it fires after {@link com.tiedup.remake.client.gltf.GltfClientSetup}
+ * registers the generic GLB cache clear — item definitions must not load
+ * against a stale GLB cache, and the validator must run against a warm
+ * item registry.
+ */
+ @SubscribeEvent(priority = EventPriority.LOW)
+ public static void onRegisterReloadListeners(
+ RegisterClientReloadListenersEvent event
+ ) {
+ event.registerReloadListener(new DataDrivenItemReloadListener());
+ event.registerReloadListener(new GlbValidationReloadListener());
+ TiedUpMod.LOGGER.info(
+ "[V2ClientSetup] Data-driven item + GLB validation reload listeners registered"
+ );
+ }
+
/**
* Register custom icon models for baking.
*
diff --git a/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java b/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java
index e89523b..b8ccd43 100644
--- a/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java
+++ b/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java
@@ -232,42 +232,19 @@ public class EntityFurniture
syncSeatAssignmentsIfServer();
}
- /**
- * Serialize {@link #seatAssignments} into {@link #SEAT_ASSIGNMENTS_SYNC}
- * so tracking clients see the authoritative mapping. No-op on client.
- */
+ /** Push seatAssignments into SEAT_ASSIGNMENTS_SYNC. No-op on client. */
private void syncSeatAssignmentsIfServer() {
if (this.level().isClientSide) return;
- StringBuilder sb = new StringBuilder(seatAssignments.size() * 40);
- boolean first = true;
- for (Map.Entry entry : seatAssignments.entrySet()) {
- if (!first) sb.append('|');
- sb.append(entry.getKey()).append(';').append(entry.getValue());
- first = false;
- }
- this.entityData.set(SEAT_ASSIGNMENTS_SYNC, sb.toString());
+ this.entityData.set(
+ SEAT_ASSIGNMENTS_SYNC,
+ FurnitureSeatSyncCodec.encode(seatAssignments)
+ );
}
- /**
- * Parse {@link #SEAT_ASSIGNMENTS_SYNC} back into {@link #seatAssignments}.
- * Called on the client when the server's broadcast arrives. Malformed
- * entries (bad UUID, empty seat id) are skipped silently; we don't want
- * to throw on a packet from a future protocol version.
- */
+ /** Rebuild seatAssignments from the server's broadcast. */
private void applySyncedSeatAssignments(String serialized) {
seatAssignments.clear();
- if (serialized.isEmpty()) return;
- for (String entry : serialized.split("\\|")) {
- int sep = entry.indexOf(';');
- if (sep <= 0 || sep == entry.length() - 1) continue;
- try {
- UUID uuid = UUID.fromString(entry.substring(0, sep));
- String seatId = entry.substring(sep + 1);
- seatAssignments.put(uuid, seatId);
- } catch (IllegalArgumentException ignored) {
- // Corrupt UUID — skip this entry, preserve the rest.
- }
- }
+ seatAssignments.putAll(FurnitureSeatSyncCodec.decode(serialized));
}
@Override
@@ -474,98 +451,18 @@ public class EntityFurniture
this.transitionTargetState = STATE_OCCUPIED;
}
- // Note: the previous client-side startFurnitureAnimationClient call is
- // no longer needed here — the animation is kicked off by
- // onSyncedDataUpdated when SEAT_ASSIGNMENTS_SYNC arrives (~1 tick
- // after mount). The cold-cache retry in AnimationTickHandler still
- // covers the case where the GLB hasn't parsed yet.
+ // Seat-pose animation is kicked off from onSyncedDataUpdated once
+ // SEAT_ASSIGNMENTS_SYNC arrives (~1 tick after mount). The cold-
+ // cache retry in AnimationTickHandler covers the case where the
+ // GLB hasn't parsed yet.
}
/**
- * Client-only: (re)start the seat pose animation for a player mounting this
- * furniture. Safe to call repeatedly — {@link BondageAnimationManager#playFurniture}
- * replaces any active animation. No-op if the GLB isn't loaded (cold cache),
- * the seat has no authored clip, or the animation context rejects the setup.
- *
- * Called from three sites:
- *
- * - {@link #addPassenger} on mount
- * - {@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes
- * - The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)
- *
- *
- * Clip selection mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureEntityRenderer#resolveActiveAnimation}
- * for mesh/pose coherence. Fallback chain: state-specific clip → {@code "Occupied"}
- * → first available clip. This lets artists author optional state-specific poses
- * ({@code Entering}, {@code Exiting}, {@code Shake}) without requiring all of them.
- */
- public static void startFurnitureAnimationClient(
- EntityFurniture furniture,
- Player player
- ) {
- if (!furniture.level().isClientSide) return;
-
- SeatDefinition seat = furniture.getSeatForPassenger(player);
- if (seat == null) return;
-
- FurnitureDefinition def = furniture.getDefinition();
- if (def == null) return;
-
- com.tiedup.remake.v2.furniture.client.FurnitureGltfData gltfData =
- com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.get(
- def.modelLocation()
- );
- if (gltfData == null) return;
-
- Map<
- String,
- com.tiedup.remake.client.gltf.GltfData.AnimationClip
- > seatClips = gltfData.seatAnimations().get(seat.id());
- if (seatClips == null || seatClips.isEmpty()) return;
-
- com.tiedup.remake.client.gltf.GltfData seatSkeleton =
- gltfData.seatSkeletons().get(seat.id());
-
- // State-driven clip selection for the player seat armature. Names match
- // the ARTIST_GUIDE.md "Player Seat Animations" section so artists can
- // author matching clips. The fallback chain handles missing clips
- // (state-specific → "Occupied" → first available), so artists only need
- // to author what they want to customize.
- String stateClipName = switch (furniture.getAnimState()) {
- case STATE_OCCUPIED -> "Occupied";
- case STATE_STRUGGLE -> "Struggle";
- case STATE_ENTERING -> "Enter";
- case STATE_EXITING -> "Exit";
- case STATE_LOCKING -> "LockClose";
- case STATE_UNLOCKING -> "LockOpen";
- default -> "Idle";
- };
-
- com.tiedup.remake.client.gltf.GltfData.AnimationClip clip =
- seatClips.get(stateClipName);
- if (clip == null) clip = seatClips.get("Occupied");
- if (clip == null) clip = seatClips.values().iterator().next();
- if (clip == null) return;
-
- dev.kosmx.playerAnim.core.data.KeyframeAnimation anim =
- com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext.create(
- clip,
- seatSkeleton,
- seat.blockedRegions()
- );
- if (anim != null) {
- com.tiedup.remake.client.animation.BondageAnimationManager.playFurniture(
- player,
- anim
- );
- }
- }
-
- /**
- * Client-side: when the synched {@code ANIM_STATE} changes, re-play the seat
- * pose for each seated player so the authored state-specific clip kicks in.
- * Without this, a server-side transition (mount entering → occupied, lock
- * close, struggle start) never propagates to the player's pose.
+ * Client-side: when the synched {@code ANIM_STATE} or seat assignments
+ * change, re-play the seat pose for each seated player so the authored
+ * state-specific clip kicks in. Without this, a server-side transition
+ * (mount entering → occupied, lock close, struggle start) never
+ * propagates to the player's pose.
*/
@Override
public void onSyncedDataUpdated(
@@ -574,22 +471,21 @@ public class EntityFurniture
super.onSyncedDataUpdated(key);
if (!this.level().isClientSide) return;
if (ANIM_STATE.equals(key)) {
- for (Entity passenger : this.getPassengers()) {
- if (passenger instanceof Player player) {
- startFurnitureAnimationClient(this, player);
- }
- }
+ restartAllSeatAnimations();
} else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
applySyncedSeatAssignments(
this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
);
- // Re-play animations for passengers whose seat id just changed.
- // Without this the client could keep rendering a passenger with
- // the previous seat's blockedRegions until some other trigger.
- for (Entity passenger : this.getPassengers()) {
- if (passenger instanceof Player player) {
- startFurnitureAnimationClient(this, player);
- }
+ restartAllSeatAnimations();
+ }
+ }
+
+ /** Client-only — caller guards via level().isClientSide. */
+ private void restartAllSeatAnimations() {
+ for (Entity passenger : this.getPassengers()) {
+ if (passenger instanceof Player player) {
+ com.tiedup.remake.v2.furniture.client.FurnitureClientAnimator
+ .start(this, player);
}
}
}
diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java
index 4f1543b..a7e915b 100644
--- a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java
+++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java
@@ -242,18 +242,9 @@ public final class FurnitureAuthPredicate {
*/
@Nullable
private static LivingEntity findOccupant(Entity furniture, String seatId) {
- if (!(furniture instanceof EntityFurniture ef)) return null;
- for (Entity passenger : ef.getPassengers()) {
- SeatDefinition assigned = ef.getSeatForPassenger(passenger);
- if (
- assigned != null &&
- assigned.id().equals(seatId) &&
- passenger instanceof LivingEntity living
- ) {
- return living;
- }
- }
- return null;
+ if (!(furniture instanceof ISeatProvider provider)) return null;
+ Entity passenger = provider.findPassengerInSeat(seatId);
+ return passenger instanceof LivingEntity living ? living : null;
}
/**
diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodec.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodec.java
new file mode 100644
index 0000000..c0dabbf
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodec.java
@@ -0,0 +1,54 @@
+package com.tiedup.remake.v2.furniture;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Wire-format codec for the {@code seatAssignments} map synced to clients
+ * via the SEAT_ASSIGNMENTS_SYNC entity data accessor.
+ *
+ * Format: {@code uuid;seatId|uuid;seatId|…} (empty string = no
+ * assignments). Seat ids cannot contain {@code |} or {@code ;} — enforced
+ * at parse time in {@link FurnitureParser}.
+ */
+public final class FurnitureSeatSyncCodec {
+
+ private FurnitureSeatSyncCodec() {}
+
+ /** Serialize an assignments map to its wire form. */
+ public static String encode(Map assignments) {
+ if (assignments.isEmpty()) return "";
+ StringBuilder sb = new StringBuilder(assignments.size() * 40);
+ boolean first = true;
+ for (Map.Entry entry : assignments.entrySet()) {
+ if (!first) sb.append('|');
+ sb.append(entry.getKey()).append(';').append(entry.getValue());
+ first = false;
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Parse a wire-form string back into an assignments map. Malformed
+ * entries (invalid UUID, missing separator, empty seat id) are skipped
+ * silently so a packet from a future protocol version can still be
+ * consumed at worst-effort without throwing.
+ */
+ public static Map decode(String serialized) {
+ Map result = new LinkedHashMap<>();
+ if (serialized == null || serialized.isEmpty()) return result;
+ for (String entry : serialized.split("\\|")) {
+ int sep = entry.indexOf(';');
+ if (sep <= 0 || sep == entry.length() - 1) continue;
+ try {
+ UUID uuid = UUID.fromString(entry.substring(0, sep));
+ String seatId = entry.substring(sep + 1);
+ result.put(uuid, seatId);
+ } catch (IllegalArgumentException ignored) {
+ // Corrupt UUID — skip this entry, preserve the rest.
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java b/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java
index 51231f0..d3c87a4 100644
--- a/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java
+++ b/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java
@@ -7,11 +7,15 @@ import net.minecraft.world.entity.Entity;
import org.jetbrains.annotations.Nullable;
/**
- * Universal interface for entities that hold players in constrained poses.
+ * Contract for entities that hold passengers in named seats.
*
- * Implemented by EntityFurniture (static) and optionally by monsters/NPCs.
- * All downstream systems (packets, animation, rendering) check ISeatProvider,
- * never EntityFurniture directly.
+ * {@link EntityFurniture} is currently the only implementation. The
+ * interface covers the seat-lookup / lock / region surface but NOT
+ * animation state or definition reference — callers that need those
+ * still downcast to {@code EntityFurniture}. Adding a second
+ * implementation (NPC carrying a captive, a block acting as a chair,
+ * …) would require extending this interface first; the existing
+ * consumers aren't polymorphic over those methods today.
*/
public interface ISeatProvider {
/** All seat definitions for this entity. */
diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureClientAnimator.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureClientAnimator.java
new file mode 100644
index 0000000..178601d
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureClientAnimator.java
@@ -0,0 +1,83 @@
+package com.tiedup.remake.v2.furniture.client;
+
+import com.tiedup.remake.client.animation.BondageAnimationManager;
+import com.tiedup.remake.client.gltf.GltfData;
+import com.tiedup.remake.v2.furniture.EntityFurniture;
+import com.tiedup.remake.v2.furniture.FurnitureDefinition;
+import com.tiedup.remake.v2.furniture.SeatDefinition;
+import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
+import java.util.Map;
+import net.minecraft.world.entity.player.Player;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.api.distmarker.OnlyIn;
+
+/**
+ * Client-only: start the seat-pose animation for a player on a furniture
+ * entity. Selects the authored clip for the current animation state, falls
+ * back through {@code "Occupied"} → first available, and dispatches the
+ * result to {@link BondageAnimationManager#playFurniture}.
+ *
+ * Safe to call repeatedly — {@code playFurniture} replaces any active
+ * animation. No-op if the GLB isn't loaded yet (cold cache) or the seat
+ * has no authored clip.
+ */
+@OnlyIn(Dist.CLIENT)
+public final class FurnitureClientAnimator {
+
+ private FurnitureClientAnimator() {}
+
+ /**
+ * Kick off (or restart) the seat-pose animation. Callers:
+ *
+ * - {@code EntityFurniture.addPassenger} — on mount
+ * - {@code EntityFurniture.onSyncedDataUpdated} — on
+ * {@code ANIM_STATE} / {@code SEAT_ASSIGNMENTS_SYNC} change
+ * - {@code AnimationTickHandler} cold-cache retry
+ *
+ *
+ * Clip selection matches {@code FurnitureEntityRenderer.resolveActiveAnimation}
+ * to keep mesh and pose in lockstep.
+ */
+ public static void start(EntityFurniture furniture, Player player) {
+ if (!furniture.level().isClientSide) return;
+
+ SeatDefinition seat = furniture.getSeatForPassenger(player);
+ if (seat == null) return;
+
+ FurnitureDefinition def = furniture.getDefinition();
+ if (def == null) return;
+
+ FurnitureGltfData gltfData = FurnitureGltfCache.get(def.modelLocation());
+ if (gltfData == null) return;
+
+ Map seatClips =
+ gltfData.seatAnimations().get(seat.id());
+ if (seatClips == null || seatClips.isEmpty()) return;
+
+ GltfData seatSkeleton = gltfData.seatSkeletons().get(seat.id());
+
+ String stateClipName = switch (furniture.getAnimState()) {
+ case EntityFurniture.STATE_OCCUPIED -> "Occupied";
+ case EntityFurniture.STATE_STRUGGLE -> "Struggle";
+ case EntityFurniture.STATE_ENTERING -> "Enter";
+ case EntityFurniture.STATE_EXITING -> "Exit";
+ case EntityFurniture.STATE_LOCKING -> "LockClose";
+ case EntityFurniture.STATE_UNLOCKING -> "LockOpen";
+ default -> "Idle";
+ };
+
+ GltfData.AnimationClip clip = seatClips.get(stateClipName);
+ if (clip == null) clip = seatClips.get("Occupied");
+ if (clip == null) clip = seatClips.values().iterator().next();
+ if (clip == null) return;
+
+ KeyframeAnimation anim = FurnitureAnimationContext.create(
+ clip,
+ seatSkeleton,
+ seat.blockedRegions()
+ );
+ if (anim != null) {
+ BondageAnimationManager.playFurniture(player, anim);
+ }
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java
index 8d62107..470c403 100644
--- a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java
+++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java
@@ -363,18 +363,14 @@ public class PacketFurnitureEscape {
0.15f - (totalDifficulty / (float) MAX_DIFFICULTY) * 0.12f
);
- // Start lockpick session via LockpickSessionManager.
- // The existing lockpick session uses a targetSlot (BodyRegionV2 ordinal) for
- // bondage items. For furniture, we repurpose targetSlot as the furniture entity ID
- // and store the seat ID in a context tag so the completion callback can find it.
- // For now, we use the simplified approach: start the session and let the existing
- // PacketLockpickAttempt handler manage the sweet-spot interaction. On success,
- // the furniture-specific completion is handled by a post-session check.
+ // Start a FURNITURE_SEAT-kind session; PacketLockpickAttempt branches
+ // on the kind to run the furniture-specific completion.
LockpickSessionManager lockpickManager =
LockpickSessionManager.getInstance();
LockpickMiniGameState session = lockpickManager.startLockpickSession(
sender,
- furnitureEntity.getId(), // repurpose targetSlot as entity ID
+ com.tiedup.remake.minigame.LockpickTargetKind.FURNITURE_SEAT,
+ furnitureEntity.getId(),
sweetSpotWidth
);
@@ -388,16 +384,12 @@ public class PacketFurnitureEscape {
session.setRemainingUses(remainingUses);
- // Store furniture context in the sender's persistent data so the
- // lockpick attempt handler can resolve the furniture on success.
- // This is cleaned up when the session ends.
+ // Store seat_id in persistent data so the completion callback can
+ // find it (the session carries furniture id via targetData, but
+ // seat id has nowhere else to live yet).
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
ctx.putInt("furniture_id", furnitureEntity.getId());
ctx.putString("seat_id", targetSeat.id());
- // Nonce: the handler accepts this ctx only when session_id matches
- // the active session. Prevents stale ctx from mis-routing a later
- // body-item lockpick.
- ctx.putUUID("session_id", session.getSessionId());
sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx);
// Send initial lockpick state to open the minigame GUI on the client
diff --git a/src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodecTest.java b/src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodecTest.java
new file mode 100644
index 0000000..7c99c51
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodecTest.java
@@ -0,0 +1,88 @@
+package com.tiedup.remake.v2.furniture;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class FurnitureSeatSyncCodecTest {
+
+ private static final UUID U1 = UUID.fromString(
+ "00000000-0000-0000-0000-000000000001"
+ );
+ private static final UUID U2 = UUID.fromString(
+ "00000000-0000-0000-0000-000000000002"
+ );
+
+ @Test
+ @DisplayName("empty map encodes to empty string")
+ void empty() {
+ assertEquals("", FurnitureSeatSyncCodec.encode(Map.of()));
+ assertTrue(FurnitureSeatSyncCodec.decode("").isEmpty());
+ }
+
+ @Test
+ @DisplayName("single entry round-trip")
+ void single() {
+ Map m = new LinkedHashMap<>();
+ m.put(U1, "left");
+ String wire = FurnitureSeatSyncCodec.encode(m);
+ assertEquals(U1 + ";left", wire);
+ assertEquals(m, FurnitureSeatSyncCodec.decode(wire));
+ }
+
+ @Test
+ @DisplayName("multi entry round-trip preserves insertion order")
+ void multi() {
+ Map m = new LinkedHashMap<>();
+ m.put(U1, "left");
+ m.put(U2, "right");
+ String wire = FurnitureSeatSyncCodec.encode(m);
+ Map back = FurnitureSeatSyncCodec.decode(wire);
+ assertEquals(m, back);
+ }
+
+ @Test
+ @DisplayName("decode skips corrupt UUIDs and preserves valid entries")
+ void skipsCorrupt() {
+ String wire = "not-a-uuid;left|" + U2 + ";right";
+ Map back = FurnitureSeatSyncCodec.decode(wire);
+ assertEquals(1, back.size());
+ assertEquals("right", back.get(U2));
+ }
+
+ @Test
+ @DisplayName("decode rejects entries with missing seat id")
+ void rejectsEmptySeatId() {
+ String wire = U1 + ";";
+ assertTrue(FurnitureSeatSyncCodec.decode(wire).isEmpty());
+ }
+
+ @Test
+ @DisplayName("decode tolerates null input")
+ void nullInput() {
+ assertTrue(FurnitureSeatSyncCodec.decode(null).isEmpty());
+ }
+
+ @Test
+ @DisplayName("decode skips entries with no separator")
+ void skipsNoSeparator() {
+ String wire = "abc|" + U1 + ";right";
+ Map back = FurnitureSeatSyncCodec.decode(wire);
+ assertEquals(1, back.size());
+ assertEquals("right", back.get(U1));
+ }
+
+ @Test
+ @DisplayName("decode skips entries starting with separator")
+ void skipsLeadingSeparator() {
+ String wire = ";seat|" + U1 + ";right";
+ Map back = FurnitureSeatSyncCodec.decode(wire);
+ assertEquals(1, back.size());
+ assertEquals("right", back.get(U1));
+ }
+}