From cc6a62a6e59e4115aed4fa4ac065c302b101ac26 Mon Sep 17 00:00:00 2001 From: NotEvil Date: Sun, 19 Apr 2026 02:06:02 +0200 Subject: [PATCH] Polish V2 subsystem: lockpick kinds, package boundaries, client extractions Architectural debt cleanup on top of the earlier V2 hardening pass. Minigame: - LockpickMiniGameState splits the overloaded targetSlot int into a LockpickTargetKind enum + targetData int. Body-vs-furniture dispatch is now a simple enum check; the NBT-tag nonce it previously depended on is gone, along with the AIOOBE risk at BodyRegionV2.values()[targetSlot]. - PacketLockpickAttempt.handleFurnitureLockpickSuccess takes the entity and seat id as explicit parameters. Caller pre-validates both before any side effect, so a corrupted ctx tag can no longer produce a "Lock picked!" UI with a used lockpick and nothing unlocked. Package boundaries: - client.gltf no longer imports v2.bondage. Render-layer attachment, DataDrivenItemReloadListener, and GlbValidationReloadListener all live in v2.client.V2ClientSetup. - GlbValidationReloadListener moved to v2.bondage.client.diagnostic. - Reload-listener ordering is preserved via EventPriority (HIGH for the generic GLB cache clear in GltfClientSetup, LOW for bondage consumers in V2ClientSetup). - Removed the unused validateAgainstDefinition stub on GlbValidator. Extractions from EntityFurniture: - FurnitureSeatSyncCodec (pipe/semicolon serialization for the SEAT_ASSIGNMENTS_SYNC entity data field), with 8 unit tests. - FurnitureClientAnimator (client-only seat-pose kickoff, moved out of the dual-side entity class). - EntityFurniture drops ~100 lines with no behavior change. Interface docs: - ISeatProvider Javadoc narrowed to reflect that EntityFurniture is the only implementation; callers that need animation state or definition reference still downcast. - FurnitureAuthPredicate.findOccupant uses the interface only. - AnimationIdBuilder flagged as legacy JSON-era utility (NPC fallback + MCA mixin). Artist guide: corrected the "Monster Seat System (Planned)" section to match the ISeatProvider single-impl reality. --- docs/ARTIST_GUIDE.md | 2 +- .../animation/tick/AnimationTickHandler.java | 6 +- .../animation/util/AnimationIdBuilder.java | 32 ++-- .../remake/client/gltf/GltfClientSetup.java | 114 +++---------- .../client/gltf/diagnostic/GlbValidator.java | 18 -- .../minigame/LockpickMiniGameState.java | 22 ++- .../minigame/LockpickSessionManager.java | 22 ++- .../remake/minigame/LockpickTargetKind.java | 13 ++ .../minigame/PacketLockpickAttempt.java | 87 +++++----- .../minigame/PacketLockpickMiniGameStart.java | 1 + .../GlbValidationReloadListener.java | 6 +- .../remake/v2/client/V2ClientSetup.java | 49 ++++++ .../remake/v2/furniture/EntityFurniture.java | 158 +++--------------- .../v2/furniture/FurnitureAuthPredicate.java | 15 +- .../v2/furniture/FurnitureSeatSyncCodec.java | 54 ++++++ .../remake/v2/furniture/ISeatProvider.java | 12 +- .../client/FurnitureClientAnimator.java | 83 +++++++++ .../network/PacketFurnitureEscape.java | 22 +-- .../furniture/FurnitureSeatSyncCodecTest.java | 88 ++++++++++ 19 files changed, 442 insertions(+), 362 deletions(-) create mode 100644 src/main/java/com/tiedup/remake/minigame/LockpickTargetKind.java rename src/main/java/com/tiedup/remake/{client/gltf => v2/bondage/client}/diagnostic/GlbValidationReloadListener.java (94%) create mode 100644 src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodec.java create mode 100644 src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureClientAnimator.java create mode 100644 src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatSyncCodecTest.java 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:

* + * + *

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:

*
    - *
  1. GLB cache clear (inline listener below) — must run - * first. Inside this single listener's {@code apply()}: - *
      - *
    1. 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.
    2. - *
    3. 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.
    4. - *
    5. Clear {@code FurnitureGltfCache} last, after the GLB - * layer has repopulated its registry but before any - * downstream item listener queries furniture models.
    6. - *
    - *
  2. - *
  3. 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.
  4. - *
  5. 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.
  6. + *
  7. Drop the three mutually-independent byte caches + * ({@code GltfCache}, {@code GltfAnimationApplier.invalidateCache}, + * {@code GltfMeshRenderer.clearRenderTypeCache}).
  8. + *
  9. Reload {@code ContextGlbRegistry} before clearing + * {@code ContextAnimationFactory.clearCache()} — otherwise + * the next factory lookup rebuilds clips against the stale + * registry and caches the wrong data.
  10. + *
  11. Clear {@code FurnitureGltfCache} last.
  12. *
* - *

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)); + } +}