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:
@@ -1589,7 +1589,7 @@ Two players can be locked side by side. The mod picks the seat nearest to where
|
|||||||
|
|
||||||
### Monster Seat System (Planned)
|
### 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -137,10 +137,8 @@ public class AnimationTickHandler {
|
|||||||
);
|
);
|
||||||
if (retries < MAX_FURNITURE_RETRIES) {
|
if (retries < MAX_FURNITURE_RETRIES) {
|
||||||
furnitureRetryCounters.put(playerUuid, retries + 1);
|
furnitureRetryCounters.put(playerUuid, retries + 1);
|
||||||
com.tiedup.remake.v2.furniture.EntityFurniture.startFurnitureAnimationClient(
|
com.tiedup.remake.v2.furniture.client.FurnitureClientAnimator
|
||||||
furniture,
|
.start(furniture, player);
|
||||||
player
|
|
||||||
);
|
|
||||||
if (retries + 1 == MAX_FURNITURE_RETRIES) {
|
if (retries + 1 == MAX_FURNITURE_RETRIES) {
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"[FurnitureAnim] Giving up on furniture animation retry for {} after {} attempts — GLB likely has no Player_* armature.",
|
"[FurnitureAnim] Giving up on furniture animation retry for {} after {} attempts — GLB likely has no Player_* armature.",
|
||||||
|
|||||||
@@ -6,26 +6,24 @@ import net.minecraftforge.api.distmarker.Dist;
|
|||||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
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.
|
* <p><b>Legacy path.</b> The V2 player pipeline resolves animations from
|
||||||
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
|
* GLB models via {@code GltfAnimationApplier.applyV2Animation} +
|
||||||
*
|
* {@code RegionBoneMapper} and does not touch this class. Only two
|
||||||
* <p>Animation naming convention:
|
* callers remain:</p>
|
||||||
* <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:
|
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li>
|
* <li>{@code NpcAnimationTickHandler} — JSON fallback when a tied
|
||||||
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li>
|
* NPC has no GLB-bearing item equipped.</li>
|
||||||
* <li>tiedup:wrap_idle - WRAP + FULL + idle</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>
|
* </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)
|
@OnlyIn(Dist.CLIENT)
|
||||||
public final class AnimationIdBuilder {
|
public final class AnimationIdBuilder {
|
||||||
|
|||||||
@@ -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.ContextAnimationFactory;
|
||||||
import com.tiedup.remake.client.animation.context.ContextGlbRegistry;
|
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.ResourceManager;
|
||||||
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||||
import net.minecraft.util.profiling.ProfilerFiller;
|
import net.minecraft.util.profiling.ProfilerFiller;
|
||||||
import net.minecraftforge.api.distmarker.Dist;
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
import net.minecraftforge.client.event.EntityRenderersEvent;
|
|
||||||
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
|
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.EventPriority;
|
||||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
import net.minecraftforge.fml.common.Mod;
|
import net.minecraftforge.fml.common.Mod;
|
||||||
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
|
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
|
||||||
@@ -18,8 +15,9 @@ import org.apache.logging.log4j.LogManager;
|
|||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forge event registration for the glTF pipeline.
|
* MOD-bus setup for the generic glTF pipeline — init + cache-invalidation
|
||||||
* Registers render layers and animation factory.
|
* reload listener. Bondage-specific registrations (render layer, item-aware
|
||||||
|
* reload listeners) live in {@code V2ClientSetup}.
|
||||||
*/
|
*/
|
||||||
public final class GltfClientSetup {
|
public final class GltfClientSetup {
|
||||||
|
|
||||||
@@ -27,9 +25,6 @@ public final class GltfClientSetup {
|
|||||||
|
|
||||||
private GltfClientSetup() {}
|
private GltfClientSetup() {}
|
||||||
|
|
||||||
/**
|
|
||||||
* MOD bus event subscribers (FMLClientSetupEvent, AddLayers).
|
|
||||||
*/
|
|
||||||
@Mod.EventBusSubscriber(
|
@Mod.EventBusSubscriber(
|
||||||
modid = "tiedup",
|
modid = "tiedup",
|
||||||
bus = Mod.EventBusSubscriber.Bus.MOD,
|
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
|
* Register the generic GLB cache-clear reload listener.
|
||||||
* 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>
|
|
||||||
*
|
*
|
||||||
|
* <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>
|
* <ol>
|
||||||
* <li><b>GLB cache clear</b> (inline listener below) — must run
|
* <li>Drop the three mutually-independent byte caches
|
||||||
* first. Inside this single listener's {@code apply()}:
|
* ({@code GltfCache}, {@code GltfAnimationApplier.invalidateCache},
|
||||||
* <ol type="a">
|
* {@code GltfMeshRenderer.clearRenderTypeCache}).</li>
|
||||||
* <li>Blow away the raw GLB byte caches
|
* <li>Reload {@code ContextGlbRegistry} before clearing
|
||||||
* ({@code GltfCache.clearCache},
|
* {@code ContextAnimationFactory.clearCache()} — otherwise
|
||||||
* {@code GltfAnimationApplier.invalidateCache},
|
* the next factory lookup rebuilds clips against the stale
|
||||||
* {@code GltfMeshRenderer.clearRenderTypeCache}).
|
* registry and caches the wrong data.</li>
|
||||||
* These three caches are mutually independent — none
|
* <li>Clear {@code FurnitureGltfCache} last.</li>
|
||||||
* 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>
|
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <p>When adding a new listener: decide where it sits in this
|
* <p>Bondage-specific listeners register at {@code LOW} priority from
|
||||||
* producer/consumer chain. If you're not sure, add it at the end
|
* {@code V2ClientSetup.onRegisterReloadListeners}, so the cache-clear
|
||||||
* (the safest position — the rest of the graph is already built).</p>
|
* here is guaranteed to land first.</p>
|
||||||
*/
|
*/
|
||||||
@SubscribeEvent
|
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||||
public static void onRegisterReloadListeners(
|
public static void onRegisterReloadListeners(
|
||||||
RegisterClientReloadListenersEvent event
|
RegisterClientReloadListenersEvent event
|
||||||
) {
|
) {
|
||||||
@@ -145,9 +85,6 @@ public final class GltfClientSetup {
|
|||||||
GltfCache.clearCache();
|
GltfCache.clearCache();
|
||||||
GltfAnimationApplier.invalidateCache();
|
GltfAnimationApplier.invalidateCache();
|
||||||
GltfMeshRenderer.clearRenderTypeCache();
|
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);
|
ContextGlbRegistry.reload(resourceManager);
|
||||||
ContextAnimationFactory.clearCache();
|
ContextAnimationFactory.clearCache();
|
||||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
|
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import com.google.gson.JsonObject;
|
|||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
import com.tiedup.remake.client.gltf.GltfBoneMapper;
|
import com.tiedup.remake.client.gltf.GltfBoneMapper;
|
||||||
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic.Severity;
|
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic.Severity;
|
||||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraftforge.api.distmarker.Dist;
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
@@ -77,22 +75,6 @@ public final class GlbValidator {
|
|||||||
return GlbValidationResult.of(source, diagnostics);
|
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 //
|
// Header + JSON chunk extraction //
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public class LockpickMiniGameState {
|
|||||||
private final UUID sessionId;
|
private final UUID sessionId;
|
||||||
private final UUID playerId;
|
private final UUID playerId;
|
||||||
private final long createdAt;
|
private final long createdAt;
|
||||||
private final int targetSlot;
|
private final LockpickTargetKind targetKind;
|
||||||
|
private final int targetData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session timeout in milliseconds (2 minutes)
|
* Session timeout in milliseconds (2 minutes)
|
||||||
@@ -58,12 +59,14 @@ public class LockpickMiniGameState {
|
|||||||
|
|
||||||
public LockpickMiniGameState(
|
public LockpickMiniGameState(
|
||||||
UUID playerId,
|
UUID playerId,
|
||||||
int targetSlot,
|
LockpickTargetKind targetKind,
|
||||||
|
int targetData,
|
||||||
float sweetSpotWidth
|
float sweetSpotWidth
|
||||||
) {
|
) {
|
||||||
this.sessionId = UUID.randomUUID();
|
this.sessionId = UUID.randomUUID();
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
this.targetSlot = targetSlot;
|
this.targetKind = targetKind;
|
||||||
|
this.targetData = targetData;
|
||||||
this.createdAt = System.currentTimeMillis();
|
this.createdAt = System.currentTimeMillis();
|
||||||
|
|
||||||
// Generate random sweet spot position
|
// Generate random sweet spot position
|
||||||
@@ -91,8 +94,12 @@ public class LockpickMiniGameState {
|
|||||||
return playerId;
|
return playerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getTargetSlot() {
|
public LockpickTargetKind getTargetKind() {
|
||||||
return targetSlot;
|
return targetKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTargetData() {
|
||||||
|
return targetData;
|
||||||
}
|
}
|
||||||
|
|
||||||
public float getSweetSpotCenter() {
|
public float getSweetSpotCenter() {
|
||||||
@@ -269,10 +276,11 @@ public class LockpickMiniGameState {
|
|||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return String.format(
|
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),
|
sessionId.toString().substring(0, 8),
|
||||||
playerId.toString().substring(0, 8),
|
playerId.toString().substring(0, 8),
|
||||||
targetSlot,
|
targetKind,
|
||||||
|
targetData,
|
||||||
currentPosition,
|
currentPosition,
|
||||||
sweetSpotCenter,
|
sweetSpotCenter,
|
||||||
sweetSpotWidth,
|
sweetSpotWidth,
|
||||||
|
|||||||
@@ -33,17 +33,19 @@ public class LockpickSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new lockpick session for a player.
|
* Start a new lockpick session for a player. Replaces any existing
|
||||||
* If player already has an active session, it will be replaced (handles ESC cancel case).
|
* session (ESC-cancel case).
|
||||||
*
|
*
|
||||||
* @param player The server player
|
* @param player The server player
|
||||||
* @param targetSlot The bondage slot being picked
|
* @param targetKind Whether the target is a body region or a seat
|
||||||
* @param sweetSpotWidth The width of the sweet spot (based on tool)
|
* @param targetData BodyRegionV2 ordinal or entity id, depending on kind
|
||||||
|
* @param sweetSpotWidth Width of the sweet spot (based on tool)
|
||||||
* @return The new session
|
* @return The new session
|
||||||
*/
|
*/
|
||||||
public LockpickMiniGameState startLockpickSession(
|
public LockpickMiniGameState startLockpickSession(
|
||||||
ServerPlayer player,
|
ServerPlayer player,
|
||||||
int targetSlot,
|
LockpickTargetKind targetKind,
|
||||||
|
int targetData,
|
||||||
float sweetSpotWidth
|
float sweetSpotWidth
|
||||||
) {
|
) {
|
||||||
UUID playerId = player.getUUID();
|
UUID playerId = player.getUUID();
|
||||||
@@ -61,16 +63,18 @@ public class LockpickSessionManager {
|
|||||||
// Create new session
|
// Create new session
|
||||||
LockpickMiniGameState session = new LockpickMiniGameState(
|
LockpickMiniGameState session = new LockpickMiniGameState(
|
||||||
playerId,
|
playerId,
|
||||||
targetSlot,
|
targetKind,
|
||||||
|
targetData,
|
||||||
sweetSpotWidth
|
sweetSpotWidth
|
||||||
);
|
);
|
||||||
lockpickSessions.put(playerId, session);
|
lockpickSessions.put(playerId, session);
|
||||||
|
|
||||||
TiedUpMod.LOGGER.info(
|
TiedUpMod.LOGGER.info(
|
||||||
"[LockpickSessionManager] Started lockpick session {} for {} (slot: {}, width: {}%)",
|
"[LockpickSessionManager] Started lockpick session {} for {} (kind: {}, data: {}, width: {}%)",
|
||||||
session.getSessionId().toString().substring(0, 8),
|
session.getSessionId().toString().substring(0, 8),
|
||||||
player.getName().getString(),
|
player.getName().getString(),
|
||||||
targetSlot,
|
targetKind,
|
||||||
|
targetData,
|
||||||
(int) (sweetSpotWidth * 100)
|
(int) (sweetSpotWidth * 100)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.tiedup.remake.items.base.ILockable;
|
|||||||
import com.tiedup.remake.minigame.LockpickMiniGameState;
|
import com.tiedup.remake.minigame.LockpickMiniGameState;
|
||||||
import com.tiedup.remake.minigame.LockpickMiniGameState.PickAttemptResult;
|
import com.tiedup.remake.minigame.LockpickMiniGameState.PickAttemptResult;
|
||||||
import com.tiedup.remake.minigame.LockpickSessionManager;
|
import com.tiedup.remake.minigame.LockpickSessionManager;
|
||||||
|
import com.tiedup.remake.minigame.LockpickTargetKind;
|
||||||
import com.tiedup.remake.network.ModNetwork;
|
import com.tiedup.remake.network.ModNetwork;
|
||||||
import com.tiedup.remake.network.PacketRateLimiter;
|
import com.tiedup.remake.network.PacketRateLimiter;
|
||||||
import com.tiedup.remake.network.sync.SyncManager;
|
import com.tiedup.remake.network.sync.SyncManager;
|
||||||
@@ -115,41 +116,40 @@ public class PacketLockpickAttempt {
|
|||||||
ServerPlayer player,
|
ServerPlayer player,
|
||||||
LockpickMiniGameState session
|
LockpickMiniGameState session
|
||||||
) {
|
) {
|
||||||
// Furniture seat lockpick path: presence of furniture_id AND a
|
if (session.getTargetKind() == LockpickTargetKind.FURNITURE_SEAT) {
|
||||||
// session_id matching the current session. A ctx without the nonce
|
// Validate EVERY input before any side effect. Consuming the
|
||||||
// (or with a foreign nonce) is rejected — this is the branch a
|
// session and damaging the lockpick before verifying that the
|
||||||
// stale-ctx bug could otherwise mis-route into.
|
// unlock will succeed would show "Lock picked!" to the player
|
||||||
CompoundTag furnitureCtx = player
|
// while nothing actually unlocks.
|
||||||
.getPersistentData()
|
Entity furnitureEntity = player.level().getEntity(session.getTargetData());
|
||||||
.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 (
|
if (
|
||||||
furnitureEntity == null ||
|
furnitureEntity == null ||
|
||||||
player.distanceTo(furnitureEntity) > 10.0
|
player.distanceTo(furnitureEntity) > 10.0
|
||||||
) {
|
) {
|
||||||
return;
|
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(
|
LockpickSessionManager.getInstance().endLockpickSession(
|
||||||
player.getUUID(),
|
player.getUUID(),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
handleFurnitureLockpickSuccess(player, furnitureCtx);
|
handleFurnitureLockpickSuccess(player, furnitureEntity, seatId);
|
||||||
player.getPersistentData().remove("tiedup_furniture_lockpick_ctx");
|
player.getPersistentData().remove("tiedup_furniture_lockpick_ctx");
|
||||||
damageLockpick(player);
|
damageLockpick(player);
|
||||||
|
|
||||||
// Send result to client
|
|
||||||
ModNetwork.sendToPlayer(
|
ModNetwork.sendToPlayer(
|
||||||
new PacketLockpickMiniGameResult(
|
new PacketLockpickMiniGameResult(
|
||||||
session.getSessionId(),
|
session.getSessionId(),
|
||||||
@@ -167,9 +167,8 @@ public class PacketLockpickAttempt {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Body item lockpick path: targetSlot stores BodyRegionV2 ordinal
|
|
||||||
BodyRegionV2 targetRegion =
|
BodyRegionV2 targetRegion =
|
||||||
BodyRegionV2.values()[session.getTargetSlot()];
|
BodyRegionV2.values()[session.getTargetData()];
|
||||||
ItemStack targetStack = V2EquipmentHelper.getInRegion(
|
ItemStack targetStack = V2EquipmentHelper.getInRegion(
|
||||||
player,
|
player,
|
||||||
targetRegion
|
targetRegion
|
||||||
@@ -212,24 +211,21 @@ public class PacketLockpickAttempt {
|
|||||||
/**
|
/**
|
||||||
* Handle a successful furniture seat lockpick: unlock the seat, dismount
|
* Handle a successful furniture seat lockpick: unlock the seat, dismount
|
||||||
* the passenger, play the unlock sound, and broadcast the updated state.
|
* the passenger, play the unlock sound, and broadcast the updated state.
|
||||||
|
* Caller is responsible for validating inputs before firing side effects.
|
||||||
*/
|
*/
|
||||||
private void handleFurnitureLockpickSuccess(
|
private void handleFurnitureLockpickSuccess(
|
||||||
ServerPlayer player,
|
ServerPlayer player,
|
||||||
CompoundTag ctx
|
Entity furnitureEntity,
|
||||||
|
String seatId
|
||||||
) {
|
) {
|
||||||
int furnitureEntityId = ctx.getInt("furniture_id");
|
if (!(furnitureEntity instanceof EntityFurniture furniture)) {
|
||||||
String seatId = ctx.getString("seat_id");
|
|
||||||
|
|
||||||
Entity entity = player.level().getEntity(furnitureEntityId);
|
|
||||||
if (!(entity instanceof EntityFurniture furniture)) {
|
|
||||||
TiedUpMod.LOGGER.warn(
|
TiedUpMod.LOGGER.warn(
|
||||||
"[PacketLockpickAttempt] Furniture entity {} not found or wrong type for lockpick success",
|
"[PacketLockpickAttempt] Lockpick target {} is not an EntityFurniture",
|
||||||
furnitureEntityId
|
furnitureEntity.getId()
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlock the seat
|
|
||||||
furniture.setSeatLocked(seatId, false);
|
furniture.setSeatLocked(seatId, false);
|
||||||
|
|
||||||
// Dismount the passenger in that seat
|
// Dismount the passenger in that seat
|
||||||
@@ -244,16 +240,15 @@ public class PacketLockpickAttempt {
|
|||||||
passenger.stopRiding();
|
passenger.stopRiding();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play unlock sound from the furniture definition
|
|
||||||
FurnitureDefinition def = furniture.getDefinition();
|
FurnitureDefinition def = furniture.getDefinition();
|
||||||
if (def != null && def.feedback().unlockSound() != null) {
|
if (def != null && def.feedback().unlockSound() != null) {
|
||||||
player
|
player
|
||||||
.level()
|
.level()
|
||||||
.playSound(
|
.playSound(
|
||||||
null,
|
null,
|
||||||
entity.getX(),
|
furniture.getX(),
|
||||||
entity.getY(),
|
furniture.getY(),
|
||||||
entity.getZ(),
|
furniture.getZ(),
|
||||||
SoundEvent.createVariableRangeEvent(
|
SoundEvent.createVariableRangeEvent(
|
||||||
def.feedback().unlockSound()
|
def.feedback().unlockSound()
|
||||||
),
|
),
|
||||||
@@ -263,13 +258,12 @@ public class PacketLockpickAttempt {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast updated lock/anim state to all tracking clients
|
|
||||||
PacketSyncFurnitureState.sendToTracking(furniture);
|
PacketSyncFurnitureState.sendToTracking(furniture);
|
||||||
|
|
||||||
TiedUpMod.LOGGER.info(
|
TiedUpMod.LOGGER.info(
|
||||||
"[PacketLockpickAttempt] Player {} picked furniture lock on entity {} seat '{}'",
|
"[PacketLockpickAttempt] Player {} picked furniture lock on entity {} seat '{}'",
|
||||||
player.getName().getString(),
|
player.getName().getString(),
|
||||||
furnitureEntityId,
|
furniture.getId(),
|
||||||
seatId
|
seatId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -330,18 +324,13 @@ public class PacketLockpickAttempt {
|
|||||||
// Jam mechanic (5%) only applies to body-item sessions — seat locks
|
// Jam mechanic (5%) only applies to body-item sessions — seat locks
|
||||||
// have no ILockable stack to jam.
|
// have no ILockable stack to jam.
|
||||||
boolean jammed = false;
|
boolean jammed = false;
|
||||||
CompoundTag sessionCtx = player
|
boolean isBodySession =
|
||||||
.getPersistentData()
|
session.getTargetKind() == LockpickTargetKind.BODY_REGION;
|
||||||
.getCompound("tiedup_furniture_lockpick_ctx");
|
|
||||||
boolean isFurnitureSession =
|
|
||||||
sessionCtx.contains("furniture_id")
|
|
||||||
&& sessionCtx.hasUUID("session_id")
|
|
||||||
&& sessionCtx.getUUID("session_id").equals(session.getSessionId());
|
|
||||||
|
|
||||||
if (!isFurnitureSession && player.getRandom().nextFloat() < 0.05f) {
|
if (isBodySession && player.getRandom().nextFloat() < 0.05f) {
|
||||||
int targetSlot = session.getTargetSlot();
|
int targetOrdinal = session.getTargetData();
|
||||||
if (targetSlot >= 0 && targetSlot < BodyRegionV2.values().length) {
|
if (targetOrdinal >= 0 && targetOrdinal < BodyRegionV2.values().length) {
|
||||||
BodyRegionV2 targetRegion = BodyRegionV2.values()[targetSlot];
|
BodyRegionV2 targetRegion = BodyRegionV2.values()[targetOrdinal];
|
||||||
ItemStack targetStack = V2EquipmentHelper.getInRegion(
|
ItemStack targetStack = V2EquipmentHelper.getInRegion(
|
||||||
player,
|
player,
|
||||||
targetRegion
|
targetRegion
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ public class PacketLockpickMiniGameStart {
|
|||||||
LockpickSessionManager manager = LockpickSessionManager.getInstance();
|
LockpickSessionManager manager = LockpickSessionManager.getInstance();
|
||||||
LockpickMiniGameState session = manager.startLockpickSession(
|
LockpickMiniGameState session = manager.startLockpickSession(
|
||||||
player,
|
player,
|
||||||
|
com.tiedup.remake.minigame.LockpickTargetKind.BODY_REGION,
|
||||||
targetRegion.ordinal(),
|
targetRegion.ordinal(),
|
||||||
sweetSpotWidth
|
sweetSpotWidth
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.DataDrivenItemDefinition;
|
||||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -5,19 +5,25 @@ import com.tiedup.remake.client.model.CellCoreBakedModel;
|
|||||||
import com.tiedup.remake.client.renderer.CellCoreRenderer;
|
import com.tiedup.remake.client.renderer.CellCoreRenderer;
|
||||||
import com.tiedup.remake.core.TiedUpMod;
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
import com.tiedup.remake.v2.V2BlockEntities;
|
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.DataDrivenItemDefinition;
|
||||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
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.FurnitureDefinition;
|
||||||
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
|
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
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.BakedModel;
|
||||||
import net.minecraft.client.resources.model.ModelResourceLocation;
|
import net.minecraft.client.resources.model.ModelResourceLocation;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraftforge.api.distmarker.Dist;
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
import net.minecraftforge.client.event.EntityRenderersEvent;
|
import net.minecraftforge.client.event.EntityRenderersEvent;
|
||||||
import net.minecraftforge.client.event.ModelEvent;
|
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.eventbus.api.SubscribeEvent;
|
||||||
import net.minecraftforge.fml.common.Mod;
|
import net.minecraftforge.fml.common.Mod;
|
||||||
import net.minecraftforge.registries.ForgeRegistries;
|
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.
|
* Register custom icon models for baking.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -232,42 +232,19 @@ public class EntityFurniture
|
|||||||
syncSeatAssignmentsIfServer();
|
syncSeatAssignmentsIfServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Push seatAssignments into SEAT_ASSIGNMENTS_SYNC. No-op on client. */
|
||||||
* Serialize {@link #seatAssignments} into {@link #SEAT_ASSIGNMENTS_SYNC}
|
|
||||||
* so tracking clients see the authoritative mapping. No-op on client.
|
|
||||||
*/
|
|
||||||
private void syncSeatAssignmentsIfServer() {
|
private void syncSeatAssignmentsIfServer() {
|
||||||
if (this.level().isClientSide) return;
|
if (this.level().isClientSide) return;
|
||||||
StringBuilder sb = new StringBuilder(seatAssignments.size() * 40);
|
this.entityData.set(
|
||||||
boolean first = true;
|
SEAT_ASSIGNMENTS_SYNC,
|
||||||
for (Map.Entry<UUID, String> entry : seatAssignments.entrySet()) {
|
FurnitureSeatSyncCodec.encode(seatAssignments)
|
||||||
if (!first) sb.append('|');
|
);
|
||||||
sb.append(entry.getKey()).append(';').append(entry.getValue());
|
|
||||||
first = false;
|
|
||||||
}
|
|
||||||
this.entityData.set(SEAT_ASSIGNMENTS_SYNC, sb.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Rebuild seatAssignments from the server's broadcast. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private void applySyncedSeatAssignments(String serialized) {
|
private void applySyncedSeatAssignments(String serialized) {
|
||||||
seatAssignments.clear();
|
seatAssignments.clear();
|
||||||
if (serialized.isEmpty()) return;
|
seatAssignments.putAll(FurnitureSeatSyncCodec.decode(serialized));
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -474,98 +451,18 @@ public class EntityFurniture
|
|||||||
this.transitionTargetState = STATE_OCCUPIED;
|
this.transitionTargetState = STATE_OCCUPIED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: the previous client-side startFurnitureAnimationClient call is
|
// Seat-pose animation is kicked off from onSyncedDataUpdated once
|
||||||
// no longer needed here — the animation is kicked off by
|
// SEAT_ASSIGNMENTS_SYNC arrives (~1 tick after mount). The cold-
|
||||||
// onSyncedDataUpdated when SEAT_ASSIGNMENTS_SYNC arrives (~1 tick
|
// cache retry in AnimationTickHandler covers the case where the
|
||||||
// after mount). The cold-cache retry in AnimationTickHandler still
|
// GLB hasn't parsed yet.
|
||||||
// covers the case where the GLB hasn't parsed yet.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-only: (re)start the seat pose animation for a player mounting this
|
* Client-side: when the synched {@code ANIM_STATE} or seat assignments
|
||||||
* furniture. Safe to call repeatedly — {@link BondageAnimationManager#playFurniture}
|
* change, re-play the seat pose for each seated player so the authored
|
||||||
* replaces any active animation. No-op if the GLB isn't loaded (cold cache),
|
* state-specific clip kicks in. Without this, a server-side transition
|
||||||
* the seat has no authored clip, or the animation context rejects the setup.
|
* (mount entering → occupied, lock close, struggle start) never
|
||||||
*
|
* propagates to the player's pose.
|
||||||
* <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.
|
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void onSyncedDataUpdated(
|
public void onSyncedDataUpdated(
|
||||||
@@ -574,22 +471,21 @@ public class EntityFurniture
|
|||||||
super.onSyncedDataUpdated(key);
|
super.onSyncedDataUpdated(key);
|
||||||
if (!this.level().isClientSide) return;
|
if (!this.level().isClientSide) return;
|
||||||
if (ANIM_STATE.equals(key)) {
|
if (ANIM_STATE.equals(key)) {
|
||||||
for (Entity passenger : this.getPassengers()) {
|
restartAllSeatAnimations();
|
||||||
if (passenger instanceof Player player) {
|
|
||||||
startFurnitureAnimationClient(this, player);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
|
} else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
|
||||||
applySyncedSeatAssignments(
|
applySyncedSeatAssignments(
|
||||||
this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
|
this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
|
||||||
);
|
);
|
||||||
// Re-play animations for passengers whose seat id just changed.
|
restartAllSeatAnimations();
|
||||||
// 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) {
|
/** Client-only — caller guards via level().isClientSide. */
|
||||||
startFurnitureAnimationClient(this, player);
|
private void restartAllSeatAnimations() {
|
||||||
}
|
for (Entity passenger : this.getPassengers()) {
|
||||||
|
if (passenger instanceof Player player) {
|
||||||
|
com.tiedup.remake.v2.furniture.client.FurnitureClientAnimator
|
||||||
|
.start(this, player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,18 +242,9 @@ public final class FurnitureAuthPredicate {
|
|||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
private static LivingEntity findOccupant(Entity furniture, String seatId) {
|
private static LivingEntity findOccupant(Entity furniture, String seatId) {
|
||||||
if (!(furniture instanceof EntityFurniture ef)) return null;
|
if (!(furniture instanceof ISeatProvider provider)) return null;
|
||||||
for (Entity passenger : ef.getPassengers()) {
|
Entity passenger = provider.findPassengerInSeat(seatId);
|
||||||
SeatDefinition assigned = ef.getSeatForPassenger(passenger);
|
return passenger instanceof LivingEntity living ? living : null;
|
||||||
if (
|
|
||||||
assigned != null &&
|
|
||||||
assigned.id().equals(seatId) &&
|
|
||||||
passenger instanceof LivingEntity living
|
|
||||||
) {
|
|
||||||
return living;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,15 @@ import net.minecraft.world.entity.Entity;
|
|||||||
import org.jetbrains.annotations.Nullable;
|
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.
|
* <p>{@link EntityFurniture} is currently the only implementation. The
|
||||||
* All downstream systems (packets, animation, rendering) check ISeatProvider,
|
* interface covers the seat-lookup / lock / region surface but NOT
|
||||||
* never EntityFurniture directly.</p>
|
* 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 {
|
public interface ISeatProvider {
|
||||||
/** All seat definitions for this entity. */
|
/** All seat definitions for this entity. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -363,18 +363,14 @@ public class PacketFurnitureEscape {
|
|||||||
0.15f - (totalDifficulty / (float) MAX_DIFFICULTY) * 0.12f
|
0.15f - (totalDifficulty / (float) MAX_DIFFICULTY) * 0.12f
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start lockpick session via LockpickSessionManager.
|
// Start a FURNITURE_SEAT-kind session; PacketLockpickAttempt branches
|
||||||
// The existing lockpick session uses a targetSlot (BodyRegionV2 ordinal) for
|
// on the kind to run the furniture-specific completion.
|
||||||
// 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.
|
|
||||||
LockpickSessionManager lockpickManager =
|
LockpickSessionManager lockpickManager =
|
||||||
LockpickSessionManager.getInstance();
|
LockpickSessionManager.getInstance();
|
||||||
LockpickMiniGameState session = lockpickManager.startLockpickSession(
|
LockpickMiniGameState session = lockpickManager.startLockpickSession(
|
||||||
sender,
|
sender,
|
||||||
furnitureEntity.getId(), // repurpose targetSlot as entity ID
|
com.tiedup.remake.minigame.LockpickTargetKind.FURNITURE_SEAT,
|
||||||
|
furnitureEntity.getId(),
|
||||||
sweetSpotWidth
|
sweetSpotWidth
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -388,16 +384,12 @@ public class PacketFurnitureEscape {
|
|||||||
|
|
||||||
session.setRemainingUses(remainingUses);
|
session.setRemainingUses(remainingUses);
|
||||||
|
|
||||||
// Store furniture context in the sender's persistent data so the
|
// Store seat_id in persistent data so the completion callback can
|
||||||
// lockpick attempt handler can resolve the furniture on success.
|
// find it (the session carries furniture id via targetData, but
|
||||||
// This is cleaned up when the session ends.
|
// seat id has nowhere else to live yet).
|
||||||
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
|
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
|
||||||
ctx.putInt("furniture_id", furnitureEntity.getId());
|
ctx.putInt("furniture_id", furnitureEntity.getId());
|
||||||
ctx.putString("seat_id", targetSeat.id());
|
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);
|
sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx);
|
||||||
|
|
||||||
// Send initial lockpick state to open the minigame GUI on the client
|
// Send initial lockpick state to open the minigame GUI on the client
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user