ANIM_STATE =
SynchedEntityData.defineId(
EntityFurniture.class,
EntityDataSerializers.BYTE
);
/**
* Passenger UUID → seat id, serialized as
* {@code "uuid;seatId|uuid;seatId|..."} (empty string = no assignments).
* Seat id itself must not contain {@code |} or {@code ;} — furniture
* definitions use lowercase snake_case which is safe.
*
* Server updates this string alongside every {@link #seatAssignments}
* mutation so clients see the authoritative mapping. Without this, each
* side independently ran {@code findNearestAvailableSeat} and could
* diverge on multi-seat furniture (wrong render offset, wrong anim).
*/
private static final EntityDataAccessor SEAT_ASSIGNMENTS_SYNC =
SynchedEntityData.defineId(
EntityFurniture.class,
EntityDataSerializers.STRING
);
// ========== Animation State Constants ==========
public static final byte STATE_IDLE = 0;
public static final byte STATE_OCCUPIED = 1;
public static final byte STATE_LOCKING = 2;
public static final byte STATE_STRUGGLE = 3;
public static final byte STATE_UNLOCKING = 4;
public static final byte STATE_ENTERING = 5;
public static final byte STATE_EXITING = 6;
// ========== Server-side State ==========
/**
* Maps passenger UUID to their assigned seat ID. Server-authoritative.
* Persisted in NBT so seat assignments survive chunk unload/reload.
*/
private final Map seatAssignments = new HashMap<>();
/**
* Accumulated damage from player attacks. Decays slowly per tick.
* When this reaches the definition's {@code breakResistance}, the furniture breaks.
*/
private float currentDamage = 0f;
/**
* Ticks remaining for a transitional animation state (ENTERING, EXITING, LOCKING, UNLOCKING).
* When this reaches 0, the animation state is reset to {@link #transitionTargetState}.
*/
private int transitionTicksLeft = 0;
/** The state to transition to after the current animation completes. */
private byte transitionTargetState = STATE_IDLE;
// ========== Constructor ==========
public EntityFurniture(EntityType> type, Level level) {
super(type, level);
// Prevent players from placing blocks inside the furniture's hitbox
this.blocksBuilding = true;
}
// ========== SynchedEntityData Registration ==========
@Override
protected void defineSynchedData() {
// Entity base class registers its own fields before calling this.
// Direct Entity subclasses do NOT call super (it is abstract).
this.entityData.define(FURNITURE_ID, "");
this.entityData.define(SEAT_LOCK_BITS, (byte) 0);
this.entityData.define(ANIM_STATE, STATE_IDLE);
this.entityData.define(SEAT_ASSIGNMENTS_SYNC, "");
}
// ========== IEntityAdditionalSpawnData ==========
/**
* Server-side: write furniture ID and facing to the spawn packet.
* This is the only reliable way to sync the definition ID to the client
* before the entity's first render frame.
*/
@Override
public void writeSpawnData(FriendlyByteBuf buffer) {
buffer.writeUtf(getFurnitureId());
buffer.writeFloat(this.getYRot());
}
/**
* Client-side: read furniture ID and facing from the spawn packet.
* Called before the entity is added to the client world.
*/
@Override
public void readSpawnData(FriendlyByteBuf additionalData) {
setFurnitureId(additionalData.readUtf());
this.setYRot(additionalData.readFloat());
// Recalculate bounding box with the correct definition dimensions
this.refreshDimensions();
}
// ========== Variable Dimensions ==========
/**
* Returns dimensions from the furniture definition, falling back to 1x1
* if the definition is missing (e.g., data pack removed the furniture type).
*/
@Override
public EntityDimensions getDimensions(Pose pose) {
FurnitureDefinition def = getDefinition();
if (def != null) {
return EntityDimensions.fixed(
def.hitboxWidth(),
def.hitboxHeight()
);
}
return EntityDimensions.fixed(1.0f, 1.0f);
}
// ========== ISeatProvider Implementation ==========
@Override
public List getSeats() {
FurnitureDefinition def = getDefinition();
return def != null ? def.seats() : Collections.emptyList();
}
@Override
@Nullable
public SeatDefinition getSeatForPassenger(Entity passenger) {
String seatId = seatAssignments.get(passenger.getUUID());
if (seatId == null) return null;
FurnitureDefinition def = getDefinition();
return def != null ? def.getSeat(seatId) : null;
}
@Override
public void assignSeat(Entity passenger, String seatId) {
seatAssignments.put(passenger.getUUID(), seatId);
syncSeatAssignmentsIfServer();
}
@Override
public void releaseSeat(Entity passenger) {
seatAssignments.remove(passenger.getUUID());
syncSeatAssignmentsIfServer();
}
/**
* Serialize {@link #seatAssignments} into {@link #SEAT_ASSIGNMENTS_SYNC}
* so tracking clients see the authoritative mapping. No-op on client.
*/
private void syncSeatAssignmentsIfServer() {
if (this.level().isClientSide) return;
StringBuilder sb = new StringBuilder(seatAssignments.size() * 40);
boolean first = true;
for (Map.Entry entry : seatAssignments.entrySet()) {
if (!first) sb.append('|');
sb.append(entry.getKey()).append(';').append(entry.getValue());
first = false;
}
this.entityData.set(SEAT_ASSIGNMENTS_SYNC, sb.toString());
}
/**
* 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) {
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.
}
}
}
@Override
public boolean isSeatLocked(String seatId) {
FurnitureDefinition def = getDefinition();
if (def == null) return false;
int idx = def.getSeatIndex(seatId);
if (idx < 0) return false;
return (this.entityData.get(SEAT_LOCK_BITS) & (1 << idx)) != 0;
}
@Override
public void setSeatLocked(String seatId, boolean locked) {
FurnitureDefinition def = getDefinition();
if (def == null) return;
int idx = def.getSeatIndex(seatId);
if (idx < 0) return;
byte bits = this.entityData.get(SEAT_LOCK_BITS);
if (locked) {
bits |= (byte) (1 << idx);
} else {
bits &= (byte) ~(1 << idx);
}
this.entityData.set(SEAT_LOCK_BITS, bits);
// Update persistent data for reconnection system on the seated player
if (!this.level().isClientSide) {
Entity passenger = findPassengerInSeat(seatId);
if (passenger instanceof ServerPlayer serverPlayer) {
if (locked) {
writeFurnitureReconnectionTag(serverPlayer, seatId);
} else {
serverPlayer
.getPersistentData()
.remove("tiedup_locked_furniture");
}
}
}
}
/**
* Find the passenger entity sitting in a specific seat.
*
* @param seatId the seat identifier to search for
* @return the passenger entity, or null if the seat is unoccupied
*/
@Nullable
public Entity findPassengerInSeat(String seatId) {
for (Entity passenger : this.getPassengers()) {
String assignedSeat = seatAssignments.get(passenger.getUUID());
if (seatId.equals(assignedSeat)) {
return passenger;
}
}
return null;
}
/**
* Write the reconnection tag to a player's persistent data so they can be
* re-mounted on login if they disconnect while locked in a furniture seat.
*
* @param player the server player locked in this furniture
* @param seatId the seat ID the player is locked in
*/
private void writeFurnitureReconnectionTag(
ServerPlayer player,
String seatId
) {
CompoundTag tag = new CompoundTag();
BlockPos pos = this.blockPosition();
tag.putInt("x", pos.getX());
tag.putInt("y", pos.getY());
tag.putInt("z", pos.getZ());
tag.putString("dim", this.level().dimension().location().toString());
tag.putString("furniture_uuid", this.getStringUUID());
tag.putString("seat_id", seatId);
player.getPersistentData().put("tiedup_locked_furniture", tag);
}
@Override
public int getLockedDifficulty(String seatId) {
FurnitureDefinition def = getDefinition();
if (def == null) return 0;
SeatDefinition seat = def.getSeat(seatId);
return seat != null ? seat.lockedDifficulty() : 0;
}
@Override
public Set getBlockedRegions(String seatId) {
FurnitureDefinition def = getDefinition();
if (def == null) return Collections.emptySet();
SeatDefinition seat = def.getSeat(seatId);
return seat != null ? seat.blockedRegions() : Collections.emptySet();
}
@Override
public boolean hasItemDifficultyBonus(String seatId) {
FurnitureDefinition def = getDefinition();
if (def == null) return false;
SeatDefinition seat = def.getSeat(seatId);
return seat != null && seat.itemDifficultyBonus();
}
// ========== Vanilla Riding System ==========
@Override
protected boolean canAddPassenger(Entity passenger) {
FurnitureDefinition def = getDefinition();
return def != null && this.getPassengers().size() < def.seats().size();
}
/**
* Position the passenger at their assigned seat's world position.
*
* On the client side, the exact seat transform is read from the parsed GLB
* data via {@code FurnitureSeatPositionHelper} (client-only). On the server
* side (or when GLB data is unavailable), an approximate position is computed
* by spacing seats evenly along the furniture's local right axis.
*/
@Override
protected void positionRider(
Entity passenger,
Entity.MoveFunction moveFunction
) {
if (!this.hasPassenger(passenger)) return;
SeatDefinition seat = getSeatForPassenger(passenger);
if (seat == null) {
// Fallback: center of entity at base height
moveFunction.accept(
passenger,
this.getX(),
this.getY(),
this.getZ()
);
return;
}
// Client-side: try to use real seat transforms parsed from the GLB model.
// FurnitureSeatPositionHelper is @OnlyIn(Dist.CLIENT) so we guard with isClientSide.
if (this.level().isClientSide) {
FurnitureDefinition def = getDefinition();
if (def != null) {
double[] pos =
com.tiedup.remake.v2.furniture.client.FurnitureSeatPositionHelper.getSeatWorldPosition(
def,
seat.id(),
this.getX(),
this.getY(),
this.getZ(),
this.getYRot()
);
if (pos != null) {
moveFunction.accept(passenger, pos[0], pos[1], pos[2]);
return;
}
}
}
// Server-side fallback (or missing GLB data): approximate positioning.
// Seats are spaced evenly along the entity's local right axis.
FurnitureDefinition def = getDefinition();
int seatCount = def != null ? def.seats().size() : 1;
int seatIdx = def != null ? def.getSeatIndex(seat.id()) : 0;
if (seatIdx < 0) seatIdx = 0;
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
double offset = FurnitureSeatGeometry.seatOffset(seatIdx, seatCount);
moveFunction.accept(
passenger,
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + right.z * offset
);
}
/**
* Called after a passenger is added to the entity.
* Assigns the passenger to the nearest available seat based on the passenger's
* look direction, and updates animation state.
*/
@Override
protected void addPassenger(Entity passenger) {
super.addPassenger(passenger);
// Seat selection is server-authoritative. The client waits for the
// SEAT_ASSIGNMENTS_SYNC broadcast (~1 tick later) before knowing
// which seat this passenger is in. During that gap positionRider
// falls back to entity center — 1 frame of imperceptible glitch
// on mount, in exchange for deterministic multi-seat correctness.
if (!this.level().isClientSide) {
SeatDefinition nearest = findNearestAvailableSeat(passenger);
if (nearest != null) {
assignSeat(passenger, nearest.id());
}
}
// Play entering transition: furniture shows "Occupied" clip while player settles in.
// After 20 ticks (1 second), state stabilizes to OCCUPIED.
if (!this.level().isClientSide) {
this.entityData.set(ANIM_STATE, STATE_ENTERING);
this.transitionTicksLeft = 20;
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.
}
/**
* Client-only: (re)start the seat pose animation for a player mounting this
* furniture. Safe to call repeatedly — {@link BondageAnimationManager#playFurniture}
* replaces any active animation. No-op if the GLB isn't loaded (cold cache),
* the seat has no authored clip, or the animation context rejects the setup.
*
* Called from three sites:
*
* - {@link #addPassenger} on mount
* - {@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes
* - The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)
*
*
* Clip selection mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureEntityRenderer#resolveActiveAnimation}
* for mesh/pose coherence. Fallback chain: state-specific clip → {@code "Occupied"}
* → first available clip. This lets artists author optional state-specific poses
* ({@code Entering}, {@code Exiting}, {@code Shake}) without requiring all of them.
*/
public static void startFurnitureAnimationClient(
EntityFurniture furniture,
Player player
) {
if (!furniture.level().isClientSide) return;
SeatDefinition seat = furniture.getSeatForPassenger(player);
if (seat == null) return;
FurnitureDefinition def = furniture.getDefinition();
if (def == null) return;
com.tiedup.remake.v2.furniture.client.FurnitureGltfData gltfData =
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.get(
def.modelLocation()
);
if (gltfData == null) return;
Map<
String,
com.tiedup.remake.client.gltf.GltfData.AnimationClip
> seatClips = gltfData.seatAnimations().get(seat.id());
if (seatClips == null || seatClips.isEmpty()) return;
com.tiedup.remake.client.gltf.GltfData seatSkeleton =
gltfData.seatSkeletons().get(seat.id());
// State-driven clip selection for the player seat armature. Names match
// the ARTIST_GUIDE.md "Player Seat Animations" section so artists can
// author matching clips. The fallback chain handles missing clips
// (state-specific → "Occupied" → first available), so artists only need
// to author what they want to customize.
String stateClipName = switch (furniture.getAnimState()) {
case STATE_OCCUPIED -> "Occupied";
case STATE_STRUGGLE -> "Struggle";
case STATE_ENTERING -> "Enter";
case STATE_EXITING -> "Exit";
case STATE_LOCKING -> "LockClose";
case STATE_UNLOCKING -> "LockOpen";
default -> "Idle";
};
com.tiedup.remake.client.gltf.GltfData.AnimationClip clip =
seatClips.get(stateClipName);
if (clip == null) clip = seatClips.get("Occupied");
if (clip == null) clip = seatClips.values().iterator().next();
if (clip == null) return;
dev.kosmx.playerAnim.core.data.KeyframeAnimation anim =
com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext.create(
clip,
seatSkeleton,
seat.blockedRegions()
);
if (anim != null) {
com.tiedup.remake.client.animation.BondageAnimationManager.playFurniture(
player,
anim
);
}
}
/**
* Client-side: when the synched {@code ANIM_STATE} changes, re-play the seat
* pose for each seated player so the authored state-specific clip kicks in.
* Without this, a server-side transition (mount entering → occupied, lock
* close, struggle start) never propagates to the player's pose.
*/
@Override
public void onSyncedDataUpdated(
net.minecraft.network.syncher.EntityDataAccessor> key
) {
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);
}
}
} 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);
}
}
}
}
/**
* Find the available seat whose approximate world position has the smallest
* angle to the passenger's look direction.
*
* Since we don't have GLB armature transforms yet (Task 14), seats are
* approximated as evenly spaced along the entity's local right axis.
* Seat 0 is at entity center, seat 1 is offset +1 block on the right, etc.
* The entity's Y rotation determines the right axis.
*
* @param passenger the entity being seated
* @return the best available seat, or null if no seats are available
*/
@Nullable
private SeatDefinition findNearestAvailableSeat(Entity passenger) {
FurnitureDefinition def = getDefinition();
if (def == null || def.seats().isEmpty()) return null;
Vec3 passengerPos = passenger.getEyePosition();
Vec3 lookDir = passenger.getLookAngle();
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
List seats = def.seats();
int seatCount = seats.size();
for (int i = 0; i < seatCount; i++) {
SeatDefinition seat = seats.get(i);
// Skip already-occupied seats
if (seatAssignments.containsValue(seat.id())) continue;
// Approximate seat world position: entity origin + offset along right axis.
// For a single seat, it's at center. For multiple, spread evenly.
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
Vec3 seatWorldPos = new Vec3(
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + right.z * offset
);
// Score: angle between passenger look direction and direction to seat.
// Use negative dot product of normalized vectors (smaller angle = lower score).
Vec3 toSeat = seatWorldPos.subtract(passengerPos);
double distSq = toSeat.lengthSqr();
if (distSq < 1e-6) {
// Passenger is essentially on top of the seat — best possible score
best = seat;
bestScore = -1.0;
continue;
}
Vec3 toSeatNorm = toSeat.normalize();
// Dot product: 1.0 = looking directly at seat, -1.0 = looking away.
// We want the highest dot product, so use negative as score.
double dot = lookDir.dot(toSeatNorm);
double score = -dot;
if (score < bestScore) {
bestScore = score;
best = seat;
}
}
return best;
}
/**
* Find the occupied, lockable seat whose approximate world position is
* closest to the player's look direction. Used for key interactions so
* that multi-seat furniture targets the seat the player is looking at.
*
* @param player the player performing the lock/unlock interaction
* @param def the furniture definition (must not be null)
* @return the best matching seat, or null if no occupied lockable seats exist
*/
@Nullable
private SeatDefinition findNearestOccupiedLockableSeat(
Player player,
FurnitureDefinition def
) {
Vec3 playerPos = player.getEyePosition();
Vec3 lookDir = player.getLookAngle();
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
List seats = def.seats();
int seatCount = seats.size();
for (int i = 0; i < seatCount; i++) {
SeatDefinition seat = seats.get(i);
// Only consider occupied, lockable seats
if (
!seat.lockable() || !seatAssignments.containsValue(seat.id())
) continue;
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
Vec3 seatWorldPos = new Vec3(
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + right.z * offset
);
Vec3 toSeat = seatWorldPos.subtract(playerPos);
double distSq = toSeat.lengthSqr();
if (distSq < 1e-6) {
best = seat;
bestScore = -1.0;
continue;
}
Vec3 toSeatNorm = toSeat.normalize();
double dot = lookDir.dot(toSeatNorm);
double score = -dot;
if (score < bestScore) {
bestScore = score;
best = seat;
}
}
return best;
}
/**
* Called when a passenger is being removed from the entity.
*
* If the passenger's seat is locked and neither the entity nor passenger
* is being removed/killed, the dismount is cancelled by re-mounting the
* passenger on the next server tick via {@code getServer().execute()}.
*
* This deferred re-mount pattern is the same one used by
* {@link com.tiedup.remake.events.captivity.ForcedSeatingHandler} for forced seating.
* We cannot call {@code startRiding()} during the removePassenger call chain
* because the passenger list is being mutated.
*/
@Override
protected void removePassenger(Entity passenger) {
SeatDefinition seat = getSeatForPassenger(passenger);
if (seat != null && isSeatLocked(seat.id()) && !this.isRemoved()) {
// Play denied sound to the passenger attempting to leave a locked seat
if (passenger instanceof ServerPlayer sp) {
FurnitureDefinition def = getDefinition();
if (def != null && def.feedback().deniedSound() != null) {
sp
.level()
.playSound(
null,
sp.getX(),
sp.getY(),
sp.getZ(),
SoundEvent.createVariableRangeEvent(
def.feedback().deniedSound()
),
SoundSource.PLAYERS,
0.5f,
1.0f
);
}
}
// Locked seat: prevent voluntary dismount by re-mounting next tick
super.removePassenger(passenger);
if (this.getServer() != null) {
String lockedSeatId = seat.id();
Entity self = this;
this.getServer().execute(() -> {
if (
passenger.isAlive() &&
self.isAlive() &&
!self.isRemoved()
) {
passenger.startRiding(self, true);
// Re-assign the specific seat (not just first available)
assignSeat(passenger, lockedSeatId);
} else {
// Passenger disconnected or entity removed — clean up the orphaned seat
releaseSeat(passenger);
updateAnimState();
}
});
}
return;
}
// Normal dismount
super.removePassenger(passenger);
if (seat != null) {
releaseSeat(passenger);
}
// Clear reconnection tag on normal dismount
if (passenger instanceof ServerPlayer serverPlayer) {
serverPlayer.getPersistentData().remove("tiedup_locked_furniture");
}
// Play exiting transition: furniture transitions to Idle over 20 ticks.
// If other passengers remain, target state stays OCCUPIED.
if (!this.level().isClientSide) {
this.entityData.set(ANIM_STATE, STATE_EXITING);
this.transitionTicksLeft = 20;
this.transitionTargetState = this.getPassengers().isEmpty()
? STATE_IDLE
: STATE_OCCUPIED;
}
// Client-side: immediately stop the furniture pose animation on the dismounting player.
// BondageAnimationManager is @OnlyIn(Dist.CLIENT), so we guard with isClientSide.
// The safety tick in AnimationTickHandler also catches this after a 3-tick grace period,
// but stopping immediately avoids a visible animation glitch during dismount.
if (this.level().isClientSide && passenger instanceof Player) {
com.tiedup.remake.client.animation.BondageAnimationManager.stopFurniture(
(Player) passenger
);
}
}
/**
* Update the synched animation state based on passenger occupancy.
* More complex states (LOCKING, STRUGGLE, ENTERING, EXITING, UNLOCKING) are set
* explicitly by other systems. This method does NOT overwrite an active transition
* to avoid cutting short one-shot animations.
*/
private void updateAnimState() {
// Don't clobber active transitions — let the tick timer handle the reset
if (this.transitionTicksLeft > 0) return;
byte state = this.getPassengers().isEmpty()
? STATE_IDLE
: STATE_OCCUPIED;
this.entityData.set(ANIM_STATE, state);
}
// ========== Interaction ==========
/**
* Handle right-click interactions with the furniture.
*
* Priority order:
*
* - Force-mount a leashed captive (collar ownership required)
* - Key item + occupied lockable seat: toggle lock
* - Empty hand + available seat: self-mount
*
*/
@Override
public InteractionResult interact(Player player, InteractionHand hand) {
if (this.level().isClientSide) {
return InteractionResult.SUCCESS;
}
FurnitureDefinition def = getDefinition();
if (def == null) {
return InteractionResult.PASS;
}
// Priority 1: Force-mount a leashed captive onto an available seat.
// The interacting player must be a captor with at least one leashed captive,
// and the captive must wear a collar owned by this player (or player is OP).
if (
player instanceof ServerPlayer serverPlayer &&
this.getPassengers().size() < def.seats().size()
) {
PlayerBindState ownerState = PlayerBindState.getInstance(
serverPlayer
);
if (ownerState != null) {
PlayerCaptorManager captorManager =
ownerState.getCaptorManager();
if (captorManager != null && captorManager.hasCaptives()) {
for (IBondageState captive : captorManager.getCaptives()) {
LivingEntity captiveEntity = captive.asLivingEntity();
// Unified authorization via shared predicate.
if (
!FurnitureAuthPredicate.canForceMount(
serverPlayer,
this,
captiveEntity
)
) {
continue;
}
// Detach leash only (drop the lead, keep tied-up status)
captive.free(false);
// Force-mount: startRiding triggers addPassenger which assigns nearest seat
boolean mounted = captiveEntity.startRiding(this, true);
if (mounted) {
// Play mount sound
FurnitureFeedback feedback = def.feedback();
if (feedback.mountSound() != null) {
this.level().playSound(
null,
this.getX(),
this.getY(),
this.getZ(),
SoundEvent.createVariableRangeEvent(
feedback.mountSound()
),
SoundSource.BLOCKS,
1.0f,
1.0f
);
}
// Broadcast updated state to tracking clients
PacketSyncFurnitureState.sendToTracking(this);
TiedUpMod.LOGGER.debug(
"[EntityFurniture] {} force-mounted captive {} onto furniture {}",
serverPlayer.getName().getString(),
captiveEntity.getName().getString(),
getFurnitureId()
);
}
return InteractionResult.CONSUME;
}
}
}
}
// Priority 2: Key + occupied seat -> lock/unlock
// Use look direction to pick the nearest occupied, lockable seat.
// Authorization is enforced via FurnitureAuthPredicate so the in-world
// path cannot drift from the packet path.
ItemStack heldItem = player.getItemInHand(hand);
if (
isKeyItem(heldItem) &&
!this.getPassengers().isEmpty() &&
player instanceof ServerPlayer sp
) {
SeatDefinition targetSeat = findNearestOccupiedLockableSeat(
player,
def
);
if (
targetSeat != null &&
FurnitureAuthPredicate.canLockUnlock(sp, this, targetSeat.id())
) {
boolean wasLocked = isSeatLocked(targetSeat.id());
setSeatLocked(targetSeat.id(), !wasLocked);
// Play lock/unlock sound
FurnitureFeedback feedback = def.feedback();
ResourceLocation soundRL = wasLocked
? feedback.unlockSound()
: feedback.lockSound();
if (soundRL != null) {
this.level().playSound(
null,
this.getX(),
this.getY(),
this.getZ(),
SoundEvent.createVariableRangeEvent(soundRL),
SoundSource.BLOCKS,
1.0f,
1.0f
);
}
// Set lock/unlock animation with transition timer
boolean nowLocked = !wasLocked;
this.entityData.set(
ANIM_STATE,
nowLocked ? STATE_LOCKING : STATE_UNLOCKING
);
this.transitionTicksLeft = 20;
this.transitionTargetState = STATE_OCCUPIED;
TiedUpMod.LOGGER.debug(
"[EntityFurniture] {} {} seat '{}' on furniture {}",
player.getName().getString(),
wasLocked ? "unlocked" : "locked",
targetSeat.id(),
getFurnitureId()
);
return InteractionResult.CONSUME;
}
}
// Priority 3: Empty hand + available seat -> self mount
if (
heldItem.isEmpty() &&
this.getPassengers().size() < def.seats().size()
) {
player.startRiding(this);
return InteractionResult.CONSUME;
}
// No valid action — play denied sound if configured
FurnitureFeedback feedback = def.feedback();
if (feedback != null && feedback.deniedSound() != null) {
this.level().playSound(
null,
this.getX(),
this.getY(),
this.getZ(),
SoundEvent.createVariableRangeEvent(feedback.deniedSound()),
SoundSource.BLOCKS,
0.5f,
1.0f
);
}
return InteractionResult.PASS;
}
/**
* Check if the given item is a key that can lock/unlock furniture seats.
* Hardcoded to {@link ItemMasterKey}: the seat-lock mechanic is single-key
* by design (no per-seat keys). A future second key item would need an
* {@code ILockKey} interface; until then {@code instanceof} is cheapest.
*/
private boolean isKeyItem(ItemStack stack) {
return !stack.isEmpty() && stack.getItem() instanceof ItemMasterKey;
}
// ========== Damage ==========
/**
* Handle left-click (attack) interactions.
*
* Creative mode: instant discard. Survival mode: accumulate damage
* until it reaches the definition's {@code breakResistance}, then break
* the furniture, eject all passengers, and optionally drop an item.
*
* @return true if the attack was consumed, false to pass to vanilla handling
*/
@Override
public boolean skipAttackInteraction(Entity attacker) {
if (this.level().isClientSide) {
return true;
}
if (attacker instanceof Player player) {
if (player.isCreative()) {
this.ejectPassengers();
this.discard();
return true;
}
// Cannot break occupied furniture in survival — must remove passengers first.
// Creative mode bypasses this check (handled above with instant discard).
if (!this.getPassengers().isEmpty()) {
FurnitureDefinition occupiedDef = getDefinition();
if (
occupiedDef != null &&
occupiedDef.feedback().deniedSound() != null
) {
this.level().playSound(
null,
this.getX(),
this.getY(),
this.getZ(),
SoundEvent.createVariableRangeEvent(
occupiedDef.feedback().deniedSound()
),
SoundSource.BLOCKS,
0.5f,
1.0f
);
}
return true; // Consume the attack but accumulate no damage
}
FurnitureDefinition def = getDefinition();
float resistance = def != null ? def.breakResistance() : 100f;
this.currentDamage += 1.0f;
if (this.currentDamage >= resistance) {
this.ejectPassengers();
if (def != null && def.dropOnBreak()) {
ItemStack dropStack = FurniturePlacerItem.createStack(
def.id()
);
if (!dropStack.isEmpty()) {
this.spawnAtLocation(dropStack);
}
}
this.discard();
}
return true;
}
return false;
}
// ========== Tick ==========
/**
* Per-tick logic. Currently only handles damage decay on the server.
* Future tasks may add animation ticking or sound loops here.
*/
@Override
public void tick() {
super.tick();
if (!this.level().isClientSide) {
// Transition animation timer: count down and reset state when complete
if (this.transitionTicksLeft > 0) {
this.transitionTicksLeft--;
if (this.transitionTicksLeft == 0) {
this.entityData.set(ANIM_STATE, this.transitionTargetState);
}
}
// Damage decay: ~20 seconds to fully heal 1 point of damage
if (this.currentDamage > 0) {
this.currentDamage = Math.max(0f, this.currentDamage - 0.05f);
}
// Periodic cleanup: remove stale seat assignments for passengers
// that are no longer riding this entity (e.g., disconnected players).
if (this.tickCount % 100 == 0) {
cleanupStaleSeatAssignments();
}
}
}
/**
* Remove seat assignments whose UUID does not match any current passenger.
* This catches edge cases where a player disconnects or is removed without
* going through the normal removePassenger path (e.g., server crash recovery,
* /kick, or chunk unload races).
*/
private void cleanupStaleSeatAssignments() {
if (seatAssignments.isEmpty()) return;
Set passengerUuids = new java.util.HashSet<>();
for (Entity passenger : this.getPassengers()) {
passengerUuids.add(passenger.getUUID());
}
boolean removed = seatAssignments
.keySet()
.removeIf(uuid -> !passengerUuids.contains(uuid));
if (removed) {
TiedUpMod.LOGGER.debug(
"[EntityFurniture] Cleaned up stale seat assignments on {}",
getFurnitureId()
);
syncSeatAssignmentsIfServer();
updateAnimState();
}
}
// ========== NBT Persistence ==========
@Override
protected void addAdditionalSaveData(CompoundTag tag) {
tag.putString(FurnitureRegistry.NBT_FURNITURE_ID, getFurnitureId());
tag.putFloat("facing", this.getYRot());
tag.putByte("seat_locks", this.entityData.get(SEAT_LOCK_BITS));
tag.putFloat("current_damage", this.currentDamage);
// Persist transition state so animations don't get stuck after server restart
if (this.transitionTicksLeft > 0) {
tag.putInt("transition_ticks", this.transitionTicksLeft);
tag.putByte("transition_target", this.transitionTargetState);
}
// Persist seat assignments so locked passengers keep their seat after reload
CompoundTag assignments = new CompoundTag();
for (Map.Entry entry : seatAssignments.entrySet()) {
assignments.putString(entry.getKey().toString(), entry.getValue());
}
tag.put("seat_assignments", assignments);
}
@Override
protected void readAdditionalSaveData(CompoundTag tag) {
if (tag.contains(FurnitureRegistry.NBT_FURNITURE_ID, 8)) {
// 8 = TAG_String
setFurnitureId(tag.getString(FurnitureRegistry.NBT_FURNITURE_ID));
}
if (tag.contains("facing")) {
this.setYRot(tag.getFloat("facing"));
}
if (tag.contains("seat_locks")) {
this.entityData.set(SEAT_LOCK_BITS, tag.getByte("seat_locks"));
}
if (tag.contains("current_damage")) {
this.currentDamage = tag.getFloat("current_damage");
}
// Restore transition state so in-progress animations resume after reload
if (tag.contains("transition_ticks")) {
this.transitionTicksLeft = tag.getInt("transition_ticks");
this.transitionTargetState = tag.getByte("transition_target");
}
// Restore seat assignments
seatAssignments.clear();
if (tag.contains("seat_assignments", 10)) {
// 10 = TAG_Compound
CompoundTag assignments = tag.getCompound("seat_assignments");
for (String key : assignments.getAllKeys()) {
try {
UUID uuid = UUID.fromString(key);
seatAssignments.put(uuid, assignments.getString(key));
} catch (IllegalArgumentException e) {
TiedUpMod.LOGGER.warn(
"[EntityFurniture] Skipping invalid UUID in seat assignments: {}",
key
);
}
}
}
// Push the restored map into the synced field so clients that start
// tracking the entity after load get the right assignments too.
syncSeatAssignmentsIfServer();
this.refreshDimensions();
}
// ========== Getters / Setters ==========
/**
* Get the furniture definition ID string (ResourceLocation form).
* May be empty if the entity has not been initialized yet.
*/
public String getFurnitureId() {
return this.entityData.get(FURNITURE_ID);
}
/**
* Set the furniture definition ID. Triggers a dimension refresh so the
* bounding box updates to match the new definition's hitbox.
*/
public void setFurnitureId(String id) {
this.entityData.set(FURNITURE_ID, id != null ? id : "");
this.refreshDimensions();
}
/**
* Resolve the current furniture definition from the registry.
*
* @return the definition, or null if the ID is empty, unparseable,
* or no longer registered (e.g., data pack was removed)
*/
@Nullable
public FurnitureDefinition getDefinition() {
return FurnitureRegistry.get(getFurnitureId());
}
/**
* Get the raw seat lock bitmask. Bit N corresponds to seat index N in the
* furniture definition's seat list.
*
* @return the lock bitmask byte (0 = all unlocked)
*/
public byte getSeatLockBits() {
return this.entityData.get(SEAT_LOCK_BITS);
}
/**
* Set the raw seat lock bitmask directly. Used by the network sync packet
* ({@link com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState})
* to apply server-authoritative state on the client without per-seat iteration.
*
* Server code should prefer {@link #setSeatLocked(String, boolean)} for
* individual seat lock changes, as it validates the seat ID against the
* furniture definition.
*
* @param bits the raw lock bitmask
*/
public void setSeatLockBitsRaw(byte bits) {
this.entityData.set(SEAT_LOCK_BITS, bits);
}
/**
* Get the current animation state byte. Use the STATE_ constants to compare.
*/
public byte getAnimState() {
return this.entityData.get(ANIM_STATE);
}
/**
* Explicitly set the animation state. Used by external systems (e.g., struggle
* minigame) to signal visual state changes beyond simple occupancy.
*/
public void setAnimState(byte state) {
this.entityData.set(ANIM_STATE, state);
}
/**
* Set a transient animation state that auto-reverts to {@code target}
* after {@code ticks} ticks. Callers of transitional states
* (LOCKING/UNLOCKING/ENTERING/EXITING) MUST use this rather than
* {@link #setAnimState} so the tick-decrement path has the right
* target to revert to.
*/
public void setTransitionState(byte state, int ticks, byte target) {
this.entityData.set(ANIM_STATE, state);
this.transitionTicksLeft = ticks;
this.transitionTargetState = target;
}
// ========== Entity Behavior Overrides ==========
/** Furniture can be targeted by the crosshair (for interaction and attack). */
@Override
public boolean isPickable() {
return true;
}
/** Furniture cannot be pushed by entities or pistons. */
@Override
public boolean isPushable() {
return false;
}
/** Furniture has solid collision (entities cannot walk through it). */
@Override
public boolean canBeCollidedWith() {
return true;
}
/** Furniture does not emit movement sounds or events. */
@Override
protected Entity.MovementEmission getMovementEmission() {
return Entity.MovementEmission.NONE;
}
// ========== Spawn Packet ==========
/**
* Return the Forge-aware spawn packet so that {@link IEntityAdditionalSpawnData}
* fields ({@code writeSpawnData}/{@code readSpawnData}) are included.
*
* This override is required per the Forge Community Wiki. Without it,
* Forge sends a vanilla {@code ClientboundAddEntityPacket} which does NOT
* include the additional spawn buffer, causing the entity to be invisible
* or have missing data on the client.
*/
@Override
public Packet getAddEntityPacket() {
return NetworkHooks.getEntitySpawningPacket(this);
}
}