Clean repo for open source release

Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
This commit is contained in:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,133 @@
package com.tiedup.remake.events.camp;
import com.tiedup.remake.blocks.BlockMarker;
import com.tiedup.remake.blocks.entity.MarkerBlockEntity;
import com.tiedup.remake.cells.CampOwnership;
import com.tiedup.remake.cells.CampOwnership.CampData;
import com.tiedup.remake.cells.MarkerType;
import com.tiedup.remake.core.TiedUpMod;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for camp chest access control.
*
* Chests with a LOOT marker above them are locked while the camp trader is alive.
* Only camp members (trader, maid, kidnappers) can access them.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class CampChestHandler {
@SubscribeEvent
public static void onChestOpen(PlayerInteractEvent.RightClickBlock event) {
if (event.getLevel().isClientSide()) {
return;
}
BlockPos pos = event.getPos();
ServerLevel level = (ServerLevel) event.getLevel();
// Only check if clicking a chest
if (!(level.getBlockState(pos).getBlock() instanceof ChestBlock)) {
return;
}
// Check if there's a LOOT marker above this chest
BlockPos markerPos = pos.above();
if (
!(level.getBlockState(markerPos).getBlock() instanceof BlockMarker)
) {
return; // No marker above, allow normal access
}
BlockEntity be = level.getBlockEntity(markerPos);
if (!(be instanceof MarkerBlockEntity marker)) {
return;
}
// Check if it's a LOOT marker
if (marker.getMarkerType() != MarkerType.LOOT) {
return; // Not a LOOT marker, allow normal access
}
// Find the nearest camp
CampOwnership registry = CampOwnership.get(level);
CampData camp = registry.findNearestAliveCamp(pos, 100);
if (camp == null) {
return; // No camp nearby, allow access
}
// Camp dead = chest accessible
if (!camp.isAlive()) {
return; // Trader dead, chest unlocked
}
// Check if the entity has camp access
Entity entity = event.getEntity();
if (hasCampAccess(camp, entity)) {
return; // Camp member, allow access
}
// Deny access
event.setCanceled(true);
event.setCancellationResult(InteractionResult.FAIL);
if (entity instanceof Player player) {
player.displayClientMessage(
Component.literal(
"This chest is locked by the camp!"
).withStyle(ChatFormatting.RED),
true
);
}
TiedUpMod.LOGGER.debug(
"[CampChestHandler] Denied access to LOOT chest at {} for {}",
pos.toShortString(),
entity.getName().getString()
);
}
/**
* Check if an entity has access to the camp's resources.
*
* @param camp The camp data
* @param entity The entity trying to access
* @return true if the entity is allowed access
*/
private static boolean hasCampAccess(CampData camp, Entity entity) {
UUID entityId = entity.getUUID();
// Trader has access
if (entityId.equals(camp.getTraderUUID())) {
return true;
}
// Maid has access
if (entityId.equals(camp.getMaidUUID())) {
return true;
}
// Kidnapper linked to this camp has access
if (camp.hasKidnapper(entityId)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,299 @@
package com.tiedup.remake.events.camp;
import com.tiedup.remake.cells.CampMaidManager;
import com.tiedup.remake.cells.CampOwnership;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityMaid;
import com.tiedup.remake.entities.EntitySlaveTrader;
import com.tiedup.remake.entities.ModEntities;
// Prison system v2
import com.tiedup.remake.prison.PrisonerManager;
import com.tiedup.remake.prison.service.PrisonerService;
import java.util.List;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles camp management tasks including maid respawn.
*
* When a maid dies:
* - Camp remains alive (trader still exists)
* - Prisoners are paused (no new tasks)
* - After 5 minutes (6000 ticks), a new maid spawns
* - Prisoners are notified and labor resumes
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class CampManagementHandler {
// Check every 100 ticks (5 seconds) - optimized for performance
private static final int CHECK_INTERVAL_TICKS = 100;
private static int tickCounter = 0;
/**
* Periodically check for camps needing maid respawn.
*/
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
tickCounter++;
if (tickCounter < CHECK_INTERVAL_TICKS) return;
tickCounter = 0;
// Process all worlds
for (ServerLevel level : event.getServer().getAllLevels()) {
processCampManagement(level);
}
}
/**
* Process camp management for a specific level.
*/
private static void processCampManagement(ServerLevel level) {
// IMPORTANT: Always use server-level registry to avoid dimension fragmentation
CampOwnership ownership = CampOwnership.get(level.getServer());
long currentTime = level.getGameTime();
// Get camps that need maid respawn
List<UUID> campsNeedingMaid = CampMaidManager.getCampsNeedingMaidRespawn(
currentTime, level
);
for (UUID campId : campsNeedingMaid) {
spawnNewMaidForCamp(level, ownership, campId);
}
// Prison system v2 - tick escape service (handles escape detection)
PrisonerService.get().tick(level.getServer(), currentTime);
// Prison system v2 - tick protection expiry
PrisonerManager.get(level).tickProtectionExpiry(currentTime);
}
/**
* Spawn a new maid for a camp that needs one.
*
* @param level The server level
* @param ownership The camp ownership data
* @param campId The camp UUID
*/
private static void spawnNewMaidForCamp(
ServerLevel level,
CampOwnership ownership,
UUID campId
) {
CampOwnership.CampData campData = ownership.getCamp(campId);
if (campData == null || !campData.isAlive()) {
return;
}
BlockPos center = campData.getCenter();
if (center == null) {
TiedUpMod.LOGGER.warn(
"[CampManagementHandler] Cannot spawn maid - camp {} has no center position",
campId.toString().substring(0, 8)
);
return;
}
UUID traderUUID = campData.getTraderUUID();
if (traderUUID == null) {
TiedUpMod.LOGGER.warn(
"[CampManagementHandler] Cannot spawn maid - camp {} has no trader",
campId.toString().substring(0, 8)
);
return;
}
// Find the trader entity
Entity traderEntity = level.getEntity(traderUUID);
EntitySlaveTrader trader = null;
if (traderEntity instanceof EntitySlaveTrader t) {
trader = t;
} else {
// Trader not loaded - search near camp center
trader = findTraderNearPosition(level, center, 50);
}
if (trader == null) {
TiedUpMod.LOGGER.warn(
"[CampManagementHandler] Cannot spawn maid - trader not found for camp {}",
campId.toString().substring(0, 8)
);
return;
}
// Create new maid
EntityMaid maid = ModEntities.MAID.get().create(level);
if (maid == null) {
TiedUpMod.LOGGER.error(
"[CampManagementHandler] Failed to create maid entity for camp {}",
campId.toString().substring(0, 8)
);
return;
}
// Find spawn position near trader (slightly offset)
BlockPos spawnPos = findSafeSpawnPosition(
level,
trader.blockPosition()
);
// Position the maid
maid.moveTo(
spawnPos.getX() + 0.5,
spawnPos.getY(),
spawnPos.getZ() + 0.5,
trader.getYRot() + 180, // Face opposite direction from trader
0
);
// Link maid to trader
maid.setMasterTraderUUID(trader.getUUID());
// Add to world
level.addFreshEntity(maid);
// Update camp ownership with new maid
CampMaidManager.assignNewMaid(campId, maid.getUUID(), level);
// Update trader's maid reference
trader.setMaidUUID(maid.getUUID());
TiedUpMod.LOGGER.info(
"[CampManagementHandler] Spawned replacement maid {} for camp {} at {}",
maid.getNpcName(),
campId.toString().substring(0, 8),
spawnPos.toShortString()
);
// Notify prisoners
notifyPrisonersOfNewMaid(level, campId, maid.getNpcName());
}
/**
* Find a trader near the given position.
*/
private static EntitySlaveTrader findTraderNearPosition(
ServerLevel level,
BlockPos pos,
int radius
) {
List<EntitySlaveTrader> traders = level.getEntitiesOfClass(
EntitySlaveTrader.class,
new net.minecraft.world.phys.AABB(pos).inflate(radius)
);
return traders.isEmpty() ? null : traders.get(0);
}
/**
* Find a safe position to spawn the maid.
* Tries positions around the target, prioritizing nearby valid spots.
*/
private static BlockPos findSafeSpawnPosition(
ServerLevel level,
BlockPos targetPos
) {
// Try offsets in a spiral pattern
int[][] offsets = {
{ 1, 0 },
{ 0, 1 },
{ -1, 0 },
{ 0, -1 },
{ 1, 1 },
{ -1, 1 },
{ -1, -1 },
{ 1, -1 },
{ 2, 0 },
{ 0, 2 },
{ -2, 0 },
{ 0, -2 },
};
for (int[] offset : offsets) {
BlockPos testPos = targetPos.offset(offset[0], 0, offset[1]);
// Check if position is safe (solid ground, air above)
if (isValidSpawnPosition(level, testPos)) {
return testPos;
}
// Try one block down
BlockPos downPos = testPos.below();
if (isValidSpawnPosition(level, downPos)) {
return downPos;
}
// Try one block up
BlockPos upPos = testPos.above();
if (isValidSpawnPosition(level, upPos)) {
return upPos;
}
}
// Fallback: use target position
return targetPos;
}
/**
* Check if a position is valid for spawning.
*/
private static boolean isValidSpawnPosition(
ServerLevel level,
BlockPos pos
) {
// Ground must be solid
if (!level.getBlockState(pos.below()).isSolid()) {
return false;
}
// Position and above must be passable (air or similar)
if (!level.getBlockState(pos).isAir()) {
return false;
}
if (!level.getBlockState(pos.above()).isAir()) {
return false;
}
return true;
}
/**
* Notify all prisoners of this camp that a new maid has arrived.
*/
private static void notifyPrisonersOfNewMaid(
ServerLevel level,
UUID campId,
String maidName
) {
PrisonerManager manager = PrisonerManager.get(level);
for (UUID prisonerId : manager.getPrisonersInCamp(campId)) {
ServerPlayer player = level
.getServer()
.getPlayerList()
.getPlayer(prisonerId);
if (player != null) {
player.sendSystemMessage(
Component.literal(
"A new maid, " +
maidName +
", has arrived. Work resumes."
).withStyle(ChatFormatting.GOLD)
);
}
}
}
}

View File

@@ -0,0 +1,85 @@
package com.tiedup.remake.events.camp;
import com.tiedup.remake.cells.CampLifecycleManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityMaid;
import com.tiedup.remake.entities.EntitySlaveTrader;
import com.tiedup.remake.items.base.ItemBind;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Protects camp NPCs (Trader and Maid) from being captured by players.
* When a player attempts to restrain a camp NPC:
* 1. The attempt is cancelled
* 2. The entire camp (trader, maid, all kidnappers) is alerted to attack
* 3. The player receives a warning message
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class CampNpcProtectionHandler {
@SubscribeEvent(priority = EventPriority.HIGHEST)
public static void onPlayerInteractEntity(
PlayerInteractEvent.EntityInteract event
) {
Player player = event.getEntity();
Entity target = event.getTarget();
// Server-side only
if (player.level().isClientSide) return;
if (!(player.level() instanceof ServerLevel serverLevel)) return;
// Check if player is holding restraint item
ItemStack heldItem = player.getItemInHand(event.getHand());
if (!(heldItem.getItem() instanceof ItemBind)) return;
// Check if target is trader or maid with active camp
UUID campId = null;
boolean isTrader = false;
if (target instanceof EntitySlaveTrader trader) {
if (trader.isTiedUp()) return; // Already captured
campId = trader.getCampUUID();
isTrader = true;
} else if (target instanceof EntityMaid maid) {
if (maid.isTiedUp() || maid.isFreed()) return; // Already captured or freed
campId = maid.getCampUUID();
isTrader = false;
} else {
return; // Not a protected NPC
}
if (campId == null) return; // No active camp
// CANCEL THE ATTEMPT
event.setCanceled(true);
// ALERT THE CAMP
CampLifecycleManager.alertCampToDefend(campId, player, serverLevel);
// Log the attempt
TiedUpMod.LOGGER.warn(
"[CampNpcProtection] {} attempted to restrain {} - camp alerted!",
player.getName().getString(),
isTrader ? "trader" : "maid"
);
// Send warning message to player
player.sendSystemMessage(
Component.literal(
isTrader
? "The camp defends their leader! You are now under attack!"
: "The camp defends their servant! You are now under attack!"
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD)
);
}
}

View File

@@ -0,0 +1,61 @@
package com.tiedup.remake.events.captivity;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.prison.PrisonerManager;
import net.minecraft.server.level.ServerLevel;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles periodic captivity system updates.
*
* Responsibilities:
* - Expire protection periods for released prisoners
* - Periodic validation (optional, for debugging)
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class CaptivityTickHandler {
private static final int EXPIRY_CHECK_INTERVAL = 100; // Check every 5 seconds (100 ticks)
private static int tickCounter = 0;
/**
* Handle server ticks for captivity system updates.
*/
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
// Only run at end of tick to avoid interfering with game logic
if (event.phase != TickEvent.Phase.END) {
return;
}
tickCounter++;
// Run expiry check every 5 seconds
if (tickCounter >= EXPIRY_CHECK_INTERVAL) {
tickCounter = 0;
// Check all loaded worlds
for (ServerLevel level : event.getServer().getAllLevels()) {
try {
PrisonerManager manager = PrisonerManager.get(level);
long currentTime = level.getGameTime();
// Expire protection for players whose grace period has ended
manager.tickProtectionExpiry(currentTime);
} catch (Exception e) {
TiedUpMod.LOGGER.error(
"[CaptivityTickHandler] Error processing captivity expiry in {}: {}",
level.dimension().location(),
e.getMessage(),
e
);
}
}
}
}
}

View File

@@ -0,0 +1,316 @@
package com.tiedup.remake.events.captivity;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.SystemMessageManager.MessageCategory;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.LeashProxyEntity;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.state.PlayerCaptorManager;
import com.tiedup.remake.util.KidnappedHelper;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.entity.EntityMountEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles forced seating for captives.
*
* Flow:
* 1. [ALT] + right-click empty vehicle → free captive + mount on vehicle
* 2. [ALT] + right-click vehicle with tied player → dismount + re-capture
* 3. Owner enters vehicle with tied player → swap (owner = driver)
* 4. Tied player Shift → blocked
* 5. Vehicle destroyed → tied player dismounts, stays free
*
* Note: In the vehicle, the captive has NO leash. The "tied up" status
* (isTiedUp) is preserved, which blocks vehicle control and dismount.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class ForcedSeatingHandler {
/** Track which players have the Force Seat keybind pressed (server-side) */
private static final Map<UUID, Boolean> forceSeatPressed =
new ConcurrentHashMap<>();
/** Track forced dismounts in progress to avoid blocking our own operations */
private static final Map<UUID, Boolean> forcedDismountInProgress =
new ConcurrentHashMap<>();
/**
* Update the Force Seat keybind state for a player.
* Called from PacketForceSeatModifier.
*/
public static void setForceSeatPressed(UUID playerId, boolean pressed) {
if (pressed) {
forceSeatPressed.put(playerId, true);
} else {
forceSeatPressed.remove(playerId);
}
}
/**
* Check if a player has the Force Seat keybind pressed.
*/
public static boolean isForceSeatPressed(Player player) {
return forceSeatPressed.getOrDefault(player.getUUID(), false);
}
/**
* Clean up keybind state when player disconnects.
*/
public static void clearPlayer(UUID playerId) {
forceSeatPressed.remove(playerId);
forcedDismountInProgress.remove(playerId);
}
// ========== ALT + CLICK ON ENTITY ==========
/**
* Handle ALT + right-click on entity.
* - If vehicle has tied player → dismount + re-capture
* - If vehicle is empty → free captive + mount
*/
@SubscribeEvent(priority = EventPriority.HIGHEST)
public static void onPlayerInteractEntity(
PlayerInteractEvent.EntityInteract event
) {
if (event.getLevel().isClientSide) return;
Player owner = event.getEntity();
Entity target = event.getTarget();
// Furniture handles its own seating — skip forced seating logic
if (target instanceof com.tiedup.remake.v2.furniture.ISeatProvider) return;
// Only process if Force Seat keybind is pressed
if (!isForceSeatPressed(owner)) return;
// Skip proxies and players
if (target instanceof LeashProxyEntity) return;
if (target instanceof Player) return;
// Get owner's state
PlayerBindState ownerState = PlayerBindState.getInstance(owner);
if (ownerState == null) return;
PlayerCaptorManager captorManager = ownerState.getCaptorManager();
// === CASE 1: Vehicle with tied player → dismount and re-capture ===
for (Entity passenger : target.getPassengers()) {
if (passenger instanceof Player tiedPlayer && tiedPlayer != owner) {
IBondageState tiedState = KidnappedHelper.getKidnappedState(
tiedPlayer
);
if (tiedState != null && tiedState.isTiedUp()) {
// Cancel the event first
event.setCanceled(true);
// Mark forced dismount in progress
forcedDismountInProgress.put(tiedPlayer.getUUID(), true);
// Schedule dismount for next tick
target
.getServer()
.execute(() -> {
try {
// Dismount the tied player
tiedPlayer.stopRiding();
// Teleport slightly away from vehicle
double offsetX =
(target.level().random.nextDouble() - 0.5) *
2;
double offsetZ =
(target.level().random.nextDouble() - 0.5) *
2;
tiedPlayer.teleportTo(
target.getX() + offsetX,
target.getY() + 0.5,
target.getZ() + offsetZ
);
// Re-capture (creates proxy + leash)
if (captorManager != null) {
tiedState.getCapturedBy(captorManager);
}
TiedUpMod.LOGGER.debug(
"[ForcedSeating] {} dismounted {} from vehicle",
owner.getName().getString(),
tiedPlayer.getName().getString()
);
} finally {
forcedDismountInProgress.remove(
tiedPlayer.getUUID()
);
}
});
return;
}
}
}
// Need captives for Case 2
if (captorManager == null || !captorManager.hasCaptives()) {
return;
}
// === CASE 2: Empty vehicle → free captive and mount ===
for (IBondageState captive : captorManager.getCaptives()) {
LivingEntity captiveEntity = captive.asLivingEntity();
// Skip if captive is the target itself
if (captiveEntity == target) continue;
// Skip if captive already riding something
if (captiveEntity.isPassenger()) continue;
// Skip if captive not tied
if (!captive.isTiedUp()) continue;
// Free the captive (detaches leash, removes relationship)
captive.free(true);
// Mount captive on vehicle (still tied up, can't control)
boolean success = captiveEntity.startRiding(target, true);
if (success) {
TiedUpMod.LOGGER.debug(
"[ForcedSeating] {} mounted {} on vehicle",
owner.getName().getString(),
captive.getKidnappedName()
);
}
event.setCanceled(true);
return;
}
}
// ========== MOUNT/DISMOUNT EVENTS ==========
/**
* Handle mounting/dismounting events.
* - Block dismount for tied players (unless vehicle is dead)
* - Swap when owner enters vehicle with tied player
*/
@SubscribeEvent(priority = EventPriority.HIGHEST)
public static void onEntityMount(EntityMountEvent event) {
if (event.getLevel().isClientSide) return;
Entity rider = event.getEntityMounting();
Entity vehicle = event.getEntityBeingMounted();
// === DISMOUNTING ===
if (event.isDismounting()) {
if (rider instanceof Player player) {
// Skip if this is a forced dismount (ALT+click or swap)
if (
forcedDismountInProgress.getOrDefault(
player.getUUID(),
false
)
) {
return;
}
// Furniture manages its own dismount via removePassenger()
if (vehicle instanceof com.tiedup.remake.v2.furniture.ISeatProvider) {
return;
}
IBondageState state = KidnappedHelper.getKidnappedState(player);
if (state != null && state.isTiedUp()) {
if (vehicle.isAlive()) {
// Block voluntary dismount (Shift key)
event.setCanceled(true);
SystemMessageManager.sendRestriction(
player,
MessageCategory.CANT_MOVE
);
return;
}
// Vehicle destroyed → captive dismounts, stays free
}
}
return;
}
// === MOUNTING: Swap if tied player already in vehicle ===
if (rider instanceof Player newDriver) {
// Skip if swap already in progress for this driver
if (
forcedDismountInProgress.getOrDefault(
newDriver.getUUID(),
false
)
) {
return;
}
for (Entity passenger : vehicle.getPassengers()) {
if (
passenger instanceof Player tiedPlayer &&
tiedPlayer != newDriver
) {
IBondageState state = KidnappedHelper.getKidnappedState(
tiedPlayer
);
if (state != null && state.isTiedUp()) {
// Mark swap in progress for both players
forcedDismountInProgress.put(newDriver.getUUID(), true);
forcedDismountInProgress.put(
tiedPlayer.getUUID(),
true
);
// Cancel this mount event - we'll do it manually
event.setCanceled(true);
// Schedule the swap for next tick
vehicle
.getServer()
.execute(() -> {
try {
// 1. Dismount tied player
tiedPlayer.stopRiding();
// 2. Mount new driver (becomes controller)
newDriver.startRiding(vehicle, true);
// 3. Remount tied player (becomes passenger)
tiedPlayer.startRiding(vehicle, true);
TiedUpMod.LOGGER.debug(
"[ForcedSeating] Swapped: {} driving, {} passenger",
newDriver.getName().getString(),
tiedPlayer.getName().getString()
);
} finally {
forcedDismountInProgress.remove(
newDriver.getUUID()
);
forcedDismountInProgress.remove(
tiedPlayer.getUUID()
);
}
});
return;
}
}
}
}
}
}

View File

@@ -0,0 +1,40 @@
package com.tiedup.remake.events.captivity;
import com.tiedup.remake.state.IPlayerLeashAccess;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles leash ticking for players via Forge events.
* This replaces the mixin @Inject approach which had refmap issues.
*
* Security fix: Uses per-player tick count instead of global counter
* to prevent erratic physics behavior with multiple players.
*/
@Mod.EventBusSubscriber(
modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class LeashTickHandler {
@SubscribeEvent
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
// Only on server side, at end of tick
if (event.phase != TickEvent.Phase.END) return;
if (!(event.player instanceof ServerPlayer serverPlayer)) return;
// FIX: Removed throttling (was: tickCount % 2 != 0)
// Throttling caused rubber-banding and jerky movement because:
// 1. Client predicts movement between ticks
// 2. Server sends velocity corrections every 2 ticks
// 3. This creates visible "snapping" effect
// Now physics runs every tick (20 Hz) for smooth movement.
// Call the leash tick method via the mixin interface
if (serverPlayer instanceof IPlayerLeashAccess access) {
access.tiedup$tickLeash();
}
}
}

View File

@@ -0,0 +1,243 @@
package com.tiedup.remake.events.captivity;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.entities.LeashProxyEntity;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.state.IPlayerLeashAccess;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.core.SettingsAccessor;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for master-slave (enslavement) interactions.
*
* <h2>Key Mechanisms</h2>
* <ul>
* <li><b>Enslavement:</b> Right-click with Lead on tied player → enslaves them</li>
* <li><b>Freeing:</b> Right-click with empty hand on your slave → frees them</li>
* </ul>
*
* <h2>Leash System</h2>
* Uses a proxy-based leash system where a {@link LeashProxyEntity} follows the player
* and holds the leash. The player does NOT mount anything - traction is applied via push().
*
* @see PlayerBindState#free()
* @see LeashProxyEntity
* @see IPlayerLeashAccess
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class PlayerEnslavementHandler {
/**
* Prevent enslaved players from attacking/breaking their constraints.
* Handles Left-Click (Attack) events.
*/
@SubscribeEvent
public static void onPlayerAttackEntity(
net.minecraftforge.event.entity.player.AttackEntityEvent event
) {
Player player = event.getEntity();
Entity target = event.getTarget();
// Check if player is leashed (enslaved) via proxy system
if (
player instanceof IPlayerLeashAccess access &&
access.tiedup$isLeashed()
) {
// Prevent breaking the LeashKnot or the LeashProxy itself
if (
target instanceof
net.minecraft.world.entity.decoration.LeashFenceKnotEntity ||
target instanceof LeashProxyEntity
) {
event.setCanceled(true);
}
}
}
/**
* Handle player interactions with other players for enslavement/freeing.
*
* Uses IBondageState for condition checks (isEnslavable)
* Note: Enslavement system (getEnslavedBy, free) remains PlayerBindState-specific
*/
@SubscribeEvent
public static void onPlayerInteractEntity(
PlayerInteractEvent.EntityInteract event
) {
Player master = event.getEntity();
Entity target = event.getTarget();
// 1. Prevention Logic: Slaves cannot interact with leash knots or proxies
if (
master instanceof IPlayerLeashAccess access &&
access.tiedup$isLeashed()
) {
if (
target instanceof
net.minecraft.world.entity.decoration.LeashFenceKnotEntity ||
target instanceof LeashProxyEntity
) {
event.setCanceled(true);
return;
}
}
// Only handle player-to-player interactions for enslavement logic
if (!(target instanceof Player)) {
return;
}
Player slave = (Player) target;
// Server-side only
if (master.level().isClientSide) {
return;
}
// Only handle MAIN_HAND to avoid double processing
if (event.getHand() != net.minecraft.world.InteractionHand.MAIN_HAND) {
return;
}
// Check if enslavement is enabled
if (!SettingsAccessor.isEnslavementEnabled(master.level().getGameRules())) {
return;
}
// Get IBondageState for condition checks
IBondageState slaveKidnappedState = KidnappedHelper.getKidnappedState(
slave
);
if (slaveKidnappedState == null) {
return;
}
// Get PlayerBindState for enslavement operations (Player-only system)
PlayerBindState masterState = PlayerBindState.getInstance(master);
PlayerBindState slaveState = PlayerBindState.getInstance(slave);
if (masterState == null || slaveState == null) {
return;
}
ItemStack heldItem = master.getItemInHand(event.getHand());
// ========================================
// Scenario 1: Enslavement with Lead
// ========================================
if (heldItem.is(Items.LEAD)) {
// Check if target is enslavable (using IBondageState)
if (!slaveKidnappedState.isEnslavable()) {
// Exception: collar owner can capture even if not tied
boolean canCapture = false;
if (slaveKidnappedState.hasCollar()) {
ItemStack collar = slaveKidnappedState.getEquipment(BodyRegionV2.NECK);
if (collar.getItem() instanceof ItemCollar collarItem) {
if (
collarItem
.getOwners(collar)
.contains(master.getUUID())
) {
canCapture = true;
}
}
}
if (!canCapture) {
TiedUpMod.LOGGER.debug(
"[PlayerEnslavementHandler] {} cannot be enslaved - not tied and not collar owner",
slave.getName().getString()
);
return;
}
}
// Phase 17: Check if not already captured (Player-specific check)
if (slaveState.isCaptive()) {
TiedUpMod.LOGGER.debug(
"[PlayerEnslavementHandler] {} is already captured",
slave.getName().getString()
);
return;
}
// Attempt capture
boolean success = slaveState.getCapturedBy(
masterState.getCaptorManager()
);
if (success) {
heldItem.shrink(1);
TiedUpMod.LOGGER.info(
"[PlayerEnslavementHandler] {} enslaved {} (lead consumed)",
master.getName().getString(),
slave.getName().getString()
);
event.setCanceled(true);
} else {
TiedUpMod.LOGGER.warn(
"[PlayerEnslavementHandler] Failed to enslave {} by {}",
slave.getName().getString(),
master.getName().getString()
);
}
}
// ========================================
// Scenario 2: Freeing with Empty Hand
// ========================================
else if (heldItem.isEmpty()) {
// Phase 17: isSlave → isCaptive
if (!slaveState.isCaptive()) return;
// Phase 17: getMaster → getCaptor, getSlaveHolderManager → getCaptorManager
if (slaveState.getCaptor() != masterState.getCaptorManager()) {
TiedUpMod.LOGGER.debug(
"[PlayerEnslavementHandler] {} tried to free {} but is not the captor",
master.getName().getString(),
slave.getName().getString()
);
return;
}
slaveState.free();
TiedUpMod.LOGGER.info(
"[PlayerEnslavementHandler] {} freed {}",
master.getName().getString(),
slave.getName().getString()
);
event.setCanceled(true);
}
}
/**
* Periodic check for enslaved players.
*
* Phase 14.1.5: Remains PlayerBindState-specific (enslavement system is Player-only)
*/
@SubscribeEvent
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
Player player = event.player;
if (player.level().isClientSide) return;
if (player.tickCount % 20 != 0) return;
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) return;
// Phase 17: isSlave → isCaptive, checkStillSlave → checkStillCaptive
if (state.isCaptive()) {
state.checkStillCaptive();
}
// Phase 17: getSlaveHolderManager → getCaptorManager, cleanupInvalidSlaves → cleanupInvalidCaptives
state.getCaptorManager().cleanupInvalidCaptives();
}
}

View File

@@ -0,0 +1,65 @@
package com.tiedup.remake.events.combat;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.prison.PrisonerManager;
import com.tiedup.remake.prison.PrisonerRecord;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.entity.player.AttackEntityEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for grace period management.
*
* Revokes grace period when a player attacks a kidnapper.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class GraceEventHandler {
@SubscribeEvent
public static void onAttackEntity(AttackEntityEvent event) {
if (event.getEntity().level().isClientSide()) {
return;
}
Entity attacker = event.getEntity();
Entity target = event.getTarget();
// Check if a player is attacking a kidnapper
if (
attacker instanceof Player player &&
target instanceof EntityKidnapper
) {
ServerLevel level = (ServerLevel) player.level();
PrisonerManager manager = PrisonerManager.get(level);
PrisonerRecord record = manager.getRecord(player.getUUID());
// Check if player has grace
if (record.isProtected(level.getGameTime())) {
// Revoke grace by clearing protection expiry
record.setProtectionExpiry(0);
player.displayClientMessage(
Component.literal(
"You attacked a kidnapper - protection lost!"
).withStyle(ChatFormatting.RED),
true
);
TiedUpMod.LOGGER.info(
"[GraceEventHandler] Player {} lost grace period by attacking kidnapper {}",
player.getName().getString(),
target.getName().getString()
);
}
}
}
}

View File

@@ -0,0 +1,423 @@
package com.tiedup.remake.events.combat;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.entities.EntityMaid;
import com.tiedup.remake.entities.EntitySlaveTrader;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.prison.LaborRecord;
import com.tiedup.remake.prison.PrisonerManager;
import com.tiedup.remake.prison.PrisonerRecord;
import com.tiedup.remake.prison.PrisonerState;
import com.tiedup.remake.state.IRestrainable;
import com.tiedup.remake.util.KidnappedHelper;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.entity.player.AttackEntityEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Punishes prisoners who attack or attempt to restrain kidnappers, traders, or maids while working.
*
* Triggers:
* - Attacking a protected entity (kidnappers, traders, maids)
* - Using rope/restraint items on a protected entity
*
* Punishments (applied immediately):
* - Shock (damage + message)
* - Debt increase (+25 emeralds)
* - Task marked as failed (no payment)
* - Equipment reclaimed
* - Immediate teleportation back to cell
* - State changed to IMPRISONED (resting)
* - Attack/interaction cancelled (no damage to target)
*
* This prevents prisoners from attacking or restraining their captors during labor tasks.
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class LaborAttackPunishmentHandler {
/** Debt increase per attack */
private static final int DEBT_INCREASE_PER_ATTACK = 25;
/** Base shock damage */
private static final float BASE_SHOCK_DAMAGE = 3.0f;
/**
* Detect when a prisoner attacks a kidnapper/trader/maid during labor.
*/
@SubscribeEvent
public static void onPlayerAttackEntity(AttackEntityEvent event) {
Player player = event.getEntity();
Entity target = event.getTarget();
// Server-side only
if (player.level().isClientSide) {
return;
}
// Only handle ServerPlayer
if (!(player instanceof ServerPlayer serverPlayer)) {
return;
}
// Only handle ServerLevel
if (!(player.level() instanceof ServerLevel serverLevel)) {
return;
}
// Check if target is a kidnapper, trader, or maid
boolean isProtectedEntity =
target instanceof EntityKidnapper ||
target instanceof EntitySlaveTrader ||
target instanceof EntityMaid;
if (!isProtectedEntity) {
return;
}
// Check if player is a prisoner in WORKING state
PrisonerManager manager = PrisonerManager.get(serverLevel);
PrisonerRecord record = manager.getRecord(serverPlayer.getUUID());
if (record.getState() != PrisonerState.WORKING) {
return; // Not working, no punishment
}
// PUNISH THE PRISONER
punishPrisonerForAttack(serverPlayer, serverLevel, record, target);
// Cancel the attack
event.setCanceled(true);
}
/**
* Detect when a prisoner tries to use rope/restraint items on kidnapper/trader/maid during labor.
*/
@SubscribeEvent
public static void onPlayerInteractEntity(
PlayerInteractEvent.EntityInteract event
) {
Player player = event.getEntity();
Entity target = event.getTarget();
InteractionHand hand = event.getHand();
// Server-side only
if (player.level().isClientSide) {
return;
}
// Only handle ServerPlayer
if (!(player instanceof ServerPlayer serverPlayer)) {
return;
}
// Only handle ServerLevel
if (!(player.level() instanceof ServerLevel serverLevel)) {
return;
}
// Check if target is a kidnapper, trader, or maid
boolean isProtectedEntity =
target instanceof EntityKidnapper ||
target instanceof EntitySlaveTrader ||
target instanceof EntityMaid;
if (!isProtectedEntity) {
return;
}
// Check if player is a prisoner in WORKING state
PrisonerManager manager = PrisonerManager.get(serverLevel);
PrisonerRecord record = manager.getRecord(serverPlayer.getUUID());
if (record.getState() != PrisonerState.WORKING) {
return; // Not working, no punishment
}
// Check if player is holding a restraint item (rope, chain, etc.)
ItemStack heldItem = serverPlayer.getItemInHand(hand);
if (!(heldItem.getItem() instanceof ItemBind)) {
return; // Not a restraint item
}
// PUNISH THE PRISONER
punishPrisonerForAttack(serverPlayer, serverLevel, record, target);
// Cancel the interaction
event.setCanceled(true);
TiedUpMod.LOGGER.info(
"[LaborAttackPunishmentHandler] Prisoner {} attempted to restrain {} during labor - punished",
serverPlayer.getName().getString(),
target instanceof EntityKidnapper kidnapper
? kidnapper.getNpcName()
: target instanceof EntitySlaveTrader trader
? trader.getNpcName()
: target instanceof EntityMaid maid
? maid.getNpcName()
: "captor"
);
}
/**
* Apply punishment to prisoner for attacking protected entity.
*/
private static void punishPrisonerForAttack(
ServerPlayer prisoner,
ServerLevel level,
PrisonerRecord record,
Entity target
) {
IRestrainable kidnappedState = KidnappedHelper.getKidnappedState(
prisoner
);
if (kidnappedState == null) {
return;
}
// 1. Shock the prisoner
String targetName = "your captor";
if (target instanceof EntityKidnapper kidnapper) {
targetName = kidnapper.getNpcName();
} else if (target instanceof EntitySlaveTrader trader) {
targetName = trader.getNpcName();
} else if (target instanceof EntityMaid maid) {
targetName = maid.getNpcName();
}
String shockMessage = String.format(
"You DARE attack %s? You'll pay for this insolence!",
targetName
);
kidnappedState.shockKidnapped(shockMessage, BASE_SHOCK_DAMAGE);
// 2. Increase debt
PrisonerManager manager = PrisonerManager.get(level);
com.tiedup.remake.prison.RansomRecord ransomRecord =
manager.getRansomRecord(prisoner.getUUID());
if (ransomRecord != null) {
manager.increaseDebt(prisoner.getUUID(), DEBT_INCREASE_PER_ATTACK);
prisoner.sendSystemMessage(
Component.literal(
String.format(
"Your debt has increased by %d emeralds for attacking %s!",
DEBT_INCREASE_PER_ATTACK,
targetName
)
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD)
);
}
// 3. CRITICAL FIX: Collect task items, reclaim equipment, and confiscate everything
LaborRecord laborRecord = manager.getLaborRecord(prisoner.getUUID());
com.tiedup.remake.labor.LaborTask task = laborRecord.getTask();
// Find cell via PrisonerRecord.getCellId() — CellRegistryV2 won't have the prisoner
// during WORKING state because extractFromCell() removes them from the registry.
com.tiedup.remake.cells.CellRegistryV2 cellRegistry =
com.tiedup.remake.cells.CellRegistryV2.get(level);
java.util.UUID cellId = record.getCellId();
com.tiedup.remake.cells.CellDataV2 cell =
cellId != null ? cellRegistry.getCell(cellId) : null;
if (task != null) {
// Collect task items (prevent player keeping valuable items like diamonds)
java.util.List<net.minecraft.world.item.ItemStack> collectedItems =
task.collectItems(prisoner);
TiedUpMod.LOGGER.debug(
"[LaborAttackPunishmentHandler] Collected {} task items from {} before punishment",
collectedItems.size(),
prisoner.getName().getString()
);
// Reclaim labor tools
java.util.List<net.minecraft.world.item.ItemStack> reclaimedTools =
task.reclaimEquipment(prisoner);
TiedUpMod.LOGGER.debug(
"[LaborAttackPunishmentHandler] Reclaimed {} tools from {} before punishment",
reclaimedTools.size(),
prisoner.getName().getString()
);
if (cell != null) {
java.util.List<net.minecraft.world.item.ItemStack> allItems =
new java.util.ArrayList<>();
allItems.addAll(collectedItems);
allItems.addAll(reclaimedTools);
if (!allItems.isEmpty()) {
depositPunishmentItems(level, cell, allItems);
}
}
}
// Confiscate remaining contraband (anything picked up during labor)
if (cell != null && !prisoner.getInventory().isEmpty()) {
com.tiedup.remake.cells.ConfiscatedInventoryRegistry registry =
com.tiedup.remake.cells.ConfiscatedInventoryRegistry.get(level);
java.util.List<net.minecraft.core.BlockPos> campChests =
findCampLootChests(level, cell);
if (!campChests.isEmpty()) {
registry.dumpInventoryToChest(prisoner, campChests.get(0));
TiedUpMod.LOGGER.debug(
"[LaborAttackPunishmentHandler] Confiscated remaining contraband from {}",
prisoner.getName().getString()
);
}
}
laborRecord.setTaskFailed(true);
// 4. Warning message
prisoner.sendSystemMessage(
Component.literal(
"Your task has been marked as failed. You will not be paid for your work."
).withStyle(ChatFormatting.DARK_RED)
);
// 5. Return prisoner to cell immediately (punishment)
if (cell != null) {
// Teleport to cell
net.minecraft.core.BlockPos spawnPoint = cell
.getSpawnPoint()
.above();
com.tiedup.remake.util.teleport.Position teleportTarget =
new com.tiedup.remake.util.teleport.Position(
spawnPoint,
level.dimension()
);
com.tiedup.remake.util.teleport.TeleportHelper.teleportEntity(
prisoner,
teleportTarget
);
// Reassign prisoner to cell registry (was removed during extract)
cellRegistry.assignPrisoner(cell.getId(), prisoner.getUUID());
// Transition to IMPRISONED state and start rest
long currentTime = level.getGameTime();
record.setState(PrisonerState.IMPRISONED, currentTime);
laborRecord.setTask(null); // CRITICAL: Clear task to prevent reuse after punishment
laborRecord.startRest(currentTime);
laborRecord.setEscortMaidId(null);
prisoner.sendSystemMessage(
Component.literal(
"You have been returned to your cell for your insolence!"
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD)
);
TiedUpMod.LOGGER.info(
"[LaborAttackPunishmentHandler] Prisoner {} attacked {} during labor - punished (shocked, +{} debt, task failed, items confiscated, returned to cell)",
prisoner.getName().getString(),
targetName,
DEBT_INCREASE_PER_ATTACK
);
} else {
TiedUpMod.LOGGER.warn(
"[LaborAttackPunishmentHandler] Could not return {} to cell - no cell found! (cellId={}, record={})",
prisoner.getName().getString(),
cellId != null ? cellId.toString().substring(0, 8) : "null",
record
);
}
}
/**
* Deposit punishment items (task items + tools) in camp chests.
* Items are confiscated as punishment and stored in camp.
*/
private static void depositPunishmentItems(
ServerLevel level,
com.tiedup.remake.cells.CellDataV2 cell,
java.util.List<net.minecraft.world.item.ItemStack> items
) {
if (items.isEmpty()) {
return;
}
java.util.UUID campId = cell.getOwnerId();
if (campId == null) {
TiedUpMod.LOGGER.warn(
"[LaborAttackPunishmentHandler] Cell has no camp owner - cannot deposit punishment items"
);
return;
}
java.util.List<net.minecraft.core.BlockPos> campChests =
findCampLootChests(level, cell);
if (campChests.isEmpty()) {
// Fallback: drop at camp center
com.tiedup.remake.cells.CampOwnership campOwnership =
com.tiedup.remake.cells.CampOwnership.get(level);
com.tiedup.remake.cells.CampOwnership.CampData camp =
campOwnership.getCamp(campId);
if (camp != null && camp.getCenter() != null) {
net.minecraft.core.BlockPos center = camp.getCenter();
TiedUpMod.LOGGER.warn(
"[LaborAttackPunishmentHandler] No LOOT chests found - dropping punishment items at camp center {}",
center.toShortString()
);
for (net.minecraft.world.item.ItemStack stack : items) {
if (stack.isEmpty()) continue;
net.minecraft.world.entity.item.ItemEntity itemEntity =
new net.minecraft.world.entity.item.ItemEntity(
level,
center.getX() + 0.5,
center.getY() + 1.0,
center.getZ() + 0.5,
stack
);
level.addFreshEntity(itemEntity);
}
}
return;
}
// Use unified inventory system for smart chest rotation
com.tiedup.remake.cells.ConfiscatedInventoryRegistry registry =
com.tiedup.remake.cells.ConfiscatedInventoryRegistry.get(level);
int deposited = registry.depositItemsInChests(items, campChests, level);
TiedUpMod.LOGGER.info(
"[LaborAttackPunishmentHandler] Deposited {} punishment items in {} camp LOOT chests",
deposited,
campChests.size()
);
}
/**
* Find all LOOT chests for a camp.
* Delegates to ItemService which handles fast-path + lazy discovery.
*/
private static java.util.List<
net.minecraft.core.BlockPos
> findCampLootChests(
ServerLevel level,
com.tiedup.remake.cells.CellDataV2 cell
) {
java.util.UUID campId = cell.getOwnerId();
if (campId == null) return java.util.List.of();
return com.tiedup.remake.prison.service.ItemService.get().findAllCampChests(
level,
campId
);
}
}

View File

@@ -0,0 +1,65 @@
package com.tiedup.remake.events.combat;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.entity.living.LivingChangeTargetEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler to prevent monsters from targeting tied-up players.
*
* When a player is bound (has bind item), monsters will ignore them.
* This makes sense because:
* 1. Tied-up players are helpless and can't fight back
* 2. Camp kidnappers protect their captives from monsters
* 3. It reduces frustration of being killed while unable to defend
*
* The kidnapper's HuntMonstersGoal will handle killing nearby monsters instead.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class MonsterTargetingHandler {
/**
* Called when any living entity changes its target.
* Cancel the event if a monster tries to target a tied-up player.
*/
@SubscribeEvent
public static void onLivingChangeTarget(LivingChangeTargetEvent event) {
// Only care about monsters
if (!(event.getEntity() instanceof Monster monster)) {
return;
}
// Check if new target is a player
LivingEntity newTarget = event.getNewTarget();
if (!(newTarget instanceof Player player)) {
return;
}
// Server-side only for ServerPlayer state check
if (!(player instanceof ServerPlayer serverPlayer)) {
return;
}
// Check if player is tied up
PlayerBindState state = PlayerBindState.getInstance(serverPlayer);
if (state != null && state.isTiedUp()) {
// Cancel targeting - monster ignores tied-up player
event.setCanceled(true);
TiedUpMod.LOGGER.debug(
"[MonsterTargetingHandler] {} ignored tied-up player {}",
monster.getName().getString(),
player.getName().getString()
);
}
}
}

View File

@@ -0,0 +1,137 @@
package com.tiedup.remake.events.interaction;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.conversation.ConversationManager;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.conversation.PacketEndConversationS2C;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Server tick handler for dialogue and conversation system maintenance.
*
* Phase 14: Conversation System
*
* Handles:
* - Cleanup of stale/abandoned conversations
* - Distance checks for active conversations
* - Entity validity checks (NPC died, etc.)
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class DialogueTickHandler {
/**
* How often to run cleanup (in ticks).
* 1200 ticks = 1 minute
*/
private static final int CLEANUP_INTERVAL = 1200;
/**
* How often to check active conversations (in ticks).
* 20 ticks = 1 second
*/
private static final int CHECK_INTERVAL = 20;
/**
* Maximum distance for conversation before auto-ending.
*/
private static final double MAX_CONVERSATION_DISTANCE = 8.0;
/**
* Handle server tick events for dialogue system maintenance.
*/
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
long currentTick = event.getServer().getTickCount();
// Check active conversations every second
if (currentTick % CHECK_INTERVAL == 0) {
checkActiveConversations(event.getServer());
}
// Cleanup stale conversations every minute
if (currentTick % CLEANUP_INTERVAL == 0) {
ConversationManager.cleanupStaleConversations();
}
}
/**
* Check all active conversations for validity.
* Ends conversations where player/NPC is too far, dead, or disconnected.
*/
private static void checkActiveConversations(
net.minecraft.server.MinecraftServer server
) {
List<ServerPlayer> playersToEnd = new ArrayList<>();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
ConversationManager.ConversationState state =
ConversationManager.getConversationState(player);
if (state == null) continue;
UUID speakerId = state.getSpeakerEntityId();
boolean shouldEnd = false;
int entityId = -1;
// O(1) lookup: get the specific ServerLevel, then getEntity by UUID
ServerLevel speakerLevel = server.getLevel(
state.getSpeakerDimension()
);
Entity speakerEntity =
speakerLevel != null ? speakerLevel.getEntity(speakerId) : null;
if (speakerEntity == null) {
// Entity no longer exists
shouldEnd = true;
TiedUpMod.LOGGER.debug(
"[DialogueTickHandler] Ending conversation: NPC no longer exists"
);
} else if (!speakerEntity.isAlive()) {
// Entity died
shouldEnd = true;
entityId = speakerEntity.getId();
TiedUpMod.LOGGER.debug(
"[DialogueTickHandler] Ending conversation: NPC died"
);
} else if (
speakerEntity.distanceTo(player) > MAX_CONVERSATION_DISTANCE
) {
// Player moved too far
shouldEnd = true;
entityId = speakerEntity.getId();
TiedUpMod.LOGGER.debug(
"[DialogueTickHandler] Ending conversation: Player too far ({})",
speakerEntity.distanceTo(player)
);
}
if (shouldEnd) {
playersToEnd.add(player);
// Send packet to close client GUI
if (entityId != -1) {
ModNetwork.sendToPlayer(
new PacketEndConversationS2C(entityId),
player
);
}
}
}
// End conversations outside the iteration
for (ServerPlayer player : playersToEnd) {
ConversationManager.endConversation(player);
}
}
}

View File

@@ -0,0 +1,167 @@
package com.tiedup.remake.events.interaction;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.state.IRestrainable;
import com.tiedup.remake.state.ICaptor;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.util.tasks.ItemTask;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for player interactions with EntityKidnapper.
*
* Phase 14.3.5: Sale system interactions
*
* Handles:
* - Right-click on kidnapper selling slave to buy
* - Payment validation
* - Slave transfer to buyer
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class KidnapperInteractionEventHandler {
/**
* Handle right-click interaction with EntityKidnapper.
*/
@SubscribeEvent
public static void onEntityInteract(
PlayerInteractEvent.EntityInteract event
) {
// Server-side only
if (event.getLevel().isClientSide()) return;
// Must be interacting with a kidnapper
if (!(event.getTarget() instanceof EntityKidnapper kidnapper)) return;
// Must use main hand
if (event.getHand() != InteractionHand.MAIN_HAND) return;
Player player = event.getEntity();
// Skip enslaved kidnappers - let item/entity interactions handle them
if (kidnapper.isTiedUp()) return;
// Check if kidnapper is selling a captive
if (kidnapper.isSellingCaptive()) {
handleSalePurchase(player, kidnapper, event);
return;
}
// Other interactions can be added here later
}
/**
* Handle a player attempting to purchase a captive from a kidnapper.
*/
private static void handleSalePurchase(
Player player,
EntityKidnapper kidnapper,
PlayerInteractEvent.EntityInteract event
) {
IRestrainable captive = kidnapper.getCaptive();
if (captive == null) return;
ItemTask price = captive.getSalePrice();
if (price == null) return;
ItemStack heldItem = player.getMainHandItem();
// Check if player is holding the required payment
if (!price.matchesItem(heldItem)) {
// Wrong item - show what's needed
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
Component.translatable(
"tiedup.sale.wrong_item",
price.toDisplayString()
).getString()
);
return;
}
if (!price.isCompletedBy(heldItem)) {
// Not enough items
int have = heldItem.getCount();
int need = price.getAmount();
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
Component.translatable(
"tiedup.sale.not_enough",
have,
need,
price.getDisplayName()
).getString()
);
return;
}
// Player has the required payment!
// Consume payment
price.consumeFrom(heldItem);
// Get player's kidnapper interface for slave transfer
PlayerBindState bindState = PlayerBindState.getInstance(player);
if (bindState == null) {
TiedUpMod.LOGGER.error(
"[KidnapperInteractionEventHandler] Player {} has no bind state!",
player.getName().getString()
);
return;
}
// Phase 17: getSlaveHolderManager → getCaptorManager
ICaptor buyerKidnapper = bindState.getCaptorManager();
if (buyerKidnapper == null) {
TiedUpMod.LOGGER.error(
"[KidnapperInteractionEventHandler] Player {} has no kidnapper interface!",
player.getName().getString()
);
return;
}
// Complete the sale
if (kidnapper.completeSale(buyerKidnapper)) {
// Success!
SystemMessageManager.sendChatToPlayer(
player,
Component.translatable(
"tiedup.sale.success",
captive.getKidnappedName()
).getString(),
ChatFormatting.GREEN
);
// Kidnapper talks about completed sale
kidnapper.talkTo(player, DialogueCategory.SALE_COMPLETE);
TiedUpMod.LOGGER.info(
"[KidnapperInteractionEventHandler] {} purchased {} from {} for {}",
player.getName().getString(),
captive.getKidnappedName(),
kidnapper.getNpcName(),
price.toDisplayString()
);
event.setCanceled(true);
} else {
// Sale failed for some reason
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
Component.translatable("tiedup.sale.failed").getString()
);
}
}
}

View File

@@ -0,0 +1,92 @@
package com.tiedup.remake.events.interaction;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityKidnapperMerchant;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.merchant.PacketOpenMerchantScreen;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for player interactions with EntityKidnapperMerchant.
*
* Handles:
* - Right-click on merchant to open trading screen
* - Validation checks (merchant mode, no captives)
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class MerchantInteractionEventHandler {
/**
* Handle right-click interaction with EntityKidnapperMerchant.
*/
@SubscribeEvent
public static void onEntityInteract(
PlayerInteractEvent.EntityInteract event
) {
// Server-side only
if (event.getLevel().isClientSide()) return;
// Must be interacting with a merchant
if (
!(event.getTarget() instanceof EntityKidnapperMerchant merchant)
) return;
// Must use main hand
if (event.getHand() != InteractionHand.MAIN_HAND) return;
// Skip enslaved merchants - allow normal NPC interactions (command wand, conversation)
if (merchant.isTiedUp()) return;
Player player = event.getEntity();
// Validation 1: Merchant must be in MERCHANT mode (not hostile)
if (merchant.isHostile()) {
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
"The merchant is too angry to trade!"
);
event.setCanceled(true);
return;
}
// Validation 2: Merchant must not have captives
if (merchant.hasCaptives()) {
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
"The merchant is busy with a captive!"
);
event.setCanceled(true);
return;
}
// Open trading screen
if (player instanceof ServerPlayer serverPlayer) {
// Mark merchant as trading with this player
merchant.startTrading(player.getUUID());
ModNetwork.sendToPlayer(
new PacketOpenMerchantScreen(
merchant.getUUID(),
merchant.getTrades()
),
serverPlayer
);
TiedUpMod.LOGGER.debug(
"[MerchantInteractionEventHandler] {} opened merchant screen for {}",
player.getName().getString(),
merchant.getNpcName()
);
}
event.setCanceled(true);
}
}

View File

@@ -0,0 +1,148 @@
package com.tiedup.remake.events.lifecycle;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import java.util.Map;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.GameRules;
import net.minecraftforge.event.AttachCapabilitiesEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for capability attachment and persistence.
* Handles attaching V2 bondage equipment to players and preserving it across death/respawn.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class CapabilityEventHandler {
private static final ResourceLocation V2_BONDAGE_EQUIPMENT_ID =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"v2_bondage_equipment"
);
/**
* Attach V2 bondage equipment capability to all players.
*/
@SubscribeEvent
public static void onAttachCapabilities(
AttachCapabilitiesEvent<Entity> event
) {
if (!(event.getObject() instanceof Player)) {
return;
}
// V2 bondage equipment capability
V2BondageEquipmentProvider v2Provider = new V2BondageEquipmentProvider();
event.addCapability(V2_BONDAGE_EQUIPMENT_ID, v2Provider);
event.addListener(v2Provider::invalidate);
}
/**
* Handle bondage equipment on player clone (death/respawn or dimension change).
*
* <p>Behavior:
* <ul>
* <li>Death without keepInventory: Fire onUnequipped for all items, don't copy</li>
* <li>Death with keepInventory: Preserve via serialize/deserialize round-trip</li>
* <li>Dimension change (End portal): Preserve via serialize/deserialize round-trip</li>
* </ul>
*
* IMPORTANT: Must call reviveCaps() on original player to access capabilities
* after the entity has been marked as removed.
*/
@SubscribeEvent
public static void onPlayerClone(PlayerEvent.Clone event) {
// Revive capabilities on old player (required after entity removal)
event.getOriginal().reviveCaps();
Player oldPlayer = event.getOriginal();
Player newPlayer = event.getEntity();
if (event.isWasDeath()) {
boolean keepInventory = oldPlayer.level().getGameRules()
.getBoolean(GameRules.RULE_KEEPINVENTORY);
// === V2 Death Handling ===
oldPlayer
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(oldV2 -> {
if (keepInventory) {
// keepInventory: serialize/deserialize round-trip
newPlayer
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(newV2 -> {
CompoundTag saved = oldV2.serializeNBT();
newV2.deserializeNBT(saved);
// Fire onEquipped for each item on the new player
for (Map.Entry<BodyRegionV2, ItemStack> entry
: newV2.getAllEquipped().entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) {
v2Item.onEquipped(stack, newPlayer);
}
}
TiedUpMod.LOGGER.debug(
"[CapabilityEventHandler] V2 preserved on death (keepInventory)"
);
});
} else {
// No keepInventory: fire onUnequipped for each V2 item
for (Map.Entry<BodyRegionV2, ItemStack> entry
: oldV2.getAllEquipped().entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) {
v2Item.onUnequipped(stack, oldPlayer);
}
}
TiedUpMod.LOGGER.debug(
"[CapabilityEventHandler] V2 cleared on death (no keepInventory)"
);
}
});
// Invalidate old player caps to prevent memory leak
event.getOriginal().invalidateCaps();
return;
}
// DIMENSION CHANGE (End portal, etc.): Preserve all equipment
// === V2 Dimension Change ===
oldPlayer
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(oldV2 -> {
newPlayer
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(newV2 -> {
CompoundTag saved = oldV2.serializeNBT();
newV2.deserializeNBT(saved);
// Fire onEquipped for each item on the new player
for (Map.Entry<BodyRegionV2, ItemStack> entry
: newV2.getAllEquipped().entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) {
v2Item.onEquipped(stack, newPlayer);
}
}
TiedUpMod.LOGGER.debug(
"[CapabilityEventHandler] Preserved V2 equipment on dimension change"
);
});
});
// Invalidate old player caps to prevent memory leak
event.getOriginal().invalidateCaps();
}
}

View File

@@ -0,0 +1,118 @@
package com.tiedup.remake.events.lifecycle;
import com.tiedup.remake.core.SettingsAccessor;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.entities.EntityKidnapperArcher;
import com.tiedup.remake.entities.EntityKidnapperElite;
import com.tiedup.remake.entities.EntityKidnapperMerchant;
import com.tiedup.remake.entities.EntityMaster;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.level.GameRules;
import net.minecraft.world.level.Level;
import net.minecraftforge.event.entity.living.MobSpawnEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for controlling entity spawning via gamerules.
*
* Controls both on/off spawning and spawn rates:
* - damselsSpawn + damselSpawnRate: Controls EntityDamsel
* - kidnappersSpawn + variant rates: Controls EntityKidnapper variants
* - masterSpawnRate: Controls EntityMaster
*
* Spawn rates are 0-100 percentage. For example, 50 = 50% chance to allow spawn.
* Note: Command spawns (/tiedup npc spawn) are not affected.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class EntitySpawnHandler {
/**
* Check if entity spawn should be allowed based on gamerules.
*
* @param event The spawn check event
*/
@SubscribeEvent
public static void onCheckSpawn(MobSpawnEvent.FinalizeSpawn event) {
LivingEntity entity = event.getEntity();
Level level = entity.level();
GameRules gameRules = level.getGameRules();
RandomSource random = level.getRandom();
// Check Master spawn (separate entity, not related to Damsel)
if (entity instanceof EntityMaster) {
int spawnRate = SettingsAccessor.getMasterSpawnRate(gameRules);
if (!checkSpawnRate(random, spawnRate)) {
event.setSpawnCancelled(true);
return;
}
}
// Check Kidnapper spawn (includes Elite, Archer, Merchant)
else if (entity instanceof EntityKidnapper kidnapper) {
if (!SettingsAccessor.doKidnappersSpawn(gameRules)) {
event.setSpawnCancelled(true);
return;
}
// Check variant-specific spawn rate
int spawnRate = getKidnapperVariantSpawnRate(kidnapper, gameRules);
if (!checkSpawnRate(random, spawnRate)) {
event.setSpawnCancelled(true);
return;
}
}
// Check Damsel spawn
else if (entity instanceof EntityDamsel) {
if (!SettingsAccessor.doDamselsSpawn(gameRules)) {
event.setSpawnCancelled(true);
return;
}
// Check damsel spawn rate
int spawnRate = SettingsAccessor.getDamselSpawnRate(gameRules);
if (!checkSpawnRate(random, spawnRate)) {
event.setSpawnCancelled(true);
return;
}
}
}
/**
* Check if spawn should be allowed based on rate (0-100).
*
* @param random Random source
* @param rate Spawn rate percentage (0-100)
* @return true if spawn should be allowed
*/
private static boolean checkSpawnRate(RandomSource random, int rate) {
if (rate <= 0) return false;
if (rate >= 100) return true;
return random.nextInt(100) < rate;
}
/**
* Get spawn rate for a specific kidnapper variant.
*/
private static int getKidnapperVariantSpawnRate(
EntityKidnapper kidnapper,
GameRules gameRules
) {
// Check class type to determine spawn rate
if (kidnapper instanceof EntityKidnapperArcher) {
return SettingsAccessor.getKidnapperArcherSpawnRate(gameRules);
} else if (kidnapper instanceof EntityKidnapperElite) {
return SettingsAccessor.getKidnapperEliteSpawnRate(gameRules);
} else if (kidnapper instanceof EntityKidnapperMerchant) {
return SettingsAccessor.getKidnapperMerchantSpawnRate(gameRules);
} else {
// Base kidnapper
return SettingsAccessor.getKidnapperSpawnRate(gameRules);
}
}
}

View File

@@ -0,0 +1,163 @@
package com.tiedup.remake.events.lifecycle;
import com.mojang.logging.LogUtils;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.events.captivity.ForcedSeatingHandler;
import com.tiedup.remake.events.restriction.LaborToolProtectionHandler;
import com.tiedup.remake.events.restriction.PetPlayRestrictionHandler;
import com.tiedup.remake.network.PacketRateLimiter;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger;
/**
* Handler for player disconnect events to clean up server-side resources.
*
* <p>This handler ensures that resources associated with disconnected players
* are properly cleaned up to prevent memory leaks and unbounded map growth.
*
* <p>Phase: Server Resource Management
*
* <p>Cleanup includes:
* <ul>
* <li>Rate limiter token buckets ({@link PacketRateLimiter})</li>
* <li>Future: Session data, pending tasks, etc.</li>
* </ul>
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class PlayerDisconnectHandler {
private static final Logger LOGGER = LogUtils.getLogger();
/**
* Clean up resources when a player logs out.
*
* <p>This event fires when a player disconnects from the server,
* either by logging out normally or being kicked/timing out.
*
* @param event The player logout event
*/
@SubscribeEvent
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
java.util.UUID playerId = event.getEntity().getUUID();
// NOTE: PlayerBindState.removeInstance() is called by PlayerLifecycleHandler (EventPriority.HIGH)
// We don't duplicate it here to avoid redundant cleanup
// Clean up rate limiter state
PacketRateLimiter.cleanup(playerId);
// Clean up static cooldown maps to prevent memory leaks
com.tiedup.remake.commands.SocialCommand.cleanupPlayer(playerId);
LaborToolProtectionHandler.cleanupPlayer(playerId);
// BUG FIX: Memory leak cleanup for event handlers
// Clean up ForcedSeatingHandler maps
ForcedSeatingHandler.clearPlayer(playerId);
// Clean up PetPlayRestrictionHandler timestamp map
PetPlayRestrictionHandler.clearPlayer(playerId);
// Clean up minigame sessions
com.tiedup.remake.minigame.MiniGameSessionManager.getInstance().cleanupPlayer(
playerId
);
// Clean up pet cage state
com.tiedup.remake.v2.blocks.PetCageManager.onPlayerDisconnect(playerId);
// Clean up pet bed state (has onPlayerDisconnect but was never wired)
com.tiedup.remake.v2.blocks.PetBedManager.onPlayerDisconnect(playerId);
// Clean up active conversations
com.tiedup.remake.dialogue.conversation.ConversationManager.cleanupPlayer(playerId);
// Clean up cell selection mode
com.tiedup.remake.cells.CellSelectionManager.cleanup(playerId);
// BUG FIX: Security - Remove labor tools from disconnecting player
// This prevents players from keeping unbreakable tools by disconnecting
if (
event.getEntity() instanceof
net.minecraft.server.level.ServerPlayer player
) {
removeLaborTools(player);
}
// BUG FIX: Memory leak cleanup for entities
// Clean up EntityKidnapperMerchant tradingPlayers set (O(1) reverse-lookup)
if (
event.getEntity().level() instanceof
net.minecraft.server.level.ServerLevel serverLevel
) {
java.util.UUID merchantUUID =
com.tiedup.remake.entities.EntityKidnapperMerchant.getMerchantForPlayer(
playerId
);
if (merchantUUID != null) {
net.minecraft.world.entity.Entity merchantEntity =
serverLevel.getEntity(merchantUUID);
if (
merchantEntity instanceof
com.tiedup.remake.entities.EntityKidnapperMerchant merchant
) {
merchant.cleanupTradingPlayer(playerId);
}
}
// Kidnapper robbery immunity: cheap per-entity Map.remove(), disconnect-only — acceptable scan
// Uses getAllEntities since there's no UUID index for this reverse lookup
for (net.minecraft.world.entity.Entity entity : serverLevel.getAllEntities()) {
if (
entity instanceof
com.tiedup.remake.entities.EntityKidnapper kidnapper
) {
kidnapper.clearRobbedImmunity(playerId);
}
}
}
LOGGER.debug(
"Cleaned up server resources for player: {} ({})",
event.getEntity().getName().getString(),
playerId
);
}
/**
* SECURITY: Remove all labor tools from player inventory on disconnect.
* Prevents exploit where players disconnect to keep unbreakable tools.
*/
private static void removeLaborTools(
net.minecraft.server.level.ServerPlayer player
) {
var inventory = player.getInventory();
int removedCount = 0;
for (int i = 0; i < inventory.getContainerSize(); i++) {
net.minecraft.world.item.ItemStack stack = inventory.getItem(i);
if (!stack.isEmpty() && stack.hasTag()) {
net.minecraft.nbt.CompoundTag tag = stack.getTag();
if (tag != null && tag.getBoolean("LaborTool")) {
inventory.setItem(
i,
net.minecraft.world.item.ItemStack.EMPTY
);
removedCount++;
}
}
}
if (removedCount > 0) {
LOGGER.info(
"[PlayerDisconnectHandler] Removed {} labor tools from {} on disconnect",
removedCount,
player.getName().getString()
);
}
}
}

View File

@@ -0,0 +1,384 @@
package com.tiedup.remake.events.lifecycle;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.entities.ModEntities;
import com.tiedup.remake.state.IPlayerLeashAccess;
import com.tiedup.remake.state.PlayerBindState;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.decoration.LeashFenceKnotEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles clean player lifecycle transitions.
* - Master/Slave links are cleaned up on disconnect.
* - Pole leash positions are saved for restoration on reconnect.
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class PlayerLifecycleHandler {
/**
* Triggered when a player leaves the server.
* Cleans up enslavement links and saves pole leash state.
*
* Priority HIGH ensures this runs BEFORE other logout handlers that may read PlayerBindState.
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onPlayerLoggedOut(
PlayerEvent.PlayerLoggedOutEvent event
) {
Player player = event.getEntity();
if (player.level().isClientSide) return;
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) return;
// Phase 17: CASE 1: This player is a Captor - free all their captives
if (state.getCaptorManager().hasCaptives()) {
TiedUpMod.LOGGER.info(
"[Lifecycle] Captor {} disconnected. Freeing all captives.",
player.getName().getString()
);
state.getCaptorManager().freeAllCaptives(true);
}
// CASE 2: This player is leashed
if (
player instanceof IPlayerLeashAccess access &&
access.tiedup$isLeashed()
) {
Entity holder = access.tiedup$getLeashHolder();
// CASE 2a: Leashed to a pole - SAVE for restoration
if (holder instanceof LeashFenceKnotEntity fenceKnot) {
BlockPos polePos = fenceKnot.getPos();
player
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(cap -> {
cap.savePoleLeash(polePos, player.level().dimension());
TiedUpMod.LOGGER.info(
"[Lifecycle] {} disconnected while leashed to pole at {}. Saved for restoration.",
player.getName().getString(),
polePos
);
});
// Detach without dropping (will restore on reconnect)
access.tiedup$detachLeash();
}
// MEDIUM FIX: CASE 2b: Leashed to a captor (player or NPC) - SAVE for restoration
else if (state.isCaptive() && state.getCaptor() != null) {
java.util.UUID captorUUID = state
.getCaptor()
.getEntity()
.getUUID();
player
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(cap -> {
cap.saveCaptorUUID(captorUUID);
TiedUpMod.LOGGER.info(
"[Lifecycle] {} disconnected while captive to {}. Saved for restoration.",
player.getName().getString(),
captorUUID.toString().substring(0, 8)
);
});
// Remove from captor's tracking list (will be re-added on reconnect)
state.getCaptor().removeCaptive(state, false);
// Detach leash without dropping (will restore on reconnect)
access.tiedup$detachLeash();
// Clear captor reference (will be restored on reconnect)
state.setCaptor(null);
}
}
// CASE 3: Pet play - handle Master entity
handleMasterOnDisconnect(player);
// Canonical removal point — this is the ONLY place removeInstance should be called for server-side logout
PlayerBindState.removeInstance(player.getUUID(), false);
}
/**
* Triggered when a player joins the server.
* Restores Master entity if the player was a pet.
*/
@SubscribeEvent(priority = EventPriority.LOW)
public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
Player player = event.getEntity();
if (player.level().isClientSide) return;
if (player instanceof ServerPlayer serverPlayer) {
// Clean up any leftover pet bed/cage speed modifiers from previous session
com.tiedup.remake.v2.blocks.PetBedManager.onPlayerLogin(
serverPlayer
);
com.tiedup.remake.v2.blocks.PetCageManager.onPlayerLogin(
serverPlayer
);
// Delay to ensure player is fully loaded and in world
serverPlayer
.getServer()
.execute(() -> {
// Extra tick delay to ensure world is ready
serverPlayer
.level()
.getServer()
.execute(() -> {
handleMasterOnReconnect(serverPlayer);
});
});
}
}
// ========================================
// MASTER PERSISTENCE CONSTANTS
// ========================================
private static final String NBT_MASTER_DATA = "TiedUp_MasterPersistence";
private static final String NBT_MASTER_HAS_MASTER = "HasMaster";
private static final String NBT_MASTER_VARIANT_ID = "MasterVariantId";
private static final String NBT_MASTER_STATE = "MasterState";
private static final String NBT_MASTER_NAME = "MasterName";
// ========================================
// MASTER DISCONNECT HANDLING
// ========================================
/**
* Handle Master entity when pet player disconnects.
* Saves Master data to player and removes Master entity.
*
* If Master entity is not found (in unloaded chunk), falls back to reading
* data from the collar NBT which is continuously synchronized.
*/
private static void handleMasterOnDisconnect(Player player) {
// Check if player has a Master (pet play mode)
UUID masterUUID = EntityMaster.getMasterUUID(player);
if (masterUUID == null) return;
if (!(player.level() instanceof ServerLevel serverLevel)) return;
// O(1) lookup by UUID instead of scanning all entities
EntityMaster master = null;
Entity masterEntity = serverLevel.getEntity(masterUUID);
if (masterEntity instanceof EntityMaster m) {
master = m;
}
CompoundTag masterData = new CompoundTag();
if (master != null) {
// Master found - save from entity (most up-to-date)
masterData.putBoolean(NBT_MASTER_HAS_MASTER, true);
masterData.putString(
NBT_MASTER_VARIANT_ID,
master.getKidnapperVariantId()
);
masterData.putString(
NBT_MASTER_STATE,
master.getStateManager().serializeState()
);
masterData.putString(NBT_MASTER_NAME, master.getNpcName());
TiedUpMod.LOGGER.info(
"[Lifecycle] {} disconnected - saving Master {} (entity found)",
player.getName().getString(),
master.getNpcName()
);
// Despawn the Master entity
master.discard();
} else {
// Master not found - fallback to collar data
ItemStack collar =
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion(
player,
com.tiedup.remake.v2.BodyRegionV2.NECK
);
if (!collar.isEmpty() && collar.hasTag()) {
CompoundTag collarTag = collar.getTag();
if (collarTag.getBoolean("masterDataValid")) {
masterData.putBoolean(NBT_MASTER_HAS_MASTER, true);
masterData.putString(
NBT_MASTER_VARIANT_ID,
collarTag.getString("masterVariantId")
);
masterData.putString(
NBT_MASTER_STATE,
collarTag.getString("masterState")
);
masterData.putString(
NBT_MASTER_NAME,
collarTag.getString("masterName")
);
TiedUpMod.LOGGER.warn(
"[Lifecycle] {} disconnected - Master entity not found in loaded chunks, " +
"using collar data. Master may remain in unloaded chunk.",
player.getName().getString()
);
} else {
TiedUpMod.LOGGER.error(
"[Lifecycle] {} has Master UUID but no valid collar data!",
player.getName().getString()
);
return;
}
} else {
TiedUpMod.LOGGER.error(
"[Lifecycle] {} has Master UUID but no collar found!",
player.getName().getString()
);
return;
}
}
// Store in player's persistent data
CompoundTag persistentData = player.getPersistentData();
persistentData.put(NBT_MASTER_DATA, masterData);
}
// ========================================
// MASTER RECONNECT HANDLING
// ========================================
/**
* Handle Master restoration when pet player reconnects.
* Spawns a new Master entity with saved data.
*
* First cleans up any orphaned Master entities that may remain from previous sessions
* (e.g., if Master was in unloaded chunk during disconnect).
*/
private static void handleMasterOnReconnect(ServerPlayer player) {
// Check if player has saved Master data
CompoundTag persistentData = player.getPersistentData();
if (!persistentData.contains(NBT_MASTER_DATA)) return;
CompoundTag masterData = persistentData.getCompound(NBT_MASTER_DATA);
if (!masterData.getBoolean(NBT_MASTER_HAS_MASTER)) return;
// Check if player still has pet collar
if (!EntityMaster.hasPetCollar(player)) {
// Player no longer has collar - clear saved data
persistentData.remove(NBT_MASTER_DATA);
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected but no pet collar - clearing Master data",
player.getName().getString()
);
return;
}
ServerLevel level = player.serverLevel();
// CLEANUP: Remove orphaned Master entity that may remain from previous session
// (e.g., if Master was in unloaded chunk during disconnect)
// O(1) lookup by UUID from collar instead of scanning all entities
UUID existingMasterUUID = EntityMaster.getMasterUUID(player);
if (existingMasterUUID != null) {
Entity orphanEntity = level.getEntity(existingMasterUUID);
if (
orphanEntity instanceof EntityMaster m && m.isPetPlayer(player)
) {
TiedUpMod.LOGGER.warn(
"[Lifecycle] Found orphaned Master {} claiming {}, despawning before restoration",
m.getNpcName(),
player.getName().getString()
);
m.discard();
}
}
// Spawn new Master entity near player
EntityMaster master = ModEntities.MASTER.get().create(level);
if (master == null) {
TiedUpMod.LOGGER.error(
"[Lifecycle] Failed to create Master entity for {}",
player.getName().getString()
);
return;
}
// Restore Master data
String variantId = masterData.getString(NBT_MASTER_VARIANT_ID);
String stateSerialized = masterData.getString(NBT_MASTER_STATE);
String masterName = masterData.getString(NBT_MASTER_NAME);
if (!variantId.isEmpty()) {
// Lookup and set the variant
com.tiedup.remake.entities.KidnapperVariant variant =
master.lookupVariantById(variantId);
if (variant != null) {
master.setKidnapperVariant(variant);
}
}
// Position Master near player
double angle = master.getRandom().nextDouble() * Math.PI * 2;
double dist = 2.0 + master.getRandom().nextDouble() * 2;
double x = player.getX() + Math.cos(angle) * dist;
double z = player.getZ() + Math.sin(angle) * dist;
master.setPos(x, player.getY(), z);
// Set the pet player
master.setPetPlayer(player);
// Restore state if available
if (!stateSerialized.isEmpty()) {
master.getStateManager().deserializeState(stateSerialized);
}
// Spawn the Master
level.addFreshEntity(master);
// Update the player's collar with new Master UUID
updateCollarMasterUUID(player, master.getUUID());
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected - restored Master {} at ({}, {}, {})",
player.getName().getString(),
master.getNpcName(),
(int) x,
(int) player.getY(),
(int) z
);
// Clear saved Master data
persistentData.remove(NBT_MASTER_DATA);
}
/**
* Update the player's collar with the new Master UUID.
*/
private static void updateCollarMasterUUID(
ServerPlayer player,
UUID masterUUID
) {
net.minecraft.world.item.ItemStack collarStack =
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion(
player,
com.tiedup.remake.v2.BodyRegionV2.NECK
);
if (!collarStack.isEmpty()) {
CompoundTag tag = collarStack.getOrCreateTag();
tag.putUUID("masterUUID", masterUUID);
}
}
}

View File

@@ -0,0 +1,437 @@
package com.tiedup.remake.events.lifecycle;
import com.tiedup.remake.events.restriction.BondageItemRestrictionHandler;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import com.tiedup.remake.cells.CampOwnership;
import com.tiedup.remake.cells.CellRegistryV2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.labor.LaborTask;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.labor.PacketSyncLaborProgress;
import com.tiedup.remake.network.sync.PacketSyncBindState;
import com.tiedup.remake.prison.LaborRecord;
import com.tiedup.remake.prison.PrisonerManager;
import com.tiedup.remake.prison.PrisonerRecord;
import com.tiedup.remake.prison.PrisonerState;
import com.tiedup.remake.state.IPlayerLeashAccess;
import com.tiedup.remake.state.PlayerBindState;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.decoration.LeashFenceKnotEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraftforge.event.entity.living.LivingDeathEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for PlayerBindState lifecycle management.
* Handles player connection, disconnection, death, and respawn.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class PlayerStateEventHandler {
/** UUIDs of players who died while imprisoned — message sent on respawn */
private static final Set<UUID> pendingDeathEscapeMessage =
ConcurrentHashMap.newKeySet();
/**
* Called when a player logs in to the server.
* Initialize or reset their PlayerBindState, restore pole leash, and sync to client.
*/
@SubscribeEvent
public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
if (!(event.getEntity() instanceof ServerPlayer player)) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state != null) {
state.resetNewConnection(player);
}
// Restore pole leash if player was leashed to one when they disconnected
restorePoleLeashIfNeeded(player);
// MEDIUM FIX: Restore captor if player was captive when they disconnected
restoreCaptorIfNeeded(player);
// Sync V2 equipment + bind state to the logging-in player
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync(player);
PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer(
player
);
if (statePacket != null) {
ModNetwork.sendToPlayer(statePacket, player);
}
// Sync Labor HUD on login
syncLaborState(player);
}
/**
* Restore leash to pole if player was leashed when they disconnected.
*/
private static void restorePoleLeashIfNeeded(ServerPlayer player) {
player
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(cap -> {
if (!cap.wasLeashedToPole()) {
return;
}
BlockPos polePos = cap.getSavedPolePosition();
ResourceKey<Level> poleDimension = cap.getSavedPoleDimension();
if (polePos == null || poleDimension == null) {
cap.clearSavedPoleLeash();
return;
}
// Check if player is in the same dimension
if (!player.level().dimension().equals(poleDimension)) {
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected but is in different dimension. Clearing saved pole leash.",
player.getName().getString()
);
cap.clearSavedPoleLeash();
return;
}
ServerLevel level = player.serverLevel();
// Try to find or create the LeashFenceKnotEntity at that position
LeashFenceKnotEntity fenceKnot =
LeashFenceKnotEntity.getOrCreateKnot(level, polePos);
if (fenceKnot == null) {
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected but pole at {} no longer exists.",
player.getName().getString(),
polePos
);
cap.clearSavedPoleLeash();
return;
}
// Attach leash to the pole
if (player instanceof IPlayerLeashAccess access) {
access.tiedup$attachLeash(fenceKnot);
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected. Restored leash to pole at {}.",
player.getName().getString(),
polePos
);
}
// Clear saved data (no longer needed)
cap.clearSavedPoleLeash();
});
}
/**
* MEDIUM FIX: Restore captor if player was captive when they disconnected.
*/
private static void restoreCaptorIfNeeded(ServerPlayer player) {
player
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(cap -> {
if (!cap.hasSavedCaptor()) {
return;
}
java.util.UUID captorUUID = cap.getSavedCaptorUUID();
if (captorUUID == null) {
cap.clearSavedCaptor();
return;
}
ServerLevel level = player.serverLevel();
// Try to find the captor entity
net.minecraft.world.entity.Entity captorEntity =
level.getEntity(captorUUID);
if (captorEntity == null) {
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected but captor {} no longer exists. Clearing captivity.",
player.getName().getString(),
captorUUID.toString().substring(0, 8)
);
cap.clearSavedCaptor();
return;
}
// Check if captor is still a valid ICaptor
if (
!(captorEntity instanceof
net.minecraft.world.entity.LivingEntity livingCaptor)
) {
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected but captor is not a LivingEntity. Clearing captivity.",
player.getName().getString()
);
cap.clearSavedCaptor();
return;
}
// Get the ICaptor interface
com.tiedup.remake.state.ICaptor captor = null;
if (
livingCaptor instanceof
com.tiedup.remake.state.ICaptor kidnapper
) {
captor = kidnapper;
} else {
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected but captor {} does not implement ICaptor. Clearing captivity.",
player.getName().getString(),
captorUUID.toString().substring(0, 8)
);
cap.clearSavedCaptor();
return;
}
// Check if player is still enslavable (still tied up)
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null || !state.isTiedUp()) {
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected but is no longer tied up. Clearing saved captor.",
player.getName().getString()
);
cap.clearSavedCaptor();
return;
}
// Restore captivity using the PlayerCaptivity component
boolean success = state.getCapturedBy(captor);
if (success) {
TiedUpMod.LOGGER.info(
"[Lifecycle] {} reconnected. Restored captivity to {}.",
player.getName().getString(),
captorEntity.getName().getString()
);
} else {
TiedUpMod.LOGGER.warn(
"[Lifecycle] {} reconnected but failed to restore captivity to {}.",
player.getName().getString(),
captorEntity.getName().getString()
);
}
// Clear saved data (no longer needed)
cap.clearSavedCaptor();
});
}
/**
* Called when a player logs out of the server.
* Clean up their PlayerBindState instance.
*/
@SubscribeEvent
public static void onPlayerLoggedOut(
PlayerEvent.PlayerLoggedOutEvent event
) {
if (!(event.getEntity() instanceof ServerPlayer player)) {
return;
}
// NOTE: PlayerBindState.removeInstance() is NOT called here.
// The canonical removal point is PlayerLifecycleHandler.onPlayerLoggedOut (EventPriority.HIGH).
// Calling it here at NORMAL priority would be a redundant double-removal.
// Clean up message cooldowns to prevent memory leak
BondageItemRestrictionHandler.clearCooldowns(player.getUUID());
// Clean up pending death escape message flag (player disconnected between death and respawn)
pendingDeathEscapeMessage.remove(player.getUUID());
}
/**
* Called when a player dies.
* Handle bondage state cleanup based on game rules.
*/
@SubscribeEvent
public static void onPlayerDeath(LivingDeathEvent event) {
if (!(event.getEntity() instanceof ServerPlayer player)) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state != null) {
state.onDeathKidnapped(player.level());
}
// Clean up captivity state - prisoner can't continue task after death
PrisonerManager manager = PrisonerManager.get(player.serverLevel());
PrisonerRecord record = manager.getRecord(player.getUUID());
LaborRecord laborRecord = manager.getLaborRecord(player.getUUID());
if (record.isImprisoned() || laborRecord.hasTask()) {
// SECURITY: Remove labor tools before death to prevent item duplication
// (Tools would otherwise drop on death)
removeLaborToolsOnDeath(player);
// Transition to FREE state via escape (clears all data)
long currentTime = player.serverLevel().getGameTime();
// Use centralized escape service for complete cleanup
com.tiedup.remake.prison.service.PrisonerService.get().escape(
player.serverLevel(),
player.getUUID(),
"death"
);
// Flag for respawn message
pendingDeathEscapeMessage.add(player.getUUID());
TiedUpMod.LOGGER.debug(
"[PlayerStateEventHandler] Transitioned {} to FREE state on death",
player.getName().getString()
);
} else if (record.isProtected(player.serverLevel().getGameTime())) {
// Revoke grace period on death - player can be targeted again after respawn
// Death is considered "payment" for any remaining grace
record.setProtectionExpiry(0);
}
// Release prisoner from any cells to prevent "ghost prisoner" blocking cells
// Without this, the cell still counts this player as prisoner, making it "full"
// and preventing new kidnappers from using it
CellRegistryV2 cellRegistry = CellRegistryV2.get(player.server);
if (cellRegistry != null) {
int released = cellRegistry.releasePrisonerFromAllCells(
player.getUUID()
);
if (released > 0) {
TiedUpMod.LOGGER.debug(
"[PlayerStateEventHandler] Released {} from {} cell(s) on death",
player.getName().getString(),
released
);
}
}
}
/**
* Called when a player respawns after death.
* Handled by PlayerEvent.Clone in CapabilityEventHandler (capability copying)
* and PlayerLoggedIn (state reset).
*
* The respawn process:
* 1. PlayerEvent.Clone -> Copy capability data from old player to new player
* 2. PlayerLoggedIn -> Reset PlayerBindState with new player entity
* 3. Sync to client
*/
@SubscribeEvent
public static void onPlayerRespawn(PlayerEvent.PlayerRespawnEvent event) {
if (!(event.getEntity() instanceof ServerPlayer player)) {
return;
}
// Get or create state for the respawned player
PlayerBindState state = PlayerBindState.getInstance(player);
if (state != null) {
state.resetNewConnection(player);
}
// Sync V2 equipment + bind state to the respawned player
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.sync(player);
PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer(
player
);
if (statePacket != null) {
ModNetwork.sendToPlayer(statePacket, player);
}
// Sync Labor HUD on respawn (restores task OR clears it if executed)
syncLaborState(player);
// Send death escape message if applicable
if (pendingDeathEscapeMessage.remove(player.getUUID())) {
player.sendSystemMessage(
net.minecraft.network.chat.Component.literal(
"You died and escaped captivity. Your items remain in the camp chest."
).withStyle(net.minecraft.ChatFormatting.YELLOW)
);
}
}
/**
* Helper to sync labor state to client.
* Sends current progress if active, or a clear packet if not.
*/
private static void syncLaborState(ServerPlayer player) {
PrisonerManager manager = PrisonerManager.get(player.serverLevel());
PrisonerRecord record = manager.getRecord(player.getUUID());
LaborRecord laborRecord = manager.getLaborRecord(player.getUUID());
if (record.isImprisoned() && laborRecord.hasTask()) {
LaborTask task = laborRecord.getTask();
ModNetwork.sendToPlayer(
new PacketSyncLaborProgress(
task.getDescription(),
task.getProgress(),
task.getQuota(),
task.getValue()
),
player
);
TiedUpMod.LOGGER.debug(
"[PlayerStateEventHandler] Synced labor HUD for {}: {}/{} {}",
player.getName().getString(),
task.getProgress(),
task.getQuota(),
task.getDescription()
);
} else {
// No active captivity state - ensure HUD is cleared (important after execution/death)
ModNetwork.sendToPlayer(new PacketSyncLaborProgress(), player);
TiedUpMod.LOGGER.debug(
"[PlayerStateEventHandler] Cleared labor HUD for {}",
player.getName().getString()
);
}
}
/**
* SECURITY: Remove all labor tools from player inventory before death.
* Prevents tools from dropping and being recoverable.
*/
private static void removeLaborToolsOnDeath(ServerPlayer player) {
var inventory = player.getInventory();
int removedCount = 0;
for (int i = 0; i < inventory.getContainerSize(); i++) {
ItemStack stack = inventory.getItem(i);
if (!stack.isEmpty() && stack.hasTag()) {
CompoundTag tag = stack.getTag();
if (tag != null && tag.getBoolean("LaborTool")) {
inventory.setItem(i, ItemStack.EMPTY);
removedCount++;
}
}
}
if (removedCount > 0) {
TiedUpMod.LOGGER.debug(
"[PlayerStateEventHandler] Removed {} labor tools from {} before death",
removedCount,
player.getName().getString()
);
}
}
}

View File

@@ -0,0 +1,548 @@
package com.tiedup.remake.events.restriction;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.items.ItemLockpick;
import com.tiedup.remake.items.base.IKnife;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.IRestrainable;
import com.tiedup.remake.dialogue.EntityDialogueManager;
import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.SystemMessageManager.MessageCategory;
import com.tiedup.remake.util.GameConstants;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.ButtonBlock;
import net.minecraft.world.level.block.DoorBlock;
import net.minecraft.world.level.block.FenceGateBlock;
import net.minecraft.world.level.block.LeverBlock;
import net.minecraft.world.level.block.TrapDoorBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.living.LivingEvent;
import net.minecraftforge.event.entity.living.LivingHurtEvent;
import net.minecraftforge.event.entity.living.LivingEntityUseItemEvent;
import net.minecraftforge.event.entity.player.AttackEntityEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.event.level.BlockEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Unified event handler for all bondage item restrictions.
*
* Phase 14.4+: Centralized restriction system
* Leg Binding: Separate arm/leg restrictions
*
* This handler manages restrictions based on equipped bondage items:
*
* === LEGS BOUND (hasLegsBound) ===
* - No sprinting
* - No climbing ladders (can descend)
* - No elytra flying
* - Reduced swim speed (50%)
*
* === ARMS BOUND (hasArmsBound) ===
* - No block breaking
* - No block placing
* - No attacking
* - No item usage
* - No block interaction (except allowed blocks)
*
* === MITTENS ===
* - Additional hand restrictions
* - Allowed: buttons, levers, doors, trapdoors, fence gates (when arms bound only)
*
* === BLINDFOLDED ===
* - Vision effects (handled client-side)
*
* === GAGGED ===
* - Chat muffling (handled in ChatEventHandler)
*
* @see RestraintTaskTickHandler for task tick progression (untying, tying, force feeding)
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class BondageItemRestrictionHandler {
/** Cooldown between restriction messages (in milliseconds) */
private static final long MESSAGE_COOLDOWN_MS = 2000; // 2 seconds
/** Swim speed multiplier when tied (from config) */
/** Per-player, per-category message cooldowns */
private static final Map<
UUID,
Map<MessageCategory, Long>
> messageCooldowns = new HashMap<>();
// ========================================
// MOVEMENT RESTRICTIONS
// ========================================
/**
* Movement restrictions per tick (throttled to every 5 ticks).
* - Prevent sprinting when tied
* - Prevent ladder climbing when tied (can descend)
* - Reduce swim speed when tied
*/
@SubscribeEvent
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
if (event.side.isClient() || event.phase != TickEvent.Phase.END) {
return;
}
Player player = event.player;
// Throttle: only check every 5 ticks (0.25 seconds) - per-player timing
if (player.tickCount % 5 != 0) {
return;
}
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
// Movement restrictions only apply when LEGS are bound
if (!state.hasLegsBound()) return;
// === SPRINT RESTRICTION ===
if (player.isSprinting()) {
player.setSprinting(false);
}
// === LADDER RESTRICTION ===
// Can descend but not climb
if (player.onClimbable()) {
Vec3 motion = player.getDeltaMovement();
if (motion.y > 0) {
player.setDeltaMovement(motion.x, 0, motion.z);
}
}
// === SWIM SPEED RESTRICTION ===
if (player.isInWater() && player.isSwimming()) {
Vec3 motion = player.getDeltaMovement();
player.setDeltaMovement(motion.scale(
com.tiedup.remake.core.ModConfig.SERVER.tiedSwimSpeedMultiplier.get()
));
}
}
/**
* Prevent elytra flying when tied.
*/
@SubscribeEvent
public static void onLivingTick(LivingEvent.LivingTickEvent event) {
if (!(event.getEntity() instanceof Player player)) return;
if (player.level().isClientSide) return;
// Cheap check FIRST: only proceed if player is flying
if (!player.isFallFlying()) return;
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null || !state.hasLegsBound()) return;
// Elytra restriction only applies when LEGS are bound
player.stopFallFlying();
sendRestrictionMessage(player, MessageCategory.NO_ELYTRA);
}
// ========================================
// INTERACTION RESTRICTIONS
// ========================================
/**
* Block breaking restrictions.
* - Tied: Cannot break blocks
* - Mittens: Cannot break blocks (hands covered)
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onBlockBreak(BlockEvent.BreakEvent event) {
Player player = event.getPlayer();
if (player == null) return;
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
// Block breaking requires ARMS to be free
if (state.hasArmsBound()) {
event.setCanceled(true);
sendRestrictionMessage(player, MessageCategory.CANT_BREAK_TIED);
} else if (state.hasMittens()) {
event.setCanceled(true);
sendRestrictionMessage(player, MessageCategory.CANT_BREAK_MITTENS);
}
}
/**
* Block placing restrictions.
* - Arms bound: Cannot place blocks
* - Mittens: Cannot place blocks (hands covered)
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onBlockPlace(BlockEvent.EntityPlaceEvent event) {
if (!(event.getEntity() instanceof Player player)) return;
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
// Block placing requires ARMS to be free
if (state.hasArmsBound()) {
event.setCanceled(true);
sendRestrictionMessage(player, MessageCategory.CANT_PLACE_TIED);
} else if (state.hasMittens()) {
event.setCanceled(true);
sendRestrictionMessage(player, MessageCategory.CANT_PLACE_MITTENS);
}
}
/**
* Block interaction restrictions.
* - Arms bound: Cannot interact with most blocks
* - Mittens: Cannot interact (hands covered)
* - Exception: Buttons, levers, doors (can be pressed/opened with body when arms bound, but not with mittens)
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onRightClickBlock(
PlayerInteractEvent.RightClickBlock event
) {
Player player = event.getEntity();
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
boolean hasArmsBound = state.hasArmsBound();
boolean hasMittens = state.hasMittens();
if (!hasArmsBound && !hasMittens) return;
// Get the block being interacted with
BlockState blockState = event.getLevel().getBlockState(event.getPos());
// Allow specific block interactions (can press with body/head) - only when arms bound WITHOUT mittens
// Mittens block ALL interactions since you can't use buttons with covered hands
if (
hasArmsBound && !hasMittens && isAllowedTiedInteraction(blockState)
) {
return; // Allow this interaction
}
// Block all other interactions
event.setCanceled(true);
if (hasArmsBound) {
sendRestrictionMessage(player, MessageCategory.CANT_INTERACT_TIED);
} else {
sendRestrictionMessage(
player,
MessageCategory.CANT_INTERACT_MITTENS
);
}
}
/**
* Item usage restrictions.
* - Arms bound: Cannot use items (except knife/lockpick without mittens)
* - Mittens: Cannot use items (hands covered)
*
* v2.5: Allow knife and lockpick usage when tied (but NOT with mittens).
* This enables the player to cut/lockpick their binds while restrained.
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onRightClickItem(
PlayerInteractEvent.RightClickItem event
) {
Player player = event.getEntity();
ItemStack heldItem = event.getItemStack();
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
// v2.5: Allow knife and lockpick usage when tied (but NOT with mittens)
if (state.hasArmsBound() && !state.hasMittens()) {
// Allow knives - they have special cutting logic
if (heldItem.getItem() instanceof IKnife) {
return; // Don't block
}
// Allow lockpicks - they open the struggle choice screen
if (heldItem.getItem() instanceof ItemLockpick) {
return; // Don't block
}
}
// Item usage requires ARMS to be free (except above exceptions)
if (state.hasArmsBound()) {
event.setCanceled(true);
sendRestrictionMessage(player, MessageCategory.CANT_USE_ITEM_TIED);
} else if (state.hasMittens()) {
event.setCanceled(true);
sendRestrictionMessage(
player,
MessageCategory.CANT_USE_ITEM_MITTENS
);
}
}
/**
* Attack restrictions.
* - Captive/slave attacking their kidnapper: Cannot attack, gets shocked
* - Arms bound: Cannot attack at all
* - Mittens only: Can punch but damage is zeroed in onLivingHurt()
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onAttack(AttackEntityEvent event) {
Player player = event.getEntity();
Entity target = event.getTarget();
// Check if player is attacking a kidnapper
if (target instanceof EntityKidnapper kidnapper) {
// Check if the attacker is this kidnapper's captive or job worker
boolean isThisCaptive = false;
boolean isThisJobWorker = false;
// Check if player is the current captive
IRestrainable captive = kidnapper.getCaptive();
if (captive != null) {
UUID captiveUUID = captive.getKidnappedUniqueId();
if (
captiveUUID != null && captiveUUID.equals(player.getUUID())
) {
isThisCaptive = true;
}
}
// Check if player is the job worker
UUID workerUUID = kidnapper.getJobWorkerUUID();
if (workerUUID != null && workerUUID.equals(player.getUUID())) {
isThisJobWorker = true;
}
// Only block if player is THIS kidnapper's captive or job worker
if (isThisCaptive || isThisJobWorker) {
event.setCanceled(true);
// Kidnapper responds with dialogue
kidnapper.talkTo(
player,
DialogueCategory.ATTACK_SLAVE
);
// Shock the captive/worker
IRestrainable playerState = KidnappedHelper.getKidnappedState(
player
);
if (playerState != null) {
playerState.shockKidnapped(
" (You cannot attack your master!)",
2.0f
);
}
TiedUpMod.LOGGER.debug(
"[RESTRICTION] {} tried to attack master {} - shocked (captive={}, worker={})",
player.getName().getString(),
kidnapper.getName().getString(),
isThisCaptive,
isThisJobWorker
);
return;
}
// Other players CAN attack this kidnapper - don't block
}
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
// Arms bound: cannot attack at all
if (state.hasArmsBound()) {
event.setCanceled(true);
return;
}
// Mittens only (not arms bound): allow punch animation
// Damage will be zeroed in onLivingHurt()
}
/**
* Damage reduction for mittens.
* When a player with mittens (but not arms bound) attacks, damage is reduced to 0.
* The punch animation still plays, but no damage is dealt.
*/
@SubscribeEvent(priority = EventPriority.HIGHEST)
public static void onLivingHurt(LivingHurtEvent event) {
// Check if the attacker is a player with mittens
if (!(event.getSource().getEntity() instanceof Player player)) return;
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
// Mittens only (not arms bound): zero damage
if (state.hasMittens() && !state.hasArmsBound()) {
event.setAmount(0);
}
}
/**
* Fall damage protection for captive players on a leash.
* When being led by a kidnapper/master, the player has no control over movement
* and should not take fall damage from terrain the captor drags them through.
*/
@SubscribeEvent(priority = EventPriority.HIGHEST)
public static void onCaptiveFallDamage(LivingHurtEvent event) {
if (!(event.getEntity() instanceof Player player)) return;
if (
!event
.getSource()
.is(net.minecraft.world.damagesource.DamageTypes.FALL)
) return;
IRestrainable state = KidnappedHelper.getKidnappedState(player);
if (state == null) return;
if (state.isCaptive()) {
event.setCanceled(true);
}
}
/**
* Notify the Master when their pet takes damage.
* This allows the Master to react (e.g., get up from human chair).
*/
@SubscribeEvent
public static void onPetHurt(LivingHurtEvent event) {
if (!(event.getEntity() instanceof ServerPlayer pet)) return;
if (event.getAmount() <= 0) return;
// Find if this player has a nearby Master who owns them
var masters = pet.level().getEntitiesOfClass(
com.tiedup.remake.entities.EntityMaster.class,
pet.getBoundingBox().inflate(32.0),
m -> m.isAlive() && m.hasPet()
&& pet.getUUID().equals(m.getStateManager().getPetPlayerUUID())
);
for (var master : masters) {
master.onPetHurt(event.getSource(), event.getAmount());
}
}
// ========== BREAK SPEED REDUCTION ==========
/**
* Apply break speed reduction when tied up.
* This makes mining extremely slow (10% speed) when restrained.
*/
@SubscribeEvent
public static void onCalculateSpeed(PlayerEvent.BreakSpeed event) {
Player player = event.getEntity();
IBondageState state = KidnappedHelper.getKidnappedState(player);
if (state != null && state.isTiedUp()) {
event.setNewSpeed(
event.getNewSpeed() * GameConstants.TIED_BREAK_SPEED_MULTIPLIER
);
}
}
// ========== BLOCK SELF-EATING WHEN GAGGED ==========
/**
* Block gagged players from eating food themselves.
*/
@SubscribeEvent
public static void onGaggedPlayerEat(LivingEntityUseItemEvent.Start event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
if (!event.getItem().getItem().isEdible()) {
return;
}
IBondageState state = KidnappedHelper.getKidnappedState(player);
if (state != null && state.isGagged()) {
event.setCanceled(true);
player.displayClientMessage(
Component.literal("You can't eat with a gag on.").withStyle(
ChatFormatting.RED
),
true
);
}
}
// ========================================
// HELPER METHODS
// ========================================
/**
* Check if a block interaction should be allowed when tied.
* These blocks can be activated with body/head, not hands.
*/
private static boolean isAllowedTiedInteraction(BlockState blockState) {
// Buttons - can press with head/body
if (blockState.getBlock() instanceof ButtonBlock) return true;
// Levers - can push with body
if (blockState.getBlock() instanceof LeverBlock) return true;
// Doors - can push open with body
if (blockState.getBlock() instanceof DoorBlock) return true;
// Trapdoors - debatable, but allow for now
if (blockState.getBlock() instanceof TrapDoorBlock) return true;
// Fence gates - can push open
if (blockState.getBlock() instanceof FenceGateBlock) return true;
return false;
}
/**
* Send a restriction message to the player.
* Uses per-category cooldown to prevent spam.
*/
private static void sendRestrictionMessage(
Player player,
MessageCategory category
) {
// Only send on server side
if (player.level().isClientSide) return;
UUID playerId = player.getUUID();
long now = System.currentTimeMillis();
// Get or create player's cooldown map
Map<MessageCategory, Long> playerCooldowns =
messageCooldowns.computeIfAbsent(playerId, k -> new HashMap<>());
// Check cooldown for this category
Long lastSent = playerCooldowns.get(category);
if (lastSent != null && (now - lastSent) < MESSAGE_COOLDOWN_MS) {
return; // Still on cooldown
}
// Update cooldown and send message
playerCooldowns.put(category, now);
SystemMessageManager.sendRestriction(player, category);
}
/**
* Clean up cooldowns for a player (call when they disconnect).
*/
public static void clearCooldowns(UUID playerId) {
messageCooldowns.remove(playerId);
}
}

View File

@@ -0,0 +1,154 @@
package com.tiedup.remake.events.restriction;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityLaborGuard;
import com.tiedup.remake.prison.LaborRecord;
import com.tiedup.remake.prison.PrisonerManager;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.jetbrains.annotations.Nullable;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.ShulkerBoxBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraftforge.event.entity.item.ItemTossEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for labor tool protection.
*
* Prevents prisoners from:
* - Dropping labor tools
* - Storing labor tools in containers
*
* Note: Death handling removed - if prisoner dies, they're no longer working anyway.
*
* This is extracted from CampLaborEventHandler after the refactoring to remove event-driven task tracking.
* Only the essential protection mechanisms remain.
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class LaborToolProtectionHandler {
private static final Map<UUID, Long> lastDropWarningTick = new HashMap<>();
private static final long DROP_WARNING_COOLDOWN = 60; // 3 seconds
/** Remove player entry on disconnect to prevent memory leak. */
public static void cleanupPlayer(java.util.UUID playerId) {
lastDropWarningTick.remove(playerId);
}
/**
* Prevent dropping labor tools.
* Forge removes the item from inventory before firing ItemTossEvent,
* so we must restore it after canceling.
*/
@SubscribeEvent
public static void onItemToss(ItemTossEvent event) {
ItemStack stack = event.getEntity().getItem();
if (!isLaborTool(stack)) return;
// Cancel + restore (Forge removes from inventory before event fires)
event.setCanceled(true);
event.getPlayer().getInventory().add(stack);
Player player = event.getPlayer();
if (!(player instanceof ServerPlayer serverPlayer)) return;
if (!(player.level() instanceof ServerLevel level)) return;
// Cooldown
long tick = level.getGameTime();
Long last = lastDropWarningTick.get(player.getUUID());
if (last != null && (tick - last) < DROP_WARNING_COOLDOWN) return;
lastDropWarningTick.put(player.getUUID(), tick);
// Guard reaction
EntityLaborGuard guard = findGuardForPlayer(level, player.getUUID());
if (guard != null && guard.isAlive()) {
guard.getLookControl().setLookAt(player);
guard.guardSay(
serverPlayer,
"guard.labor.drop_tool",
"Stupid! Don't drop your tools!"
);
} else {
player.displayClientMessage(
Component.literal("You cannot drop labor tools!"),
true
);
}
}
/**
* Prevent storing labor tools in containers.
*/
@SubscribeEvent
public static void onRightClickBlock(
PlayerInteractEvent.RightClickBlock event
) {
Player player = event.getEntity();
ItemStack held = player.getMainHandItem();
if (!isLaborTool(held)) return;
BlockState state = event.getLevel().getBlockState(event.getPos());
if (
!(state.getBlock() instanceof ChestBlock) &&
!(state.getBlock() instanceof ShulkerBoxBlock)
) return;
event.setCanceled(true);
if (
player instanceof ServerPlayer sp &&
player.level() instanceof ServerLevel sl
) {
// Reuse same cooldown
long tick = sl.getGameTime();
Long last = lastDropWarningTick.get(player.getUUID());
if (last != null && (tick - last) < DROP_WARNING_COOLDOWN) return;
lastDropWarningTick.put(player.getUUID(), tick);
EntityLaborGuard guard = findGuardForPlayer(sl, player.getUUID());
if (guard != null && guard.isAlive()) {
guard.getLookControl().setLookAt(player);
guard.guardSay(
sp,
"guard.labor.hide_tool",
"Don't try to hide your tools!"
);
} else {
player.displayClientMessage(
Component.literal("You cannot store labor tools!"),
true
);
}
}
}
@Nullable
private static EntityLaborGuard findGuardForPlayer(
ServerLevel level,
UUID playerUUID
) {
PrisonerManager manager = PrisonerManager.get(level);
LaborRecord labor = manager.getLaborRecord(playerUUID);
UUID guardId = labor.getGuardId();
if (guardId == null) return null;
net.minecraft.world.entity.Entity entity = level.getEntity(guardId);
if (entity instanceof EntityLaborGuard guard) return guard;
return null;
}
/**
* Check if an item is a labor tool (tagged as LaborTool).
*/
private static boolean isLaborTool(ItemStack stack) {
return stack.hasTag() && stack.getTag().getBoolean("LaborTool");
}
}

View File

@@ -0,0 +1,349 @@
package com.tiedup.remake.events.restriction;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.items.ItemChokeCollar;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.blocks.PetBedManager;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.BedBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.event.entity.player.PlayerSleepInBedEvent;
import net.minecraftforge.eventbus.api.Event;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for pet play restrictions.
*
* When a player has a pet play collar (from EntityMaster):
* - Cannot eat food from hand (must use Bowl block)
* - Cannot sleep in normal beds (must use Pet Bed block)
*
* Placeholder blocks:
* - Bowl: Cauldron (shift+right-click with food to eat)
* - Pet Bed: White carpet (shift+right-click to sleep)
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class PetPlayRestrictionHandler {
/** Message cooldown in milliseconds */
private static final long MESSAGE_COOLDOWN_MS = 3000;
/** Last message time per player */
private static final java.util.Map<java.util.UUID, Long> lastMessageTime =
new java.util.HashMap<>();
// ========================================
// EATING RESTRICTION
// ========================================
/**
* Prevent pet play players from eating food from hand.
* They must use a Bowl block (cauldron placeholder).
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onRightClickItem(
PlayerInteractEvent.RightClickItem event
) {
Player player = event.getEntity();
if (player.level().isClientSide) return;
// Check for pet play collar
if (!EntityMaster.hasPetCollar(player)) return;
ItemStack heldItem = event.getItemStack();
// Check if trying to eat food
if (heldItem.isEdible()) {
event.setCanceled(true);
event.setCancellationResult(
net.minecraft.world.InteractionResult.FAIL
);
sendThrottledMessage(
player,
"You cannot eat from your hand! Use a bowl."
);
TiedUpMod.LOGGER.debug(
"[PetPlayRestrictionHandler] Blocked {} from eating food directly",
player.getName().getString()
);
}
}
/**
* Allow eating from Bowl block (cauldron placeholder).
* Shift+right-click with food on cauldron to eat.
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onRightClickBlock(
PlayerInteractEvent.RightClickBlock event
) {
Player player = event.getEntity();
if (player.level().isClientSide) return;
// Check for pet play collar
if (!EntityMaster.hasPetCollar(player)) return;
BlockPos pos = event.getPos();
BlockState state = player.level().getBlockState(pos);
ItemStack heldItem = event.getItemStack();
// Bowl interaction (cauldron placeholder)
if (
state.getBlock() == Blocks.CAULDRON &&
player.isShiftKeyDown() &&
heldItem.isEdible()
) {
// Allow eating from bowl
// Consume the food
if (player instanceof ServerPlayer serverPlayer) {
net.minecraft.world.food.FoodProperties food = heldItem
.getItem()
.getFoodProperties();
if (food != null) {
serverPlayer
.getFoodData()
.eat(food.getNutrition(), food.getSaturationModifier());
// Shrink the item
heldItem.shrink(1);
// Play eating sound
player
.level()
.playSound(
null,
pos,
net.minecraft.sounds.SoundEvents.GENERIC_EAT,
net.minecraft.sounds.SoundSource.PLAYERS,
1.0f,
1.0f
);
TiedUpMod.LOGGER.debug(
"[PetPlayRestrictionHandler] {} ate from bowl",
player.getName().getString()
);
// Cancel to prevent normal cauldron interaction
event.setCanceled(true);
event.setCancellationResult(
net.minecraft.world.InteractionResult.SUCCESS
);
}
}
}
// Pet bed interaction (carpet placeholder) - handled in sleep event
}
// ========================================
// SLEEPING RESTRICTION
// ========================================
/**
* Prevent pet play players from sleeping in normal beds.
* They must use a Pet Bed block (carpet placeholder).
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onSleepInBed(PlayerSleepInBedEvent event) {
Player player = event.getEntity();
if (player.level().isClientSide) return;
// Check for pet play collar
if (!EntityMaster.hasPetCollar(player)) return;
// Block sleeping in beds
BlockState state = player.level().getBlockState(event.getPos());
if (state.getBlock() instanceof BedBlock) {
event.setResult(Player.BedSleepingProblem.OTHER_PROBLEM);
sendThrottledMessage(
player,
"You cannot sleep in a bed! Use your pet bed."
);
TiedUpMod.LOGGER.debug(
"[PetPlayRestrictionHandler] Blocked {} from sleeping in bed",
player.getName().getString()
);
}
}
/**
* Allow sleeping on Pet Bed block (carpet placeholder).
* Shift+right-click on white carpet to sleep.
*/
@SubscribeEvent(priority = EventPriority.NORMAL)
public static void onInteractForSleep(
PlayerInteractEvent.RightClickBlock event
) {
Player player = event.getEntity();
if (player.level().isClientSide) return;
// Check for pet play collar
if (!EntityMaster.hasPetCollar(player)) return;
BlockPos pos = event.getPos();
BlockState state = player.level().getBlockState(pos);
// Pet bed interaction (white carpet placeholder)
if (
state.getBlock() == Blocks.WHITE_CARPET && player.isShiftKeyDown()
) {
if (player instanceof ServerPlayer serverPlayer) {
// Try to sleep
Player.BedSleepingProblem problem = canSleepNow(serverPlayer);
if (problem == null) {
// Set spawn point to pet bed location
serverPlayer.setRespawnPosition(
serverPlayer.level().dimension(),
pos,
serverPlayer.getYRot(),
false,
true
);
// Start sleeping (simplified - real implementation would need proper sleep mechanics)
serverPlayer.sendSystemMessage(
Component.literal(
"You curl up in your pet bed..."
).withStyle(Style.EMPTY.withColor(0x888888))
);
// Apply sleep effects (heal, skip night handled elsewhere)
serverPlayer.heal(2.0f);
TiedUpMod.LOGGER.debug(
"[PetPlayRestrictionHandler] {} slept in pet bed",
player.getName().getString()
);
event.setCanceled(true);
event.setCancellationResult(
net.minecraft.world.InteractionResult.SUCCESS
);
} else {
sendThrottledMessage(
player,
"You can only sleep at night or during thunderstorms."
);
}
}
}
}
/**
* Check if player can sleep now (time/weather check).
*/
private static Player.BedSleepingProblem canSleepNow(ServerPlayer player) {
// Check time of day
if (player.level().isDay() && !player.level().isThundering()) {
return Player.BedSleepingProblem.NOT_POSSIBLE_NOW;
}
return null;
}
// ========================================
// CHOKE COLLAR EFFECT
// ========================================
/**
* Apply choke collar effect on player tick.
* When choking is active, rapidly reduces air supply to cause drowning damage.
*/
@SubscribeEvent
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
// Only process on server side, at END phase
if (event.phase != TickEvent.Phase.END) return;
if (event.player.level().isClientSide) return;
if (!(event.player instanceof ServerPlayer player)) return;
// Check pet bed sit cancellation (movement detection)
PetBedManager.tickPlayer(player);
// Check pet cage validity
com.tiedup.remake.v2.blocks.PetCageManager.tickPlayer(player);
// Get player's collar
PlayerBindState bindState = PlayerBindState.getInstance(player);
if (bindState == null || !bindState.hasCollar()) return;
ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK);
if (collar.getItem() instanceof ItemChokeCollar chokeCollar) {
if (chokeCollar.isChoking(collar)) {
// Apply ChokeEffect (short duration, re-applied each active tick)
if (
!player.hasEffect(
com.tiedup.remake.core.ModEffects.CHOKE.get()
)
) {
player.addEffect(
new MobEffectInstance(
com.tiedup.remake.core.ModEffects.CHOKE.get(),
40,
0,
false,
false,
false
)
);
}
} else {
// Remove effect if choke is deactivated
player.removeEffect(
com.tiedup.remake.core.ModEffects.CHOKE.get()
);
}
}
}
// ========================================
// UTILITY
// ========================================
/**
* BUG FIX: Clean up player data to prevent memory leak.
* Called on player logout.
*/
public static void clearPlayer(java.util.UUID playerId) {
lastMessageTime.remove(playerId);
}
/**
* Send a message with cooldown to prevent spam.
*/
private static void sendThrottledMessage(Player player, String message) {
long now = System.currentTimeMillis();
Long lastTime = lastMessageTime.get(player.getUUID());
if (lastTime == null || now - lastTime > MESSAGE_COOLDOWN_MS) {
lastMessageTime.put(player.getUUID(), now);
player.sendSystemMessage(
Component.literal(message).withStyle(
Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR)
)
);
}
}
}

View File

@@ -0,0 +1,672 @@
package com.tiedup.remake.events.restriction;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.minigame.StruggleSessionManager;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.personality.PacketSlaveBeingFreed;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.tasks.ForceFeedingTask;
import com.tiedup.remake.tasks.TimedInteractTask;
import com.tiedup.remake.tasks.UntyingPlayerTask;
import com.tiedup.remake.tasks.UntyingTask;
import com.tiedup.remake.util.GameConstants;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.core.SettingsAccessor;
import java.util.List;
import java.util.UUID;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Tick handler for restraint-related tasks (untying, tying, force feeding).
*
* Manages progress-based interaction tasks that span multiple ticks:
* - Untying mechanic (empty hand right-click on tied entity)
* - Tying mechanic (tick progression)
* - Force feeding mechanic (food right-click on gagged entity)
* - Auto-shock collar checks
* - Struggle auto-stop (legacy QTE fallback)
*
* @see BondageItemRestrictionHandler for movement, interaction, and eating restrictions
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class RestraintTaskTickHandler {
// ========== PLAYER-SPECIFIC TICK ==========
/**
* Handle player tick event for player-specific features.
* - Auto-shock collar check (throttled to every N ticks)
*/
@SubscribeEvent
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
if (event.side.isClient() || event.phase != TickEvent.Phase.END) {
return;
}
Player player = event.player;
PlayerBindState playerState = PlayerBindState.getInstance(player);
// Check if struggle animation should stop
// For continuous struggle: animation is managed by MiniGameSessionManager
// Only auto-stop if NO active continuous session (legacy QTE fallback)
if (playerState != null && playerState.isStruggling()) {
// Don't auto-stop if there's an active continuous struggle session
StruggleSessionManager mgr = StruggleSessionManager.getInstance();
if (mgr.getContinuousStruggleSession(player.getUUID()) == null) {
// Legacy behavior: stop after 80 ticks (no active continuous session)
if (
playerState.shouldStopStruggling(
player.level().getGameTime()
)
) {
playerState.setStruggling(false, 0);
com.tiedup.remake.network.sync.SyncManager.syncStruggleState(
player
);
}
}
}
// Process untying task tick (progress-based system)
// tick() increments/decrements progress based on whether update() was called this tick
// sendProgressPackets() updates the UI for both players
if (playerState != null) {
com.tiedup.remake.tasks.UntyingTask currentUntyingTask =
playerState.getCurrentUntyingTask();
if (currentUntyingTask != null && !currentUntyingTask.isStopped()) {
// AUTO-UPDATE: Check if player is still targeting the same entity
// This allows "hold click" behavior without needing repeated interactLivingEntity calls
if (
currentUntyingTask instanceof
com.tiedup.remake.tasks.UntyingPlayerTask untyingPlayerTask
) {
net.minecraft.world.entity.LivingEntity target =
untyingPlayerTask.getTargetEntity();
if (target != null && target.isAlive()) {
// Check if player is looking at target and close enough
double distance = player.distanceTo(target);
boolean isLookingAtTarget = isPlayerLookingAtEntity(
player,
target,
4.0
);
if (
distance <= 4.0 &&
isLookingAtTarget &&
player.hasLineOfSight(target)
) {
// Player is still targeting - auto-update the task
currentUntyingTask.update();
}
}
}
// Process tick (increment if active, decrement if not)
currentUntyingTask.tick();
// Send progress packets to update UI
currentUntyingTask.sendProgressPackets();
// Check if task stopped (completed or cancelled due to no progress)
if (currentUntyingTask.isStopped()) {
playerState.setCurrentUntyingTask(null);
TiedUpMod.LOGGER.debug(
"[RESTRAINT] {} untying task ended (tick update)",
player.getName().getString()
);
}
}
// Process tying task tick (same progress-based system)
com.tiedup.remake.tasks.TyingTask currentTyingTask =
playerState.getCurrentTyingTask();
if (currentTyingTask != null && !currentTyingTask.isStopped()) {
// AUTO-UPDATE: Check if player is still targeting the same entity
// This allows "hold click" behavior without needing repeated interactLivingEntity calls
if (
currentTyingTask instanceof
com.tiedup.remake.tasks.TyingPlayerTask tyingPlayerTask
) {
net.minecraft.world.entity.LivingEntity target =
tyingPlayerTask.getTargetEntity();
boolean isSelfTying =
target != null && target.equals(player);
if (isSelfTying) {
// Self-tying: skip look-at/distance checks (player can't raycast to own hitbox)
// Progress is driven by continuous PacketSelfBondage packets from client
currentTyingTask.update();
} else if (target != null && target.isAlive()) {
// Tying another player: check distance + line of sight
double distance = player.distanceTo(target);
boolean isLookingAtTarget = isPlayerLookingAtEntity(
player,
target,
4.0
);
if (
distance <= 4.0 &&
isLookingAtTarget &&
player.hasLineOfSight(target)
) {
currentTyingTask.update();
}
}
}
// Process tick (increment if active, decrement if not)
currentTyingTask.tick();
// Send progress packets to update UI
currentTyingTask.sendProgressPackets();
// Check if task stopped (completed or cancelled due to no progress)
if (currentTyingTask.isStopped()) {
playerState.setCurrentTyingTask(null);
TiedUpMod.LOGGER.debug(
"[RESTRAINT] {} tying task ended (tick update)",
player.getName().getString()
);
}
}
// Process force feeding task tick
TimedInteractTask feedingTask = playerState.getCurrentFeedingTask();
if (feedingTask != null && !feedingTask.isStopped()) {
LivingEntity target = feedingTask.getTargetEntity();
if (target != null && target.isAlive()) {
double distance = player.distanceTo(target);
boolean isLookingAtTarget = isPlayerLookingAtEntity(
player,
target,
4.0
);
if (
distance <= 4.0 &&
isLookingAtTarget &&
player.hasLineOfSight(target)
) {
feedingTask.update();
}
}
feedingTask.tick();
feedingTask.sendProgressPackets();
if (feedingTask.isStopped()) {
playerState.setCurrentFeedingTask(null);
TiedUpMod.LOGGER.debug(
"[RESTRAINT] {} feeding task ended (tick update)",
player.getName().getString()
);
}
}
}
// Throttle: only check every N ticks (configurable via GameConstants) - per-player timing
if (player.tickCount % GameConstants.SHOCK_COLLAR_CHECK_INTERVAL != 0) {
return;
}
// Phase 13: Auto-shock collar logic (Player-specific feature)
if (playerState != null) {
playerState.checkAutoShockCollar();
}
}
// ========== UNTYING MECHANIC ==========
/**
* Handle untying a tied entity (right-click with empty hand).
*
* Based on original PlayerKidnapActionsHandler.onUntyingTarget() (1.12.2)
*
* When a player right-clicks a tied entity (player or NPC) with an empty hand,
* starts or continues an untying task to free them.
*/
@SubscribeEvent
public static void onUntyingTarget(
PlayerInteractEvent.EntityInteract event
) {
// Only run on server side
if (event.getLevel().isClientSide) {
return;
}
Entity target = event.getTarget();
Player helper = event.getEntity();
// Must be targeting a LivingEntity, using main hand, and have empty hand
if (
!(target instanceof LivingEntity targetEntity) ||
event.getHand() != InteractionHand.MAIN_HAND ||
!helper.getMainHandItem().isEmpty()
) {
return;
}
// MCA villagers require Shift+click to untie (prevents conflict with MCA menu)
if (
com.tiedup.remake.compat.mca.MCACompat.isMCALoaded() &&
com.tiedup.remake.compat.mca.MCACompat.isMCAVillager(
targetEntity
) &&
!helper.isShiftKeyDown()
) {
return;
}
// Check if target is tied using IBondageState interface
IBondageState targetState = KidnappedHelper.getKidnappedState(
targetEntity
);
if (targetState == null || !targetState.isTiedUp()) {
return;
}
// ========================================
// SECURITY: Distance and line-of-sight validation
// ========================================
double maxUntieDistance = 4.0; // Max distance to untie (blocks)
double distance = helper.distanceTo(targetEntity);
if (distance > maxUntieDistance) {
TiedUpMod.LOGGER.warn(
"[RESTRAINT] {} tried to untie {} from too far away ({} blocks)",
helper.getName().getString(),
targetEntity.getName().getString(),
String.format("%.1f", distance)
);
return;
}
// Check line-of-sight (helper must be able to see target)
if (!helper.hasLineOfSight(targetEntity)) {
TiedUpMod.LOGGER.warn(
"[RESTRAINT] {} tried to untie {} without line of sight",
helper.getName().getString(),
targetEntity.getName().getString()
);
return;
}
// Check for Kidnapper fight back - block untying if Kidnapper is nearby
if (targetEntity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc) {
if (
npc.getCaptor() instanceof EntityKidnapper kidnapper &&
kidnapper.isAlive()
) {
double distanceToKidnapper = helper.distanceTo(kidnapper);
double fightBackRange = 16.0; // Kidnapper notices within 16 blocks
if (distanceToKidnapper <= fightBackRange) {
// Trigger Kidnapper fight back by setting helper as "attacker"
// This activates KidnapperFightBackGoal which handles pursuit and attack
kidnapper.setLastAttacker(helper);
TiedUpMod.LOGGER.debug(
"[RESTRAINT] {} tried to untie {}, {} fights back!",
helper.getName().getString(),
npc.getName().getString(),
kidnapper.getName().getString()
);
// Block untying - send message to player
helper.displayClientMessage(
Component.translatable(
"tiedup.message.kidnapper_guards_captive"
),
true
);
return;
}
}
}
// Check for Kidnapper fight back - block untying if player is captive or job worker
if (targetEntity instanceof Player targetPlayer) {
List<EntityKidnapper> nearbyKidnappers = helper
.level()
.getEntitiesOfClass(
EntityKidnapper.class,
helper.getBoundingBox().inflate(16.0)
);
for (EntityKidnapper kidnapper : nearbyKidnappers) {
if (!kidnapper.isAlive()) continue;
// Check if player is kidnapper's current captive (held by leash)
IBondageState captive = kidnapper.getCaptive();
boolean isCaptive =
captive != null && captive.asLivingEntity() == targetPlayer;
// Check if player is kidnapper's job worker
UUID workerUUID = kidnapper.getJobWorkerUUID();
boolean isJobWorker =
workerUUID != null &&
workerUUID.equals(targetPlayer.getUUID());
if (isCaptive || isJobWorker) {
// Trigger Kidnapper fight back
kidnapper.setLastAttacker(helper);
TiedUpMod.LOGGER.debug(
"[RESTRAINT] {} tried to untie {} (captive={}, worker={}), {} fights back!",
helper.getName().getString(),
targetPlayer.getName().getString(),
isCaptive,
isJobWorker,
kidnapper.getNpcName()
);
// Block untying - send message to player
helper.displayClientMessage(
Component.translatable(
"tiedup.message.kidnapper_guards_captive"
),
true
);
return;
}
}
}
// Check if helper is tied using IBondageState interface
IBondageState helperKidnappedState = KidnappedHelper.getKidnappedState(
helper
);
if (helperKidnappedState == null || helperKidnappedState.isTiedUp()) {
return;
}
// Get PlayerBindState for task management (helper only)
PlayerBindState helperState = PlayerBindState.getInstance(helper);
if (helperState == null) {
return;
}
// Block untying while force feeding
TimedInteractTask activeFeedTask = helperState.getCurrentFeedingTask();
if (activeFeedTask != null && !activeFeedTask.isStopped()) {
return;
}
// Get untying duration (default: 10 seconds)
int untyingSeconds = getUntyingDuration(helper);
// Phase 11: Check collar ownership for TiedUp NPCs
// Non-owners take 3x longer and trigger alert to owners
if (targetEntity instanceof com.tiedup.remake.entities.AbstractTiedUpNpc npc) {
if (!npc.isCollarOwner(helper)) {
// Non-owner: triple the untying time
untyingSeconds *= 3;
// Alert all collar owners
alertCollarOwners(npc, helper);
TiedUpMod.LOGGER.debug(
"[RESTRAINT] Non-owner {} trying to free {} ({}s)",
helper.getName().getString(),
npc.getNpcName(),
untyingSeconds
);
}
}
// Get current untying task (if any)
UntyingTask currentTask = helperState.getCurrentUntyingTask();
// Check if we should start a new task or continue existing one
if (
currentTask == null ||
!currentTask.isSameTarget(targetEntity) ||
currentTask.isStopped()
) {
// Create new untying task (unified for Players and NPCs)
UntyingPlayerTask newTask = new UntyingPlayerTask(
targetState,
targetEntity,
untyingSeconds,
helper.level(),
helper
);
// Start new task
helperState.setCurrentUntyingTask(newTask);
newTask.setUpTargetState(); // Initialize target's restraint state
newTask.start();
currentTask = newTask;
TiedUpMod.LOGGER.debug(
"[RESTRAINT] {} started untying {} ({} seconds)",
helper.getName().getString(),
targetEntity.getName().getString(),
untyingSeconds
);
} else {
// Continue existing task - ensure helper is set
if (currentTask instanceof UntyingPlayerTask playerTask) {
playerTask.setHelper(helper);
}
}
// Mark this tick as active (progress will increase in onPlayerTick)
// The tick() method in onPlayerTick handles progress increment/decrement
currentTask.update();
}
/**
* Check if a player is looking at a specific entity (raycast).
*
* @param player The player
* @param target The target entity
* @param maxDistance Maximum distance to check
* @return true if player is looking at the target entity
*/
private static boolean isPlayerLookingAtEntity(
Player player,
net.minecraft.world.entity.LivingEntity target,
double maxDistance
) {
// Get player's look vector
net.minecraft.world.phys.Vec3 eyePos = player.getEyePosition(1.0F);
net.minecraft.world.phys.Vec3 lookVec = player.getLookAngle();
net.minecraft.world.phys.Vec3 endPos = eyePos.add(
lookVec.x * maxDistance,
lookVec.y * maxDistance,
lookVec.z * maxDistance
);
// Check if raycast hits the target entity
net.minecraft.world.phys.AABB targetBounds = target
.getBoundingBox()
.inflate(0.3);
java.util.Optional<net.minecraft.world.phys.Vec3> hit =
targetBounds.clip(eyePos, endPos);
return hit.isPresent();
}
/**
* Get the untying duration in seconds from GameRule.
*
* @param player The player (for accessing world/GameRules)
* @return Duration in seconds (default: 10)
*/
private static int getUntyingDuration(Player player) {
return SettingsAccessor.getUntyingPlayerTime(player.level().getGameRules());
}
// ========== FORCE FEEDING MECHANIC ==========
/**
* Handle force feeding a gagged entity (right-click with food).
*
* When a player right-clicks a gagged entity (player or NPC) while holding food,
* starts or continues a force feeding task.
*/
@SubscribeEvent
public static void onForceFeedingTarget(
PlayerInteractEvent.EntityInteract event
) {
if (event.getLevel().isClientSide) {
return;
}
Entity target = event.getTarget();
Player feeder = event.getEntity();
if (
!(target instanceof LivingEntity targetEntity) ||
event.getHand() != InteractionHand.MAIN_HAND
) {
return;
}
ItemStack heldItem = feeder.getMainHandItem();
if (heldItem.isEmpty() || !heldItem.getItem().isEdible()) {
return;
}
// Target must have IBondageState state and be gagged
IBondageState targetState = KidnappedHelper.getKidnappedState(
targetEntity
);
if (targetState == null || !targetState.isGagged()) {
return;
}
// Feeder must not be tied up
IBondageState feederState = KidnappedHelper.getKidnappedState(feeder);
if (feederState != null && feederState.isTiedUp()) {
return;
}
// Distance and line-of-sight validation
double distance = feeder.distanceTo(targetEntity);
if (distance > 4.0) {
return;
}
if (!feeder.hasLineOfSight(targetEntity)) {
return;
}
// Get feeder's PlayerBindState for task management
PlayerBindState feederBindState = PlayerBindState.getInstance(feeder);
if (feederBindState == null) {
return;
}
// Block feeding while untying
UntyingTask activeUntieTask = feederBindState.getCurrentUntyingTask();
if (activeUntieTask != null && !activeUntieTask.isStopped()) {
return;
}
// Get current feeding task (if any)
TimedInteractTask currentTask = feederBindState.getCurrentFeedingTask();
if (
currentTask == null ||
!currentTask.isSameTarget(targetEntity) ||
currentTask.isStopped()
) {
// Create new force feeding task (5 seconds)
ForceFeedingTask newTask = new ForceFeedingTask(
targetState,
targetEntity,
5,
feeder.level(),
feeder,
heldItem,
feeder.getInventory().selected
);
feederBindState.setCurrentFeedingTask(newTask);
newTask.setUpTargetState();
newTask.start();
currentTask = newTask;
TiedUpMod.LOGGER.debug(
"[RESTRAINT] {} started force feeding {} (5 seconds)",
feeder.getName().getString(),
targetEntity.getName().getString()
);
} else {
// Continue existing task - ensure feeder is set
if (currentTask instanceof ForceFeedingTask feedTask) {
feedTask.setFeeder(feeder);
}
}
currentTask.update();
// Cancel to prevent mobInteract (avoids instant NPC feed)
event.setCancellationResult(InteractionResult.SUCCESS);
event.setCanceled(true);
}
/**
* Alert all collar owners that someone is trying to free their slave.
* Phase 11: Multiplayer protection system
*
* @param slave The slave being freed
* @param liberator The player trying to free them
*/
private static void alertCollarOwners(
com.tiedup.remake.entities.AbstractTiedUpNpc slave,
Player liberator
) {
if (!(slave.level() instanceof ServerLevel serverLevel)) return;
ItemStack collar = slave.getEquipment(BodyRegionV2.NECK);
if (
collar.isEmpty() ||
!(collar.getItem() instanceof ItemCollar collarItem)
) {
return;
}
List<UUID> owners = collarItem.getOwners(collar);
if (owners.isEmpty()) return;
// Create alert packet
PacketSlaveBeingFreed alertPacket = new PacketSlaveBeingFreed(
slave.getNpcName(),
liberator.getName().getString(),
slave.blockPosition().getX(),
slave.blockPosition().getY(),
slave.blockPosition().getZ()
);
// Send to all online owners
for (UUID ownerUUID : owners) {
ServerPlayer owner = serverLevel
.getServer()
.getPlayerList()
.getPlayer(ownerUUID);
if (owner != null && owner != liberator) {
ModNetwork.sendToPlayer(alertPacket, owner);
}
}
}
}

View File

@@ -0,0 +1,66 @@
package com.tiedup.remake.events.system;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemPadlock;
import com.tiedup.remake.items.base.ILockable;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.AnvilUpdateEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for anvil-based padlock attachment.
*
* Phase 20: Padlock via Anvil
*
* Allows combining a bondage item (ILockable) with a Padlock in the anvil
* to make the item "lockable". A lockable item can then be locked with a Key.
*
* Flow:
* - Place ILockable item in left slot
* - Place Padlock in right slot
* - Output: Same item with lockable=true
* - Cost: 1 XP level, consumes 1 padlock
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class AnvilEventHandler {
@SubscribeEvent
public static void onAnvilUpdate(AnvilUpdateEvent event) {
ItemStack left = event.getLeft(); // Bondage item
ItemStack right = event.getRight(); // Padlock
// Skip if either slot is empty
if (left.isEmpty() || right.isEmpty()) return;
// Right slot must be a Padlock
if (!(right.getItem() instanceof ItemPadlock)) return;
// Left slot must be ILockable
if (!(left.getItem() instanceof ILockable lockable)) return;
// Check if item can have a padlock attached (tape, slime, vine, web cannot)
if (!lockable.canAttachPadlock()) {
return; // Item type cannot have padlock
}
// Item must not already have a padlock attached
if (lockable.isLockable(left)) {
return; // Already has padlock
}
// Create result: copy of left with lockable=true
ItemStack result = left.copy();
lockable.setLockable(result, true);
// Set anvil output
event.setOutput(result);
event.setCost(1); // 1 XP level cost
event.setMaterialCost(1); // Consume 1 padlock
TiedUpMod.LOGGER.debug(
"[AnvilEventHandler] Padlock attachment preview: {} + Padlock",
left.getDisplayName().getString()
);
}
}

View File

@@ -0,0 +1,147 @@
package com.tiedup.remake.events.system;
import com.tiedup.remake.bounty.Bounty;
import com.tiedup.remake.bounty.BountyManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.state.PlayerCaptorManager;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.core.SettingsAccessor;
import java.util.List;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles bounty delivery detection and player login events.
*
* Phase 17: Bounty System
*
* Detects when:
* - A hunter brings a captive near the bounty client
* - A player logs in (to return expired bounty rewards)
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class BountyDeliveryHandler {
// Check every 30 ticks (1.5 seconds) - optimized for performance
private static final int CHECK_INTERVAL = 30;
private static int tickCounter = 0;
/**
* Periodically check for bounty deliveries.
*/
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
tickCounter++;
if (tickCounter < CHECK_INTERVAL) return;
tickCounter = 0;
// Check all players
for (ServerPlayer player : event
.getServer()
.getPlayerList()
.getPlayers()) {
checkBountyDelivery(player);
}
}
/**
* Check if this player can deliver any captives to nearby bounty clients.
*/
private static void checkBountyDelivery(ServerPlayer hunter) {
// Get hunter's captor manager
PlayerBindState hunterState = PlayerBindState.getInstance(hunter);
if (hunterState == null) return;
PlayerCaptorManager captorManager = hunterState.getCaptorManager();
if (captorManager == null || !captorManager.hasCaptives()) return;
// Get bounty manager
BountyManager bountyManager = BountyManager.get(hunter.serverLevel());
List<Bounty> bounties = bountyManager.getBounties(hunter.serverLevel());
if (bounties.isEmpty()) return;
// Get delivery radius
int radius = SettingsAccessor.getBountyDeliveryRadius(
hunter.serverLevel().getGameRules()
);
double radiusSq = (double) radius * radius;
// Find nearby players using pre-maintained player list (faster than AABB query)
List<ServerPlayer> nearbyPlayers = new java.util.ArrayList<>();
for (Player p : hunter.level().players()) {
if (
p != hunter &&
p instanceof ServerPlayer sp &&
hunter.distanceToSqr(p) <= radiusSq
) {
nearbyPlayers.add(sp);
}
}
// Pre-filter captives: only ServerPlayers that are tied (O(m) instead of O(n*m))
List<ServerPlayer> validCaptives = new java.util.ArrayList<>();
for (IBondageState captive : captorManager.getCaptives()) {
LivingEntity captiveEntity = captive.asLivingEntity();
if (captiveEntity == null) continue;
if (
!(captiveEntity instanceof ServerPlayer captivePlayer)
) continue;
IBondageState captiveState = KidnappedHelper.getKidnappedState(
captivePlayer
);
if (captiveState == null || !captiveState.isTiedUp()) continue;
validCaptives.add(captivePlayer);
}
if (validCaptives.isEmpty()) return;
// Check each nearby player for potential delivery
for (ServerPlayer potentialClient : nearbyPlayers) {
for (ServerPlayer captivePlayer : validCaptives) {
// Captive must be near the client
if (
captivePlayer.distanceTo(potentialClient) > radius
) continue;
// Try to deliver
boolean delivered = bountyManager.tryDeliverCaptive(
hunter,
potentialClient,
captivePlayer
);
if (delivered) {
TiedUpMod.LOGGER.info(
"[BOUNTY] Delivery detected: {} delivered {} to {}",
hunter.getName().getString(),
captivePlayer.getName().getString(),
potentialClient.getName().getString()
);
}
}
}
}
/**
* Handle player login - return pending bounty rewards.
*/
@SubscribeEvent
public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
BountyManager manager = BountyManager.get(player.serverLevel());
manager.onPlayerJoin(player);
}
}
}

View File

@@ -0,0 +1,449 @@
package com.tiedup.remake.events.system;
import com.tiedup.remake.blocks.BlockCellCore;
import com.tiedup.remake.blocks.entity.CellCoreBlockEntity;
import com.tiedup.remake.cells.*;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.event.level.BlockEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.registries.ForgeRegistries;
/**
* Central event handler for Cell System V2 Phase 2.
*
* Handles:
* - Selection mode (block click capture for Set Spawn/Delivery/Disguise)
* - Door control (block prisoners/non-owners from cell doors)
* - Breach detection (wall break → BREACHED/COMPROMISED state)
* - Breach repair (block placed at breached position → repair)
* - Selection timeout/cancel (player tick)
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class CellV2EventHandler {
// ==================== RIGHT-CLICK BLOCK ====================
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onRightClickBlock(
PlayerInteractEvent.RightClickBlock event
) {
if (event.getLevel().isClientSide()) return;
if (!(event.getEntity() instanceof ServerPlayer player)) return;
UUID playerId = player.getUUID();
BlockPos clickedPos = event.getPos();
ServerLevel level = player.serverLevel();
// 1. Check selection mode first
CellSelectionManager.SelectionContext selection =
CellSelectionManager.getSelection(playerId);
if (selection != null) {
event.setCanceled(true);
handleSelectionClick(player, level, clickedPos, selection);
return;
}
// 2. Check door control
handleDoorControl(event, player, level, clickedPos);
}
// ==================== BLOCK BREAK ====================
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onBlockBreak(BlockEvent.BreakEvent event) {
if (!(event.getLevel() instanceof ServerLevel level)) return;
BlockPos pos = event.getPos();
Player breaker = event.getPlayer();
CellRegistryV2 registry = CellRegistryV2.get(level);
// Check if broken block is a wall of a V2 cell
UUID cellId = registry.getCellIdAtWall(pos);
if (cellId == null) return;
CellDataV2 cell = registry.getCell(cellId);
if (cell == null) return;
// Prisoners cannot break the Cell Core
if (level.getBlockState(pos).getBlock() instanceof BlockCellCore) {
if (cell.hasPrisoner(breaker.getUUID())) {
event.setCanceled(true);
breaker.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.cant_break_core"
).withStyle(ChatFormatting.RED),
true
);
return;
}
}
// Record breach
registry.addBreach(cellId, pos);
// State transitions
float breachPct = cell.getBreachPercentage();
if (breachPct > 0.30f && cell.getState() != CellState.COMPROMISED) {
cell.setState(CellState.COMPROMISED);
TiedUpMod.LOGGER.info(
"[CellV2EventHandler] Cell {} COMPROMISED ({}% breached)",
cellId.toString().substring(0, 8),
(int) (breachPct * 100)
);
} else if (cell.getState() == CellState.INTACT) {
cell.setState(CellState.BREACHED);
TiedUpMod.LOGGER.info(
"[CellV2EventHandler] Cell {} BREACHED at {}",
cellId.toString().substring(0, 8),
pos.toShortString()
);
}
// Notify owner (skip if breaker is the owner)
if (cell.getOwnerId() != null && !cell.isOwnedBy(breaker.getUUID())) {
ServerPlayer owner = level
.getServer()
.getPlayerList()
.getPlayer(cell.getOwnerId());
if (owner != null) {
String cellName =
cell.getName() != null
? cell.getName()
: "Cell " + cellId.toString().substring(0, 8);
SystemMessageManager.sendToPlayer(
owner,
SystemMessageManager.MessageCategory.CELL_BREACH
);
}
}
}
// ==================== BLOCK PLACE ====================
@SubscribeEvent
public static void onBlockPlace(BlockEvent.EntityPlaceEvent event) {
if (!(event.getLevel() instanceof ServerLevel level)) return;
BlockPos pos = event.getPos();
CellRegistryV2 registry = CellRegistryV2.get(level);
// Check if placed position is a breached wall
UUID cellId = registry.getCellIdAtBreach(pos);
if (cellId == null) return;
CellDataV2 cell = registry.getCell(cellId);
if (cell == null) return;
// Only repair if the placed block is solid
BlockState placedState = event.getPlacedBlock();
if (!placedState.isSolid()) return;
registry.repairBreach(cellId, pos);
TiedUpMod.LOGGER.debug(
"[CellV2EventHandler] Breach repaired at {} in cell {}",
pos.toShortString(),
cellId.toString().substring(0, 8)
);
// Check if all breaches are repaired
if (
cell.getBreachedPositions().isEmpty() &&
cell.getState() == CellState.BREACHED
) {
cell.setState(CellState.INTACT);
// Notify owner
if (cell.getOwnerId() != null) {
ServerPlayer owner = level
.getServer()
.getPlayerList()
.getPlayer(cell.getOwnerId());
if (owner != null) {
owner.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.breach_repaired"
).withStyle(ChatFormatting.GREEN),
true
);
}
}
TiedUpMod.LOGGER.info(
"[CellV2EventHandler] Cell {} fully repaired → INTACT",
cellId.toString().substring(0, 8)
);
}
}
// ==================== PLAYER TICK ====================
@SubscribeEvent
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (!(event.player instanceof ServerPlayer player)) return;
UUID playerId = player.getUUID();
if (!CellSelectionManager.isInSelectionMode(playerId)) return;
// Cancel on sneak
if (player.isShiftKeyDown()) {
CellSelectionManager.clearSelection(playerId);
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.selection.cancelled"
).withStyle(ChatFormatting.YELLOW),
true
);
return;
}
// Cancel on timeout or distance
if (
CellSelectionManager.shouldCancel(playerId, player.blockPosition())
) {
CellSelectionManager.clearSelection(playerId);
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.selection.cancelled"
).withStyle(ChatFormatting.YELLOW),
true
);
}
}
// ==================== SELECTION HANDLING ====================
private static void handleSelectionClick(
ServerPlayer player,
ServerLevel level,
BlockPos clickedPos,
CellSelectionManager.SelectionContext selection
) {
CellRegistryV2 registry = CellRegistryV2.get(level);
CellDataV2 cell = registry.getCell(selection.cellId);
if (cell == null) {
CellSelectionManager.clearSelection(player.getUUID());
return;
}
switch (selection.mode) {
case SET_SPAWN -> handleSetSpawn(
player,
level,
clickedPos,
cell,
selection
);
case SET_DELIVERY -> handleSetDelivery(
player,
level,
clickedPos,
cell,
selection
);
case SET_DISGUISE -> handleSetDisguise(
player,
level,
clickedPos,
cell,
selection
);
}
}
private static void handleSetSpawn(
ServerPlayer player,
ServerLevel level,
BlockPos clickedPos,
CellDataV2 cell,
CellSelectionManager.SelectionContext selection
) {
// Spawn must be inside the cell or on a wall block (e.g. floor)
boolean isInterior = cell.isContainedInCell(clickedPos);
boolean isWall = cell.isWallBlock(clickedPos);
if (!isInterior && !isWall) {
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.not_inside_cell"
).withStyle(ChatFormatting.RED),
true
);
return;
}
// If clicked on a wall (floor/ceiling/wall), spawn above it
BlockPos actualSpawn = isWall ? clickedPos.above() : clickedPos;
// Verify the actual spawn position is inside the cell
if (isWall && !cell.isContainedInCell(actualSpawn)) {
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.not_inside_cell"
).withStyle(ChatFormatting.RED),
true
);
return;
}
// Update spawn in both Core BE and CellDataV2
BlockEntity be = level.getBlockEntity(selection.corePos);
if (be instanceof CellCoreBlockEntity core) {
core.setSpawnPoint(actualSpawn);
}
cell.setSpawnPoint(actualSpawn);
CellSelectionManager.clearSelection(player.getUUID());
player.displayClientMessage(
Component.translatable("msg.tiedup.cell_core.spawn_set").withStyle(
ChatFormatting.GREEN
),
true
);
}
private static void handleSetDelivery(
ServerPlayer player,
ServerLevel level,
BlockPos clickedPos,
CellDataV2 cell,
CellSelectionManager.SelectionContext selection
) {
// Delivery must be outside the cell
if (cell.isContainedInCell(clickedPos)) {
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.must_be_outside"
).withStyle(ChatFormatting.RED),
true
);
return;
}
BlockEntity be = level.getBlockEntity(selection.corePos);
if (be instanceof CellCoreBlockEntity core) {
core.setDeliveryPoint(clickedPos);
}
cell.setDeliveryPoint(clickedPos);
CellSelectionManager.clearSelection(player.getUUID());
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.delivery_set"
).withStyle(ChatFormatting.GREEN),
true
);
}
private static void handleSetDisguise(
ServerPlayer player,
ServerLevel level,
BlockPos clickedPos,
CellDataV2 cell,
CellSelectionManager.SelectionContext selection
) {
BlockState clickedState = level.getBlockState(clickedPos);
// Must be a solid block
if (!clickedState.isSolid()) {
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.must_be_solid"
).withStyle(ChatFormatting.RED),
true
);
return;
}
BlockEntity be = level.getBlockEntity(selection.corePos);
if (be instanceof CellCoreBlockEntity core) {
core.setDisguiseState(clickedState);
}
CellSelectionManager.clearSelection(player.getUUID());
String blockName = clickedState.getBlock().getName().getString();
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.disguise_set",
blockName
).withStyle(ChatFormatting.GREEN),
true
);
}
// ==================== DOOR CONTROL ====================
private static void handleDoorControl(
PlayerInteractEvent.RightClickBlock event,
ServerPlayer player,
ServerLevel level,
BlockPos clickedPos
) {
CellRegistryV2 registry = CellRegistryV2.get(level);
// Check if the clicked position is a cell door (doors are in wall set)
// or an interior linked redstone (buttons/levers)
CellDataV2 cellByWall = registry.getCellByWall(clickedPos);
CellDataV2 cellByInterior = registry.getCellContaining(clickedPos);
CellDataV2 cell = null;
boolean isDoor = false;
boolean isRedstone = false;
if (cellByWall != null) {
// Check if it's in the cell's doors list
if (cellByWall.getDoors().contains(clickedPos)) {
cell = cellByWall;
isDoor = true;
}
// Check linked redstone in walls
if (cellByWall.getLinkedRedstone().contains(clickedPos)) {
cell = cellByWall;
isRedstone = true;
}
}
if (cellByInterior != null && !isDoor && !isRedstone) {
// Check linked redstone in interior
if (cellByInterior.getLinkedRedstone().contains(clickedPos)) {
cell = cellByInterior;
isRedstone = true;
}
}
if (cell == null || (!isDoor && !isRedstone)) return;
// Owner, camp cell, or OP can always interact
if (cell.canPlayerManage(player.getUUID(), player.hasPermissions(2))) {
return;
}
// Prisoner or non-owner → block interaction
event.setCanceled(true);
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.door_locked"
).withStyle(ChatFormatting.RED),
true
);
}
}

View File

@@ -0,0 +1,198 @@
package com.tiedup.remake.events.system;
import com.tiedup.remake.core.SettingsAccessor;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.GagTalkManager;
import com.tiedup.remake.items.base.ItemGag;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.GagMaterial;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.util.TiedUpUtils;
import java.util.List;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.CommandEvent;
import net.minecraftforge.event.ServerChatEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* ChatEventHandler - Intercepts chat messages to apply gag effects.
* Evolution: Implements proximity-based chat for gagged players.
*
* Phase 14.1.5: Refactored to use IBondageState interface
*
* Security fix: Now blocks communication commands (/msg, /tell, etc.) when gagged
* to prevent gag bypass exploit
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public class ChatEventHandler {
/** List of communication commands that should be blocked when gagged */
private static final String[] BLOCKED_COMMANDS = {
"msg",
"tell",
"w",
"whisper",
"r",
"reply",
"me",
};
@SubscribeEvent
public static void onPlayerChat(ServerChatEvent event) {
ServerPlayer player = event.getPlayer();
IBondageState state = KidnappedHelper.getKidnappedState(player);
if (state != null && state.isGagged()) {
ItemStack gagStack = V2EquipmentHelper.getInRegion(
player,
BodyRegionV2.MOUTH
);
if (
!gagStack.isEmpty() &&
gagStack.getItem() instanceof ItemGag gagItem
) {
String originalMessage = event.getRawText();
GagMaterial material = gagItem.getGagMaterial();
// 1. Process the message through our GagTalkManager V2
Component muffledMessage = GagTalkManager.processGagMessage(
state,
gagStack,
originalMessage
);
// 2. Proximity Chat Logic
boolean useProximity = SettingsAccessor.isGagTalkProximityEnabled(
player.level().getGameRules());
if (useProximity) {
// Cancel global and send to nearby
event.setCanceled(true);
Component finalChat = Component.literal("<")
.append(player.getDisplayName())
.append("> ")
.append(muffledMessage);
double range = material.getTalkRange();
// Phase 14.2: Use TiedUpUtils for proximity and earplugs filtering
List<ServerPlayer> nearbyPlayers =
TiedUpUtils.getPlayersAround(
player.level(),
player.blockPosition(),
range
);
int listeners = 0;
for (ServerPlayer other : nearbyPlayers) {
// Check if receiver has earplugs (they can't hear)
IBondageState receiverState =
KidnappedHelper.getKidnappedState(other);
if (
receiverState != null && receiverState.hasEarplugs()
) {
// Can't hear - skip this player
continue;
}
other.sendSystemMessage(finalChat);
if (other != player) listeners++;
}
if (listeners == 0) {
player.displayClientMessage(
Component.translatable(
"chat.tiedup.gag.no_one_heard"
).withStyle(
net.minecraft.ChatFormatting.ITALIC,
net.minecraft.ChatFormatting.GRAY
),
true
);
}
} else {
// Just replace message but keep it global
event.setMessage(muffledMessage);
}
TiedUpMod.LOGGER.debug(
"[Chat] {} muffled message processed (Proximity: {})",
player.getName().getString(),
useProximity
);
}
}
}
/**
* Intercept commands to prevent gagged players from using communication commands.
* Blocks /msg, /tell, /w, /whisper, /r, /reply, /me when player is gagged.
*
* Security fix: Prevents gag bypass exploit via private messages
*/
@SubscribeEvent(priority = EventPriority.HIGHEST)
public static void onCommand(CommandEvent event) {
// Only check if sender is a ServerPlayer
if (
!(event
.getParseResults()
.getContext()
.getSource()
.getEntity() instanceof
ServerPlayer player)
) {
return;
}
// Check if player is gagged
IBondageState state = KidnappedHelper.getKidnappedState(player);
if (state == null || !state.isGagged()) {
return; // Not gagged, allow all commands
}
// Get the command name (first part of the command string)
String commandInput = event.getParseResults().getReader().getString();
if (commandInput.isEmpty()) {
return;
}
// Remove leading slash if present
String commandName = commandInput.startsWith("/")
? commandInput.substring(1)
: commandInput;
// Get only the first word (command name)
commandName = commandName.split(" ")[0].toLowerCase();
// Check if this is a blocked communication command
for (String blockedCmd : BLOCKED_COMMANDS) {
if (commandName.equals(blockedCmd)) {
// Block the command
event.setCanceled(true);
// Send muffled message to player
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
"Mmmph! You can't use that command while gagged!"
);
TiedUpMod.LOGGER.debug(
"[Chat] Blocked command '{}' from gagged player {}",
commandName,
player.getName().getString()
);
return;
}
}
}
}

View File

@@ -0,0 +1,39 @@
package com.tiedup.remake.events.system;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.minigame.LockpickSessionManager;
import com.tiedup.remake.minigame.StruggleSessionManager;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Server tick handler for mini-game session management.
*
* Handles:
* - Continuous struggle session ticking (direction changes, resistance updates, shock checks)
* - Session cleanup for expired/disconnected sessions
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class MiniGameTickHandler {
/**
* Tick continuous struggle sessions every server tick.
*/
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
long currentTick = event.getServer().getTickCount();
// Tick continuous struggle sessions every tick
StruggleSessionManager.getInstance().tickContinuousSessions(event.getServer(), currentTick);
// Cleanup expired sessions periodically
StruggleSessionManager.getInstance().tickCleanup(currentTick);
LockpickSessionManager.getInstance().tickCleanup(currentTick);
}
}