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.
This commit is contained in:
NotEvil
2026-04-19 02:06:02 +02:00
parent d391b892aa
commit cc6a62a6e5
19 changed files with 442 additions and 362 deletions

View File

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

View File

@@ -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.",

View File

@@ -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.
*
* <p>Centralizes the logic for constructing animation file names.
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
*
* <p>Animation naming convention:
* <pre>
* {poseType}_{bindMode}_{variant}.json
*
* poseType: tied_up_basic | straitjacket | wrap | latex_sack
* bindMode: (empty for FULL) | _arms | _legs
* variant: _idle | _struggle | (empty for static)
* </pre>
*
* <p>Examples:
* <p><b>Legacy path.</b> 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:</p>
* <ul>
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li>
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li>
* <li>tiedup:wrap_idle - WRAP + FULL + idle</li>
* <li>{@code NpcAnimationTickHandler} — JSON fallback when a tied
* NPC has no GLB-bearing item equipped.</li>
* <li>{@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.</li>
* </ul>
*
* <p>New code should not depend on this class. Animation naming:
* {@code {poseType}_{bindMode}_{variant}} (e.g.
* {@code tiedup:straitjacket_arms_struggle}).</p>
*/
@OnlyIn(Dist.CLIENT)
public final class AnimationIdBuilder {

View File

@@ -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.
*
* <p><b>ORDER MATTERS — do not rearrange without checking the
* invariants below.</b> 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.</p>
* Register the generic GLB cache-clear reload listener.
*
* <p>{@code HIGH} priority so it fires before any downstream
* listener that consumes GLB state. Within this single {@code apply}
* block, the sub-order matters:</p>
* <ol>
* <li><b>GLB cache clear</b> (inline listener below) — must run
* first. Inside this single listener's {@code apply()}:
* <ol type="a">
* <li>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.</li>
* <li>Reload {@code ContextGlbRegistry} from the new
* resource packs <i>before</i> 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.</li>
* <li>Clear {@code FurnitureGltfCache} last, after the GLB
* layer has repopulated its registry but before any
* downstream item listener queries furniture models.</li>
* </ol>
* </li>
* <li><b>Data-driven item reload</b>
* ({@code DataDrivenItemReloadListener}) — consumes the
* reloaded GLB registry indirectly via item JSON references.
* Must run <i>after</i> the GLB cache clear so any item that
* reaches into the GLB layer during load picks up fresh data.</li>
* <li><b>GLB validation</b>
* ({@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.</li>
* <li>Drop the three mutually-independent byte caches
* ({@code GltfCache}, {@code GltfAnimationApplier.invalidateCache},
* {@code GltfMeshRenderer.clearRenderTypeCache}).</li>
* <li>Reload {@code ContextGlbRegistry} before clearing
* {@code ContextAnimationFactory.clearCache()} — otherwise
* the next factory lookup rebuilds clips against the stale
* registry and caches the wrong data.</li>
* <li>Clear {@code FurnitureGltfCache} last.</li>
* </ol>
*
* <p>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).</p>
* <p>Bondage-specific listeners register at {@code LOW} priority from
* {@code V2ClientSetup.onRegisterReloadListeners}, so the cache-clear
* here is guaranteed to land first.</p>
*/
@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");
}
}

View File

@@ -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<GlbDiagnostic> validateAgainstDefinition(
GlbValidationResult result,
DataDrivenItemDefinition def
) {
return Collections.emptyList();
}
// ------------------------------------------------------------------ //
// Header + JSON chunk extraction //
// ------------------------------------------------------------------ //

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<UUID, String> 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.
*
* <p>Called from three sites:</p>
* <ul>
* <li>{@link #addPassenger} on mount</li>
* <li>{@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes</li>
* <li>The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)</li>
* </ul>
*
* <p>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.</p>
*/
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);
}
}
}

View File

@@ -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;
}
/**

View File

@@ -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.
*
* <p>Format: {@code uuid;seatId|uuid;seatId|…} (empty string = no
* assignments). Seat ids cannot contain {@code |} or {@code ;} — enforced
* at parse time in {@link FurnitureParser}.</p>
*/
public final class FurnitureSeatSyncCodec {
private FurnitureSeatSyncCodec() {}
/** Serialize an assignments map to its wire form. */
public static String encode(Map<UUID, String> assignments) {
if (assignments.isEmpty()) return "";
StringBuilder sb = new StringBuilder(assignments.size() * 40);
boolean first = true;
for (Map.Entry<UUID, String> 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<UUID, String> decode(String serialized) {
Map<UUID, String> 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;
}
}

View File

@@ -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.
*
* <p>Implemented by EntityFurniture (static) and optionally by monsters/NPCs.
* All downstream systems (packets, animation, rendering) check ISeatProvider,
* never EntityFurniture directly.</p>
* <p>{@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.</p>
*/
public interface ISeatProvider {
/** All seat definitions for this entity. */

View File

@@ -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}.
*
* <p>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.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class FurnitureClientAnimator {
private FurnitureClientAnimator() {}
/**
* Kick off (or restart) the seat-pose animation. Callers:
* <ul>
* <li>{@code EntityFurniture.addPassenger} — on mount</li>
* <li>{@code EntityFurniture.onSyncedDataUpdated} — on
* {@code ANIM_STATE} / {@code SEAT_ASSIGNMENTS_SYNC} change</li>
* <li>{@code AnimationTickHandler} cold-cache retry</li>
* </ul>
*
* <p>Clip selection matches {@code FurnitureEntityRenderer.resolveActiveAnimation}
* to keep mesh and pose in lockstep.</p>
*/
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<String, GltfData.AnimationClip> 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);
}
}
}

View File

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

View File

@@ -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<UUID, String> 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<UUID, String> m = new LinkedHashMap<>();
m.put(U1, "left");
m.put(U2, "right");
String wire = FurnitureSeatSyncCodec.encode(m);
Map<UUID, String> 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<UUID, String> 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<UUID, String> 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<UUID, String> back = FurnitureSeatSyncCodec.decode(wire);
assertEquals(1, back.size());
assertEquals("right", back.get(U1));
}
}