package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemMasterKey;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.state.PlayerCaptorManager;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.entity.IEntityAdditionalSpawnData;
import net.minecraftforge.network.NetworkHooks;
import org.jetbrains.annotations.Nullable;
/**
* Core furniture entity for all data-driven furniture pieces.
*
*
Extends {@link Entity} (not LivingEntity) and implements {@link ISeatProvider}
* for seat management plus {@link IEntityAdditionalSpawnData} for syncing the
* furniture definition ID to clients on spawn.
*
* Behavior varies per instance based on the {@code tiedup_furniture_id} stored
* in SynchedEntityData, which maps to a {@link FurnitureDefinition} in
* {@link FurnitureRegistry}. All ISeatProvider calls delegate to the definition
* with null-safety: if the definition is removed by a data pack reload, the
* entity degrades gracefully (no seats, default hitbox, no interaction).
*
* {@code getAddEntityPacket()} is overridden to return
* {@link NetworkHooks#getEntitySpawningPacket(Entity)}, which is required for
* Forge to include the {@code IEntityAdditionalSpawnData} buffer in the spawn
* packet. Without this override, the entity would be invisible on clients.
*/
public class EntityFurniture
extends Entity
implements ISeatProvider, IEntityAdditionalSpawnData
{
// ========== SynchedEntityData Accessors ==========
/** The furniture definition ID (ResourceLocation string form). */
private static final EntityDataAccessor FURNITURE_ID =
SynchedEntityData.defineId(
EntityFurniture.class,
EntityDataSerializers.STRING
);
/**
* Bitmask of locked seats. Bit N corresponds to seat index N
* in {@link FurnitureDefinition#seats()}. Max 8 seats per furniture.
*/
private static final EntityDataAccessor SEAT_LOCK_BITS =
SynchedEntityData.defineId(
EntityFurniture.class,
EntityDataSerializers.BYTE
);
/** Current animation state (idle, occupied, locking, struggle). */
private static final EntityDataAccessor ANIM_STATE =
SynchedEntityData.defineId(
EntityFurniture.class,
EntityDataSerializers.BYTE
);
// ========== 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);
}
// ========== 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);
}
@Override
public void releaseSeat(Entity passenger) {
seatAssignments.remove(passenger.getUUID());
}
@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;
float yawRad = (float) Math.toRadians(this.getYRot());
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
double offset =
seatCount == 1 ? 0.0 : (seatIdx - (seatCount - 1) / 2.0);
moveFunction.accept(
passenger,
this.getX() + rightX * offset,
this.getY() + 0.5,
this.getZ() + rightZ * 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);
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;
}
}
/**
* 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();
float yawRad = (float) Math.toRadians(this.getYRot());
// Entity-local right axis (perpendicular to facing direction in the XZ plane)
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
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 = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
Vec3 seatWorldPos = new Vec3(
this.getX() + rightX * offset,
this.getY() + 0.5,
this.getZ() + rightZ * 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();
float yawRad = (float) Math.toRadians(this.getYRot());
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
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 = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
Vec3 seatWorldPos = new Vec3(
this.getX() + rightX * offset,
this.getY() + 0.5,
this.getZ() + rightZ * 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();
// Skip captives that are already riding something
if (captiveEntity.isPassenger()) continue;
// Must be tied (leashed) and alive
if (!captive.isTiedUp()) continue;
if (!captiveEntity.isAlive()) continue;
// Must be within 5 blocks of the furniture
if (captiveEntity.distanceTo(this) > 5.0) continue;
// Verify collar ownership
if (!captive.hasCollar()) continue;
ItemStack collarStack = captive.getEquipment(
BodyRegionV2.NECK
);
if (
collarStack.isEmpty() ||
!CollarHelper.isCollar(collarStack)
) continue;
if (
!CollarHelper.isOwner(collarStack, serverPlayer) &&
!serverPlayer.hasPermissions(2)
) 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.
ItemStack heldItem = player.getItemInHand(hand);
if (isKeyItem(heldItem) && !this.getPassengers().isEmpty()) {
SeatDefinition targetSeat = findNearestOccupiedLockableSeat(
player,
def
);
if (targetSeat != null) {
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.
* Currently only {@link ItemMasterKey} qualifies.
*/
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()
);
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
);
}
}
}
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);
}
// ========== 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);
}
}