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:

*
    *
  1. Force-mount a leashed captive (collar ownership required)
  2. *
  3. Key item + occupied lockable seat: toggle lock
  4. *
  5. Empty hand + available seat: self-mount
  6. *
*/ @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); } }