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,472 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.ai.damsel.*;
import com.tiedup.remake.entities.ai.personality.*;
import com.tiedup.remake.state.ICaptor;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.ai.goal.FloatGoal;
import net.minecraft.world.entity.ai.goal.OpenDoorGoal;
import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal;
import net.minecraft.world.entity.ai.goal.target.TargetGoal;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
/**
* Manages all AI-related systems for EntityDamsel:
* - AI goals registration (25+ goals in priority order)
* - Call for help system (captive calling for rescue)
* - Leash traction system (prevents stuck while leashed)
*
* Phase 5: Extracted from EntityDamsel.java (~450 lines, 5 methods)
*/
public class DamselAIController {
private final EntityDamsel entity;
private final IAIHost host;
// ========================================
// CALL FOR HELP SYSTEM
// ========================================
/** Cooldown between call for help messages */
private int callForHelpCooldown = 0;
/** Radius to search for players to call for help (blocks) */
private static final int CALL_FOR_HELP_RADIUS = 15;
/** Minimum cooldown for call for help (5 seconds) */
private static final int CALL_FOR_HELP_COOLDOWN_MIN = 100;
/** Maximum cooldown for call for help (10 seconds) */
private static final int CALL_FOR_HELP_COOLDOWN_MAX = 200;
// ========================================
// LEASH TRACTION SYSTEM
// ========================================
/** Counter for how long NPC has been stuck while leashed */
private int leashStuckCounter = 0;
/** Previous X position for stuck detection */
private double leashPrevX;
/** Previous Z position for stuck detection */
private double leashPrevZ;
/** Distance from holder at which pulling starts (blocks) */
private static final double LEASH_PULL_START_DISTANCE = 2.5;
/** Distance at which stuck teleport begins (blocks) */
private static final double LEASH_TELEPORT_DISTANCE = 5.0;
/** Maximum distance before forced teleport (blocks) */
private static final double LEASH_MAX_DISTANCE = 8.0;
/** Ticks stuck before teleport (40 ticks = 2 seconds) */
private static final int LEASH_STUCK_THRESHOLD = 40;
/** FIX: Max traction force - reduced for smoother movement */
private static final double LEASH_MAX_FORCE = 0.12;
/** FIX: Force ramp factor - gradual increase based on distance */
private static final double LEASH_FORCE_RAMP = 0.05;
// ========================================
// CONSTRUCTOR
// ========================================
public DamselAIController(EntityDamsel entity, IAIHost host) {
this.entity = entity;
this.host = host;
}
// ========================================
// AI GOALS REGISTRATION
// ========================================
/**
* Register all AI goals for this entity.
* Must be called from EntityDamsel.registerGoals() with goalSelector and targetSelector.
*
* @param goalSelector The goal selector from Mob
* @param targetSelector The target selector from Mob
*/
public void registerGoals(
net.minecraft.world.entity.ai.goal.GoalSelector goalSelector,
net.minecraft.world.entity.ai.goal.GoalSelector targetSelector
) {
// Priority 0: Always swim (FloatGoal)
goalSelector.addGoal(0, new FloatGoal(entity));
// Priority 1: Personality command goals (take precedence when active)
goalSelector.addGoal(1, new NpcFollowCommandGoal(entity, 1.0));
// NpcHeelCommandGoal removed - HEEL is now a FollowDistance mode in NpcFollowCommandGoal
goalSelector.addGoal(1, new NpcStayCommandGoal(entity));
goalSelector.addGoal(1, new NpcIdleCommandGoal(entity));
goalSelector.addGoal(1, new NpcComeCommandGoal(entity, 1.2));
goalSelector.addGoal(1, new NpcGoHomeGoal(entity));
goalSelector.addGoal(1, new NpcSitCommandGoal(entity));
goalSelector.addGoal(1, new NpcKneelCommandGoal(entity));
goalSelector.addGoal(1, new NpcPatrolCommandGoal(entity, 0.9));
goalSelector.addGoal(1, new NpcGuardCommandGoal(entity));
goalSelector.addGoal(1, new NpcFetchCommandGoal(entity, 1.1));
goalSelector.addGoal(1, new NpcCollectCommandGoal(entity, 1.0));
// NOTE: Combat goals (Defend, Attack, Capture) removed.
// Combat behavior is now handled by NpcFollowCommandGoal based on main hand item.
// Work commands (FARM, COOK, STORE) - chest-hub system
goalSelector.addGoal(1, new NpcFarmCommandGoal(entity, 0.9));
goalSelector.addGoal(1, new NpcCookCommandGoal(entity, 1.0));
goalSelector.addGoal(1, new NpcTransferCommandGoal(entity, 1.0));
goalSelector.addGoal(1, new NpcShearCommandGoal(entity, 1.0));
goalSelector.addGoal(1, new NpcMineCommandGoal(entity, 0.9));
goalSelector.addGoal(1, new NpcBreedCommandGoal(entity, 1.0));
goalSelector.addGoal(1, new NpcFishCommandGoal(entity, 0.9));
goalSelector.addGoal(1, new NpcSortCommandGoal(entity, 1.0));
// Priority 2: Struggle goal (passive, runs alongside other goals - no flags)
goalSelector.addGoal(2, new NpcStruggleGoal(entity));
// Priority 2: Auto-eat goal (passive, eats from inventory when hungry)
goalSelector.addGoal(2, new NpcAutoEatGoal(entity));
// Priority 2: Auto-rest goal (passive, goes home to rest when tired)
goalSelector.addGoal(2, new NpcAutoRestGoal(entity));
// Priority 2-5: Custom conditional AI goals
goalSelector.addGoal(
2,
new DamselFleeFromPlayerGoal(entity, 10.0f, 1.1, 1.3)
);
goalSelector.addGoal(3, new DamselPanicGoal(entity, 1.0));
goalSelector.addGoal(4, new DamselWanderGoal(entity, 1.2));
goalSelector.addGoal(
5,
new DamselWatchPlayerGoal(entity, Player.class, 6.0f)
);
// Priority 6-7: Basic vanilla goals
goalSelector.addGoal(6, new RandomLookAroundGoal(entity));
goalSelector.addGoal(7, new OpenDoorGoal(entity, false));
}
// ========================================
// COMMAND GOALS UTILITY
// ========================================
/**
* Register all NPC command goals on a goal selector.
* Used by subclasses (EntityMaid, EntitySlaveTrader, EntityMaster) that override
* registerGoals() but still need command functionality.
*
* @param goalSelector The goal selector to register on
* @param entity The entity (must extend EntityDamsel)
* @param priority The priority level for command goals
*/
public static void registerCommandGoals(
net.minecraft.world.entity.ai.goal.GoalSelector goalSelector,
EntityDamsel entity,
int priority
) {
goalSelector.addGoal(priority, new NpcFollowCommandGoal(entity, 1.0));
goalSelector.addGoal(priority, new NpcStayCommandGoal(entity));
goalSelector.addGoal(priority, new NpcIdleCommandGoal(entity));
goalSelector.addGoal(priority, new NpcComeCommandGoal(entity, 1.2));
goalSelector.addGoal(priority, new NpcGoHomeGoal(entity));
goalSelector.addGoal(priority, new NpcSitCommandGoal(entity));
goalSelector.addGoal(priority, new NpcKneelCommandGoal(entity));
goalSelector.addGoal(priority, new NpcPatrolCommandGoal(entity, 0.9));
goalSelector.addGoal(priority, new NpcGuardCommandGoal(entity));
goalSelector.addGoal(priority, new NpcFetchCommandGoal(entity, 1.1));
goalSelector.addGoal(priority, new NpcCollectCommandGoal(entity, 1.0));
goalSelector.addGoal(priority, new NpcFarmCommandGoal(entity, 0.9));
goalSelector.addGoal(priority, new NpcCookCommandGoal(entity, 1.0));
goalSelector.addGoal(priority, new NpcTransferCommandGoal(entity, 1.0));
goalSelector.addGoal(priority, new NpcShearCommandGoal(entity, 1.0));
goalSelector.addGoal(priority, new NpcMineCommandGoal(entity, 0.9));
goalSelector.addGoal(priority, new NpcBreedCommandGoal(entity, 1.0));
goalSelector.addGoal(priority, new NpcFishCommandGoal(entity, 0.9));
goalSelector.addGoal(priority, new NpcSortCommandGoal(entity, 1.0));
}
// ========================================
// CALL FOR HELP SYSTEM
// ========================================
/**
* Periodically call for help to nearby players when being led as a captive.
* Only works if:
* - Damsel is tied up
* - Damsel is a captive (being led)
* - Not gagged (can still speak)
* - There's a player nearby who is NOT the captor
*
* Call this from EntityDamsel.aiStep().
*/
public void tickCallForHelp() {
// Decrement cooldown
if (this.callForHelpCooldown > 0) {
this.callForHelpCooldown--;
return;
}
// Check if this entity can call for help (overridable in EntityKidnapper)
if (!entity.canCallForHelp()) {
return;
}
// Must be tied and a captive to call for help
if (!host.isTiedUp() || !host.isCaptive()) {
return;
}
// Can't call for help if gagged
if (host.isGagged()) {
return;
}
// Find nearby players who could help
List<Player> nearbyPlayers = host
.level()
.getEntitiesOfClass(
Player.class,
host
.getBoundingBox()
.inflate(ModConfig.SERVER.dialogueRadius.get())
);
// Get captor entity for comparison
Entity captorEntity =
host.getBondageManager().getCaptor() != null
? host.getBondageManager().getCaptor().getEntity()
: null;
boolean foundTarget = false;
for (Player player : nearbyPlayers) {
// Skip creative/spectator
if (player.isCreative() || player.isSpectator()) continue;
// Skip the captor - don't ask your captor for help!
if (captorEntity != null && player == captorEntity) continue;
// Call for help to this player
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} calling for help to {}",
host.getNpcName(),
player.getName().getString()
);
com.tiedup.remake.dialogue.EntityDialogueManager.callForHelp(
entity,
player
);
// Found a target, set flag
foundTarget = true;
break;
}
// Reset cooldown
int baseCooldown = ModConfig.SERVER.dialogueCooldown.get();
if (foundTarget) {
// Full cooldown if we talked
this.callForHelpCooldown =
baseCooldown + host.getRandom().nextInt(baseCooldown);
} else {
// Short cooldown if no one found (prevent spam) - 2 to 4 seconds
this.callForHelpCooldown = 40 + host.getRandom().nextInt(40);
}
}
// ========================================
// LEASH TRACTION SYSTEM
// ========================================
/**
* Tick leash traction system for NPCs.
* Detects when NPC is stuck while leashed and teleports to holder.
* This mirrors the player leash system in MixinServerPlayer.
*
* FIX: Now applies ACTIVE traction force towards holder (was missing).
* FIX: Increases step height temporarily when being pulled to climb stairs.
*
* Call this from EntityDamsel.aiStep().
*/
public void tickLeashTraction() {
// Only process if leashed
Entity leashHolder = host.getLeashHolder();
if (leashHolder == null) {
this.leashStuckCounter = 0;
// Reset step height when not leashed
if (host.maxUpStep() > 0.6f) {
host.setMaxUpStep(0.6f);
}
return;
}
// Same level check
if (leashHolder.level() != host.level()) {
return;
}
float distance = host.distanceTo(leashHolder);
// Close enough: reset stuck counter, reset step height
if (distance < LEASH_PULL_START_DISTANCE) {
this.leashStuckCounter = 0;
if (host.maxUpStep() > 0.6f) {
host.setMaxUpStep(0.6f);
}
return;
}
// Too far: forced teleport to holder (regardless of stuck status)
if (distance > LEASH_MAX_DISTANCE) {
teleportToSafePositionNearHolder(leashHolder);
this.leashStuckCounter = 0;
return;
}
// Calculate direction to holder
double dx = leashHolder.getX() - host.getX();
double dy = leashHolder.getY() - host.getY();
double dz = leashHolder.getZ() - host.getZ();
// Calculate movement since last tick
double movedX = host.getX() - this.leashPrevX;
double movedZ = host.getZ() - this.leashPrevZ;
double movedHorizontal = Math.sqrt(movedX * movedX + movedZ * movedZ);
// Store current position for next tick
this.leashPrevX = host.getX();
this.leashPrevZ = host.getZ();
// FIX: Apply ACTIVE traction force towards holder with smooth ramping
if (distance > LEASH_PULL_START_DISTANCE) {
// Normalize direction
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
double dirX = horizontalDist > 0.01 ? dx / horizontalDist : 0;
double dirZ = horizontalDist > 0.01 ? dz / horizontalDist : 0;
// FIX: Smoother force calculation - gradual ramp up
// Force increases with distance but is capped for smooth movement
double distanceBeyond = distance - LEASH_PULL_START_DISTANCE;
double forceFactor = Math.min(
LEASH_MAX_FORCE,
distanceBeyond * LEASH_FORCE_RAMP
);
// FIX: Blend with current motion instead of adding directly
// This prevents jerky acceleration
net.minecraft.world.phys.Vec3 currentMotion =
host.getDeltaMovement();
double blendFactor = 0.7; // 70% new direction, 30% current momentum
double newVelX =
currentMotion.x * (1 - blendFactor) +
dirX * forceFactor * blendFactor * 3;
double newVelZ =
currentMotion.z * (1 - blendFactor) +
dirZ * forceFactor * blendFactor * 3;
double newVelY = currentMotion.y + (dy > 0.5 ? 0.03 : 0); // Gentler Y boost
host.setDeltaMovement(
new net.minecraft.world.phys.Vec3(newVelX, newVelY, newVelZ)
);
// Increase step height while being pulled (helps with stairs)
host.setMaxUpStep(1.0f);
}
// Strict stuck detection: not moving despite being far
boolean isStuck =
distance > LEASH_TELEPORT_DISTANCE &&
host.getDeltaMovement().horizontalDistanceSqr() < 0.001 &&
movedHorizontal < 0.05;
if (isStuck) {
this.leashStuckCounter++;
// Safety teleport if stuck for too long (30 ticks = 1.5 sec)
if (this.leashStuckCounter >= LEASH_STUCK_THRESHOLD) {
teleportToSafePositionNearHolder(leashHolder);
this.leashStuckCounter = 0;
}
} else {
// Reset stuck counter if moving
this.leashStuckCounter = 0;
}
}
/**
* Teleport NPC to a safe position near the leash holder.
*/
private void teleportToSafePositionNearHolder(Entity holder) {
// Target: 2 blocks away from holder in our direction
double dx = host.getX() - holder.getX();
double dz = host.getZ() - holder.getZ();
double dist = Math.sqrt(dx * dx + dz * dz);
double offsetX = 0;
double offsetZ = 0;
if (dist > 0.1) {
offsetX = (dx / dist) * 2.0;
offsetZ = (dz / dist) * 2.0;
}
double targetX = holder.getX() + offsetX;
double targetZ = holder.getZ() + offsetZ;
// Find safe Y (ground level)
double targetY = findSafeY(targetX, holder.getY(), targetZ);
host.teleportTo(targetX, targetY, targetZ);
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} stuck while leashed, teleported to holder",
host.getNpcName()
);
}
/**
* Find a safe Y coordinate for teleporting.
*/
private double findSafeY(double x, double startY, double z) {
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
// Search down first (max 5 blocks)
for (int y = 0; y > -5; y--) {
mutable.set((int) x, (int) startY + y, (int) z);
if (
host
.level()
.getBlockState(mutable)
.isSolidRender(host.level(), mutable) &&
host.level().getBlockState(mutable.above()).isAir()
) {
return mutable.getY() + 1;
}
}
// Search up (max 5 blocks)
for (int y = 1; y < 5; y++) {
mutable.set((int) x, (int) startY + y, (int) z);
if (
host
.level()
.getBlockState(mutable.below())
.isSolidRender(host.level(), mutable.below()) &&
host.level().getBlockState(mutable).isAir()
) {
return mutable.getY();
}
}
// Fallback: use holder's Y
return startY;
}
}

View File

@@ -0,0 +1,263 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.util.RotationSmoother;
import dev.kosmx.playerAnim.api.layered.AnimationStack;
import dev.kosmx.playerAnim.api.layered.IAnimation;
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
import java.util.HashMap;
import java.util.Map;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
/**
* Manages all animation-related systems for EntityDamsel:
* - IAnimatedPlayer implementation (PlayerAnimator mod)
* - Animation stack management
* - Pose management (sitting, kneeling, dog, struggling, trembling)
* - Dog pose rotation smoothing
*
* Phase 3: Extracted from EntityDamsel.java (~220 lines, 14 methods)
*/
public class DamselAnimationController {
private final IAnimationHost host;
// ========================================
// ANIMATION SYSTEM
// ========================================
/** Animation stack for PlayerAnimator mod */
private final AnimationStack animationStack = new AnimationStack();
/** Animation applier for model rendering */
private final AnimationApplier animationApplier = new AnimationApplier(
animationStack
);
/** Stored animations by ID */
private final Map<ResourceLocation, IAnimation> storedAnimations =
new HashMap<>();
// ========================================
// DOG POSE ROTATION SMOOTHING
// ========================================
/** Rotation smoother for DOG pose (prevents sudden spinning) */
private final RotationSmoother dogPoseRotationSmoother =
new RotationSmoother();
/** Track DOG pose state for transition detection */
private boolean wasInDogPose = false;
// ========================================
// CONSTRUCTOR
// ========================================
public DamselAnimationController(IAnimationHost host) {
this.host = host;
}
// ========================================
// TICK LOGIC
// ========================================
/**
* Tick animation systems BEFORE super.tick().
* Call this from EntityDamsel.tick() BEFORE super.tick().
*
* Handles:
* - DOG pose transition detection (initializes smoother)
*/
public void tickAnimationBeforeSuperTick() {
// DOG pose: Detect transition to initialize smoother
boolean inDogPose = host.isDogPose();
if (inDogPose && !wasInDogPose) {
// Just entered DOG pose - initialize smoother to current rotation
this.dogPoseRotationSmoother.setCurrent(host.getYBodyRot());
}
this.wasInDogPose = inDogPose;
}
/**
* Tick DOG pose rotation smoothing AFTER super.tick().
* Call this from EntityDamsel.tick() AFTER super.tick().
*
* FIX: Must run AFTER super.tick() because:
* 1. super.tick() calculates new yBodyRot based on movement direction
* 2. We smooth that final calculated value
* 3. If we smooth before, Minecraft overwrites our smoothed value
*
* FIX: Only set yBodyRot, not yBodyRotO:
* - yBodyRotO is the "old" rotation from last tick, used for interpolation
* - Setting both to the same value breaks inter-frame interpolation
* - Minecraft's renderer uses lerp(yBodyRotO, yBodyRot, partialTicks)
* - If both are equal, there's no interpolation = visual snapping
*/
public void tickDogPoseRotationSmoothing() {
if (!host.isDogPose()) {
return;
}
// Get the target rotation (what Minecraft calculated based on movement)
float targetRot = host.getYBodyRot();
// Smooth towards target (0.15 = 15% per tick, faster but still smooth)
float smoothedRot = this.dogPoseRotationSmoother.smooth(
targetRot,
0.15f
);
// Only set yBodyRot - let yBodyRotO be managed by Minecraft for interpolation
host.setYBodyRot(smoothedRot);
}
/**
* Tick animation stack (client-side only).
* Call this from EntityDamsel.tick() AFTER tickAnimationBeforeSuperTick().
*
* @return true if animation tick was handled (client-side), false if server should continue
*/
public boolean tickAnimationStack() {
// Client-side: tick animation stack
if (host.level().isClientSide) {
this.animationStack.tick();
return true; // Signal to caller to return early
}
return false; // Server-side, continue with tick logic
}
// ========================================
// IANIMATEDPLAYER INTERFACE
// ========================================
/**
* Get the animation stack.
* Required by IAnimatedPlayer interface.
*
* @return AnimationStack for this entity
*/
public AnimationStack getAnimationStack() {
return this.animationStack;
}
/**
* Get the animation applier for model rendering.
* Required by IAnimatedPlayer interface.
*
* <p>The AnimationApplier is used by DamselModel.setupAnim() to apply
* current animation transforms to model parts via emote.updatePart().
*
* @return AnimationApplier for this entity
*/
public AnimationApplier playerAnimator_getAnimation() {
return this.animationApplier;
}
/**
* Get a stored animation by ID.
* Required by IAnimatedPlayer interface.
*
* @param id Animation identifier
* @return The stored animation, or null if not found
*/
@Nullable
public IAnimation playerAnimator_getAnimation(ResourceLocation id) {
return this.storedAnimations.get(id);
}
/**
* Store an animation by ID.
* Required by IAnimatedPlayer interface.
*
* @param id Animation identifier
* @param animation Animation to store, or null to remove
* @return The previously stored animation, or null
*/
@Nullable
public IAnimation playerAnimator_setAnimation(
ResourceLocation id,
@Nullable IAnimation animation
) {
if (animation == null) {
return this.storedAnimations.remove(id);
}
return this.storedAnimations.put(id, animation);
}
// ========================================
// POSE MANAGEMENT
// ========================================
/**
* Check if NPC is currently sitting.
* @return true if in sitting pose
*/
public boolean isSitting() {
return host.isSittingFromData();
}
/**
* Set sitting pose state.
* @param sitting true to enter sitting pose
*/
public void setSitting(boolean sitting) {
host.setSittingToData(sitting);
// Clear kneeling if sitting
if (sitting) {
host.setKneelingToData(false);
}
}
/**
* Check if NPC is currently kneeling.
* @return true if in kneeling pose
*/
public boolean isKneeling() {
return host.isKneelingFromData();
}
/**
* Set kneeling pose state.
* @param kneeling true to enter kneeling pose
*/
public void setKneeling(boolean kneeling) {
host.setKneelingToData(kneeling);
// Clear sitting if kneeling
if (kneeling) {
host.setSittingToData(false);
}
}
/**
* Check if NPC is in any pose (sitting, kneeling, or dog).
* @return true if in a pose
*/
public boolean isInPose() {
return this.isSitting() || this.isKneeling() || host.isDogPose();
}
/**
* Check if NPC is in DOG pose (based on equipped bind).
* @return true if equipped bind has DOG pose type
*/
public boolean isDogPose() {
return host.isDogPose();
}
/**
* Check if NPC is currently struggling against restraints.
* Used for animation sync.
* @return true if struggling
*/
public boolean isStruggling() {
return host.isStrugglingFromData();
}
/**
* Set struggling state for animation.
* @param struggling true if starting struggle animation
*/
public void setStruggling(boolean struggling) {
host.setStrugglingToData(struggling);
}
}

View File

@@ -0,0 +1,248 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.entities.DamselVariant;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.skins.DamselSkinManager;
import com.tiedup.remake.entities.skins.Gender;
import org.jetbrains.annotations.Nullable;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
/**
* Manages the visual appearance of a Damsel NPC.
* Responsibilities:
* - Variant selection and management
* - Skin texture resolution
* - Gender
* - Slim arms flag
* - Custom name
*
* Phase 1 of EntityDamsel refactoring (8 phases total).
*/
public class DamselAppearance {
private final EntityDamsel entity;
/**
* Current skin variant (cached from DATA_VARIANT_ID).
* Thread-safe: accessed from render thread.
*/
@Nullable
private volatile DamselVariant variant;
/**
* Default texture for damsel entities.
*/
private static final ResourceLocation DEFAULT_DAMSEL_TEXTURE =
ResourceLocation.fromNamespaceAndPath(
"tiedup",
"textures/entity/damsel/dam_mob_1.png"
);
public DamselAppearance(EntityDamsel entity) {
this.entity = entity;
}
// ========================================
// VARIANT SYSTEM
// ========================================
/**
* Get the current variant.
* Lazy-loads from DATA_VARIANT_ID if not cached.
*/
@Nullable
public DamselVariant getVariant() {
// Try to load from synced variant ID first
if (this.variant == null && !this.getVariantId().isEmpty()) {
this.variant = DamselSkinManager.CORE.getVariant(
this.getVariantId()
);
}
// Fallback: compute from UUID if not yet synced (client-side race condition fix)
if (this.variant == null) {
this.variant = DamselSkinManager.CORE.getVariantForEntity(
this.entity.getUUID()
);
}
return this.variant;
}
/**
* Set the variant.
* Updates both cache and synced data.
*/
public void setVariant(DamselVariant variant) {
this.variant = variant;
this.entity.getEntityData().set(
EntityDamsel.DATA_VARIANT_ID,
variant.id()
);
this.setSlimArms(variant.hasSlimArms());
this.setGender(variant.gender());
applyVariantName(variant);
}
/**
* Apply the name from variant to this entity.
* Numbered variants (dam_mob_*) get random names.
* Named variants (Anastasia, Blizz, etc.) use their default name.
*/
protected void applyVariantName(DamselVariant variant) {
if (variant.id().startsWith("dam_mob_")) {
this.setNpcName(
com.tiedup.remake.util.NameGenerator.getRandomDamselName()
);
} else {
this.setNpcName(variant.defaultName());
}
}
/**
* Get variant ID (synced data).
*/
public String getVariantId() {
return this.entity.getEntityData().get(EntityDamsel.DATA_VARIANT_ID);
}
/**
* Invalidate variant cache (called when DATA_VARIANT_ID changes).
*/
public void invalidateVariantCache() {
this.variant = null;
}
// ========================================
// SLIM ARMS
// ========================================
/**
* Check if this damsel uses slim arms model.
* Reads from synced entityData - no local skin JSONs needed on client.
*/
public boolean hasSlimArms() {
return this.entity.getEntityData().get(EntityDamsel.DATA_SLIM_ARMS);
}
/**
* Set slim arms flag directly.
* Used by subclasses that have their own variant systems.
*/
protected void setSlimArms(boolean slimArms) {
this.entity.getEntityData().set(EntityDamsel.DATA_SLIM_ARMS, slimArms);
}
// ========================================
// GENDER
// ========================================
public void setGender(Gender gender) {
this.entity.getEntityData().set(
EntityDamsel.DATA_GENDER,
gender.getSerializedName()
);
}
public Gender getGender() {
return Gender.fromName(
this.entity.getEntityData().get(EntityDamsel.DATA_GENDER)
);
}
// ========================================
// SKIN TEXTURE
// ========================================
/**
* Get the skin texture for this entity.
* Computes texture path from variant ID - no local skin JSONs needed on client.
* Implements ISkinnedEntity to eliminate instanceof cascades in renderers.
*/
public ResourceLocation getSkinTexture() {
String variantId = this.getVariantId();
if (!variantId.isEmpty()) {
return ResourceLocation.fromNamespaceAndPath(
"tiedup",
"textures/entity/damsel/" + variantId + ".png"
);
}
return DEFAULT_DAMSEL_TEXTURE;
}
// ========================================
// NAME SYSTEM
// ========================================
/**
* Get NPC's custom name.
*/
public String getNpcName() {
return this.entity.getEntityData().get(EntityDamsel.DATA_DAMSEL_NAME);
}
/**
* Set NPC's custom name.
* Also updates Minecraft's custom name display.
*/
public void setNpcName(String name) {
this.entity.getEntityData().set(EntityDamsel.DATA_DAMSEL_NAME, name);
// Make the name visible in-game
this.entity.setCustomName(Component.literal(name));
this.entity.setCustomNameVisible(true);
}
// ========================================
// NBT SERIALIZATION
// ========================================
/**
* Save appearance data to NBT tag.
* Writes directly to top-level keys for backward compatibility.
*/
public void saveToTag(CompoundTag tag) {
String variantId = this.entity.getEntityData().get(
EntityDamsel.DATA_VARIANT_ID
);
if (!variantId.isEmpty()) {
tag.putString("Variant", variantId);
}
String name = this.getNpcName();
if (!name.isEmpty()) {
tag.putString("DamselName", name);
}
tag.putBoolean("SlimArms", this.hasSlimArms());
tag.putString("Gender", this.getGender().getSerializedName());
}
/**
* Load appearance data from NBT tag.
* Reads from top-level keys for backward compatibility.
*/
public void loadFromTag(CompoundTag tag) {
if (tag.contains("Variant")) {
String variantId = tag.getString("Variant");
this.entity.getEntityData().set(
EntityDamsel.DATA_VARIANT_ID,
variantId
);
this.variant = null; // Invalidate cache
}
if (tag.contains("DamselName")) {
this.setNpcName(tag.getString("DamselName"));
}
if (tag.contains("SlimArms")) {
this.setSlimArms(tag.getBoolean("SlimArms"));
}
if (tag.contains("Gender")) {
String genderName = tag.getString("Gender");
this.setGender(Gender.fromName(genderName));
}
}
}

View File

@@ -0,0 +1,468 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.entities.BondageServiceHandler;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.state.IRestrainable;
import com.tiedup.remake.state.IRestrainableEntity;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.state.ICaptor;
import com.tiedup.remake.util.RestraintEffectUtils;
import com.tiedup.remake.util.tasks.ItemTask;
import com.tiedup.remake.util.teleport.Position;
import com.tiedup.remake.util.teleport.TeleportHelper;
import java.util.UUID;
import javax.annotation.Nullable;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.damagesource.DamageSource;
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.minecraft.world.level.Level;
/**
* DamselBondageManager - Facade over NpcEquipmentManager and NpcCaptivityManager.
*
* <p>H9 refactor: Split from a 1441-line god-component into:</p>
* <ul>
* <li>{@link NpcEquipmentManager} -- equipment CRUD, coercion, bulk ops (~700L)</li>
* <li>{@link NpcCaptivityManager} -- captor, leash, free/capture (~250L)</li>
* <li>{@link BondageServiceHandler} -- existing, unchanged</li>
* <li>This facade -- delegates everything, owns sale state inline (~250L)</li>
* </ul>
*
* <p>Implements {@link IRestrainable} so all existing callers via
* {@code getBondageManager().xxx()} continue to work unchanged.</p>
*/
public class DamselBondageManager implements IRestrainable {
// ========================================
// FIELDS
// ========================================
private final AbstractTiedUpNpc entity;
private final IBondageHost host;
private final NpcEquipmentManager equipment;
private final NpcCaptivityManager captivity;
private final BondageServiceHandler bondageService;
// Sale fields (inline -- too small for own class)
private boolean forSale;
private ItemTask salePrice;
// ========================================
// CONSTRUCTOR
// ========================================
public DamselBondageManager(AbstractTiedUpNpc entity, IBondageHost host) {
this.entity = entity;
this.host = host;
this.equipment = new NpcEquipmentManager(entity, host);
this.captivity = new NpcCaptivityManager(entity, host, equipment);
this.bondageService = new BondageServiceHandler(entity);
this.forSale = false;
this.salePrice = null;
}
// ========================================
// SUB-COMPONENT ACCESS
// ========================================
/** Expose equipment sub-component for direct access where needed. */
public NpcEquipmentManager getEquipmentManager() {
return equipment;
}
/** Expose captivity sub-component for direct access where needed. */
public NpcCaptivityManager getCaptivityManager() {
return captivity;
}
// ========================================
// IBondageState DELEGATION -> equipment
// ========================================
@Override public boolean isTiedUp() { return equipment.isTiedUp(); }
@Override public boolean isGagged() { return equipment.isGagged(); }
@Override public boolean isBlindfolded() { return equipment.isBlindfolded(); }
@Override public boolean hasEarplugs() { return equipment.hasEarplugs(); }
@Override public boolean hasCollar() { return equipment.hasCollar(); }
@Override public boolean hasClothes() { return equipment.hasClothes(); }
@Override public boolean hasMittens() { return equipment.hasMittens(); }
@Override public boolean isBoundAndGagged() { return equipment.isBoundAndGagged(); }
@Override public boolean hasGaggingEffect() { return equipment.hasGaggingEffect(); }
@Override public boolean hasBlindingEffect() { return equipment.hasBlindingEffect(); }
@Override public boolean hasKnives() { return equipment.hasKnives(); }
@Override public boolean hasClothesWithSmallArms() { return equipment.hasClothesWithSmallArms(); }
@Override public boolean hasLockedCollar() { return equipment.hasLockedCollar(); }
@Override public boolean hasNamedCollar() { return equipment.hasNamedCollar(); }
// V2 region-based equipment access
@Override public ItemStack getEquipment(BodyRegionV2 region) { return equipment.getInRegion(region); }
@Override
public void equip(BodyRegionV2 region, ItemStack stack) {
switch (region) {
case ARMS -> equipment.putBindOn(stack);
case MOUTH -> equipment.putGagOn(stack);
case EYES -> equipment.putBlindfoldOn(stack);
case EARS -> equipment.putEarplugsOn(stack);
case NECK -> equipment.putCollarOn(stack);
case TORSO -> equipment.putClothesOn(stack);
case HANDS -> equipment.putMittensOn(stack);
default -> {}
}
}
// Equipment putters (local delegates — no longer in interface, but still called by legacy code)
public void putBindOn(ItemStack bind) { equipment.putBindOn(bind); }
public void putGagOn(ItemStack gag) { equipment.putGagOn(gag); }
public void putBlindfoldOn(ItemStack blindfold) { equipment.putBlindfoldOn(blindfold); }
public void putEarplugsOn(ItemStack earplugs) { equipment.putEarplugsOn(earplugs); }
public void putCollarOn(ItemStack collar) { equipment.putCollarOn(collar); }
public void putClothesOn(ItemStack clothes) { equipment.putClothesOn(clothes); }
public void putMittensOn(ItemStack mittens) { equipment.putMittensOn(mittens); }
// Equipment removers (V2 region-based)
@Override
public ItemStack unequip(BodyRegionV2 region) {
return switch (region) {
case ARMS -> equipment.takeBindOff();
case MOUTH -> equipment.takeGagOff();
case EYES -> equipment.takeBlindfoldOff();
case EARS -> equipment.takeEarplugsOff();
case NECK -> equipment.takeCollarOff(false);
case TORSO -> equipment.takeClothesOff();
case HANDS -> equipment.takeMittensOff();
default -> ItemStack.EMPTY;
};
}
@Override
public ItemStack forceUnequip(BodyRegionV2 region) {
// Delegate to NpcEquipmentManager.forceRemoveFromRegion which does the same
// work as takeXOff (clear slot, sync, onUnequipped, side effects) but
// bypasses ILockable.isLocked() checks. See RISK-001.
return equipment.forceRemoveFromRegion(region);
}
// Legacy equipment removers (local delegates for internal use)
public ItemStack takeBindOff() { return equipment.takeBindOff(); }
public ItemStack takeGagOff() { return equipment.takeGagOff(); }
public ItemStack takeBlindfoldOff() { return equipment.takeBlindfoldOff(); }
public ItemStack takeEarplugsOff() { return equipment.takeEarplugsOff(); }
public ItemStack takeCollarOff() { return equipment.takeCollarOff(); }
public ItemStack takeCollarOff(boolean force) { return equipment.takeCollarOff(force); }
public ItemStack takeClothesOff() { return equipment.takeClothesOff(); }
public ItemStack takeMittensOff() { return equipment.takeMittensOff(); }
// Equipment replacer (V2 region-based)
@Override
public ItemStack replaceEquipment(BodyRegionV2 region, ItemStack newStack, boolean force) {
return switch (region) {
case ARMS -> equipment.replaceBind(newStack, force);
case MOUTH -> equipment.replaceGag(newStack, force);
case EYES -> equipment.replaceBlindfold(newStack, force);
case EARS -> equipment.replaceEarplugs(newStack, force);
case NECK -> equipment.replaceCollar(newStack, force);
case TORSO -> equipment.replaceClothes(newStack, force);
case HANDS -> equipment.replaceMittens(newStack, force);
default -> ItemStack.EMPTY;
};
}
// Bulk operations
@Override
public void applyBondage(
ItemStack bind, ItemStack gag, ItemStack blindfold,
ItemStack earplugs, ItemStack collar, ItemStack clothes
) {
equipment.applyBondage(bind, gag, blindfold, earplugs, collar, clothes);
}
@Override public void dropBondageItems(boolean drop) { equipment.dropBondageItems(drop); }
@Override public void dropBondageItems(boolean drop, boolean dropBind) { equipment.dropBondageItems(drop, dropBind); }
@Override
public void dropBondageItems(
boolean drop, boolean dropBind, boolean dropGag,
boolean dropBlindfold, boolean dropEarplugs,
boolean dropCollar, boolean dropClothes
) {
equipment.dropBondageItems(drop, dropBind, dropGag, dropBlindfold,
dropEarplugs, dropCollar, dropClothes);
}
@Override public void dropClothes() { equipment.dropClothes(); }
@Override public int getBondageItemsWhichCanBeRemovedCount() { return equipment.getBondageItemsWhichCanBeRemovedCount(); }
@Override public boolean canBeTiedUp() { return equipment.canBeTiedUp(); }
// Permissions
@Override public boolean canTakeOffClothes(Player player) { return equipment.canTakeOffClothes(player); }
@Override public boolean canChangeClothes(Player player) { return equipment.canChangeClothes(player); }
@Override public boolean canChangeClothes() { return equipment.canChangeClothes(); }
// ICoercible DELEGATION -> equipment
@Override public void tighten(Player tightener) { equipment.tighten(tightener); }
@Override public void applyChloroform(int duration) { equipment.applyChloroform(duration); }
@Override public void shockKidnapped() { equipment.shockKidnapped(); }
@Override public void shockKidnapped(String messageAddon, float damage) { equipment.shockKidnapped(messageAddon, damage); }
@Override public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) { equipment.takeBondageItemBy(taker, slotIndex); }
// ========================================
// ICapturable DELEGATION -> captivity
// ========================================
@Override public boolean isCaptive() { return captivity.isCaptive(); }
@Override public boolean isEnslavable() { return captivity.isEnslavable(); }
@Override public ICaptor getCaptor() { return captivity.getCaptor(); }
@Override public boolean getCapturedBy(ICaptor newCaptor) { return captivity.getCapturedBy(newCaptor); }
@Override public void free() { captivity.free(); }
@Override public void free(boolean transportState) { captivity.free(transportState); }
@Override public void transferCaptivityTo(ICaptor newCaptor) { captivity.transferCaptivityTo(newCaptor); }
@Override public boolean isTiedToPole() { return captivity.isTiedToPole(); }
@Override public boolean tieToClosestPole(int searchRadius) { return captivity.tieToClosestPole(searchRadius); }
@Override public boolean canBeKidnappedByEvents() { return captivity.canBeKidnappedByEvents(); }
@Override @Nullable public Entity getTransport() { return null; } // NPCs use vanilla leash directly
// Public methods not on IRestrainable but used externally
public void clearCaptor() { captivity.clearCaptor(); }
public boolean forceCapturedBy(ICaptor newCaptor) { return captivity.forceCapturedBy(newCaptor); }
public void restoreCaptorFromUUID() { captivity.restoreCaptorFromUUID(); }
// Collar owner check (public, used by AbstractTiedUpNpc)
public boolean isCollarOwner(Player player) { return equipment.isCollarOwner(player); }
// ========================================
// ISaleable -- INLINE (4 methods, 2 fields)
// ========================================
@Override
public boolean isForSell() {
return forSale && salePrice != null;
}
@Override
@Nullable
public ItemTask getSalePrice() {
return salePrice;
}
@Override
public void putForSale(ItemTask price) {
if (price == null) return;
forSale = true;
salePrice = price;
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} put for sale at {}",
host.getNpcName(),
price.toDisplayString()
);
}
@Override
public void cancelSale() {
if (!forSale) return;
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} sale cancelled",
host.getNpcName()
);
forSale = false;
salePrice = null;
}
// ========================================
// IRestrainableEntity -- IDENTITY (stays in facade)
// ========================================
@Override
public LivingEntity asLivingEntity() {
return entity;
}
@Override
public UUID getKidnappedUniqueId() {
return host.getUUID();
}
@Override
public String getKidnappedName() {
return host.getNpcName();
}
@Override
public String getNameFromCollar() {
ItemStack collar = getEquipment(BodyRegionV2.NECK);
if (collar.isEmpty()) {
return host.getNpcName();
}
if (collar.hasCustomHoverName()) {
return collar.getHoverName().getString();
}
return host.getNpcName();
}
@Override
public void kidnappedDropItem(ItemStack stack) {
host.dropItemStack(stack);
}
@Override
public void teleportToPosition(Position position) {
if (position == null || entity.level().isClientSide) return;
TeleportHelper.teleportEntity(entity, position);
}
// ========================================
// CROSS-CUTTING METHODS (stay in facade)
// ========================================
/**
* Untie: crosses both equipment and captivity domains.
* Drops items (if requested), clears equipment, removes speed reduction.
*/
@Override
public void untie(boolean drop) {
if (drop) {
equipment.dropBondageItems(true);
} else {
// Clear all V2 regions at once, then sync ONCE (batch)
equipment.clearAllAndSync();
}
// Remove speed reduction
RestraintEffectUtils.removeBindSpeedReduction(entity);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} freed from restraints",
host.getNpcName()
);
}
/**
* Death handler: crosses captivity (free) and equipment (unlock).
*/
@Override
public boolean onDeathKidnapped(Level world) {
if (world.isClientSide) return false;
// Check if we have a collar with cell configured
ItemStack collar = getEquipment(BodyRegionV2.NECK);
if (collar.isEmpty()) return false;
if (!(collar.getItem() instanceof ItemCollar itemCollar)) return false;
UUID cellId = itemCollar.getCellId(collar);
if (cellId == null) return false;
// Get cell position from registry
if (
!(entity.level() instanceof
net.minecraft.server.level.ServerLevel serverLevel)
) return false;
com.tiedup.remake.cells.CellDataV2 cell =
com.tiedup.remake.cells.CellRegistryV2.get(serverLevel).getCell(
cellId
);
if (cell == null) return false;
Position cellPosition = new Position(
cell.getSpawnPoint().above(),
serverLevel.dimension()
);
// We have a cell -- respawn there instead of dying
// 1. Free from captivity if captured
if (isCaptive()) {
captivity.free(false);
}
// 2. Unlock all locked items
equipment.unlockAllItems();
// 3. Heal to full
host.setHealth(entity.getMaxHealth());
// 4. Teleport to cell
teleportToPosition(cellPosition);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} respawned at cell instead of dying",
entity.getName().getString()
);
return true;
}
// ========================================
// BONDAGE SERVICE DELEGATION
// ========================================
public boolean isBondageServiceEnabled() {
return bondageService.isEnabled();
}
public String getBondageServiceMessage() {
return bondageService.getMessage();
}
/**
* Handle damage with bondage service logic.
* Call this from EntityDamsel.hurt() if service is enabled.
*
* @param source damage source
* @param amount damage amount
* @return true if damage was handled (cancel default), false otherwise
*/
public boolean handleDamageWithService(DamageSource source, float amount) {
if (!isBondageServiceEnabled()) return false;
// Only process player attacks
if (!(source.getEntity() instanceof Player player)) return false;
// Cancel actual damage (service NPCs don't take damage from hits)
return true;
}
// ========================================
// PERSISTENCE ORCHESTRATION
// ========================================
/**
* Save bondage state to NBT.
* Delegates to sub-components, handles sale state inline.
*/
public void saveToTag(CompoundTag tag) {
equipment.saveEquipmentToTag(tag);
captivity.saveCaptivityToTag(tag);
// Sale state (inline)
tag.putBoolean("ForSale", forSale);
if (salePrice != null) {
tag.put("SalePrice", salePrice.save());
}
}
/**
* Load bondage state from NBT.
* Delegates to sub-components, handles sale state inline.
*/
public void loadFromTag(CompoundTag tag) {
equipment.loadEquipmentFromTag(tag);
captivity.loadCaptivityFromTag(tag);
// Sale state (inline)
forSale = tag.getBoolean("ForSale");
if (tag.contains("SalePrice")) {
salePrice = ItemTask.load(tag.getCompound("SalePrice"));
}
}
}

View File

@@ -0,0 +1,57 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.entities.EntityDamsel;
import net.minecraft.nbt.CompoundTag;
/**
* Orchestrates NBT serialization/deserialization for all EntityDamsel components.
* Provides a single entry point for save/load operations.
*
* Phase 8: Final refactoring phase - NBT orchestration.
*/
public class DamselDataSerializer {
private final EntityDamsel entity;
public DamselDataSerializer(EntityDamsel entity) {
this.entity = entity;
}
/**
* Save all component data to NBT.
* Call this from EntityDamsel.addAdditionalSaveData().
*
* @param tag NBT compound tag to write to
*/
public void save(CompoundTag tag) {
// Phase 1: Appearance (variant, gender, name, slim arms)
entity.getAppearance().saveToTag(tag);
// Bondage + Inventory are now saved by AbstractTiedUpNpc.addAdditionalSaveData()
// Phase 6: Personality (traits, needs, relationships, commands)
entity.getPersonalitySystem().saveToTag(tag);
// Reward tracker (savior, rewards)
entity.getRewardTracker().save(tag);
}
/**
* Load all component data from NBT.
* Call this from EntityDamsel.readAdditionalSaveData().
*
* @param tag NBT compound tag to read from
*/
public void load(CompoundTag tag) {
// Phase 1: Appearance (variant, gender, name, slim arms)
entity.getAppearance().loadFromTag(tag);
// Bondage + Inventory are now loaded by AbstractTiedUpNpc.readAdditionalSaveData()
// Phase 6: Personality (traits, needs, relationships, commands)
entity.getPersonalitySystem().loadFromTag(tag);
// Reward tracker (savior, rewards)
entity.getRewardTracker().load(tag);
}
}

View File

@@ -0,0 +1,140 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.dialogue.EntityDialogueManager;
import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.world.entity.player.Player;
/**
* Manages all dialogue-related systems for EntityDamsel:
* - Direct dialogue (talkTo, actionTo)
* - Radius-based dialogue (talkToPlayersInRadius, actionToPlayersInRadius)
* - Cooldown management for dialogue categories
*
* Phase 4: Extracted from EntityDamsel.java (~180 lines, 9 methods)
*/
public class DamselDialogueHandler {
private final IDialogueHost host;
/** Track last time each dialogue category was used (for cooldowns) */
private final Map<DialogueCategory, Long> dialogueCooldowns =
new HashMap<>();
public DamselDialogueHandler(IDialogueHost host) {
this.host = host;
}
// ========================================
// DIRECT DIALOGUE
// ========================================
/**
* Send a direct message to a player.
* @param player Target player
* @param message Message to send
*/
public void talkTo(Player player, String message) {
EntityDialogueManager.talkTo(host.getEntity(), player, message);
}
/**
* Send a dialogue message to a player using a category.
* @param player Target player
* @param category Dialogue category (selects random message)
*/
public void talkTo(Player player, DialogueCategory category) {
EntityDialogueManager.talkTo(host.getEntity(), player, category);
}
/**
* Send an action message to a player (e.g. "*struggles*").
* @param player Target player
* @param action Action description
*/
public void actionTo(Player player, String action) {
EntityDialogueManager.actionTo(host.getEntity(), player, action);
}
/**
* Send an action message to a player using a category.
* @param player Target player
* @param category Action category
*/
public void actionTo(Player player, DialogueCategory category) {
EntityDialogueManager.actionTo(host.getEntity(), player, category);
}
// ========================================
// RADIUS-BASED DIALOGUE
// ========================================
/**
* Send a message to all players within radius.
* @param message Message to send
* @param radius Radius in blocks
*/
public void talkToPlayersInRadius(String message, int radius) {
EntityDialogueManager.talkToNearby(host.getEntity(), message, radius);
}
/**
* Send a dialogue message to all players within radius using a category.
* @param category Dialogue category
* @param radius Radius in blocks
*/
public void talkToPlayersInRadius(DialogueCategory category, int radius) {
EntityDialogueManager.talkToNearby(host.getEntity(), category, radius);
}
/**
* Send a dialogue message to nearby players with cooldown management.
* Only sends if cooldown has expired for this category.
*
* @param category Dialogue category
* @param radius Radius in blocks
* @return true if message was sent, false if on cooldown
*/
public boolean talkToPlayersInRadiusWithCooldown(
DialogueCategory category,
int radius
) {
long currentTick = host.getGameTime();
Long lastUsed = dialogueCooldowns.get(category);
if (
lastUsed != null &&
(currentTick - lastUsed) < ModConfig.SERVER.dialogueCooldown.get()
) {
return false; // Still on cooldown
}
dialogueCooldowns.put(category, currentTick);
EntityDialogueManager.talkToNearby(host.getEntity(), category, radius);
return true;
}
/**
* Send an action message to all players within radius.
* @param action Action description
* @param radius Radius in blocks
*/
public void actionToPlayersInRadius(String action, int radius) {
EntityDialogueManager.actionToNearby(host.getEntity(), action, radius);
}
/**
* Send an action message to all players within radius using a category.
* @param category Action category
* @param radius Radius in blocks
*/
public void actionToPlayersInRadius(DialogueCategory category, int radius) {
EntityDialogueManager.actionToNearby(
host.getEntity(),
category,
radius
);
}
}

View File

@@ -0,0 +1,500 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.personality.NpcNeeds;
import com.tiedup.remake.personality.PersonalityState;
import com.tiedup.remake.util.FoodEffects;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.Container;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.food.FoodProperties;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
/**
* Manages inventory, equipment, and feeding for a Damsel NPC.
* Responsibilities:
* - NPC inventory (expandable 9/18/27 slots)
* - Armor inventory (4 slots)
* - Equipment slots (main hand)
* - Feeding system (auto-eat, player feeding)
* - MenuProvider (GUI access)
*
* Phase 2 of EntityDamsel refactoring (8 phases total).
*/
public class DamselInventoryManager {
private final AbstractTiedUpNpc entity;
/**
* NPC inventory for carrying items.
* Default 9 slots, expandable to 18 or 27 via upgrade.
*/
private NonNullList<ItemStack> npcInventory;
/**
* Current inventory size (9 default, 18 or 27 with upgrades).
*/
private int npcInventorySize;
/**
* Armor inventory (4 slots: HEAD, CHEST, LEGS, FEET).
* Index 0 = HEAD, 1 = CHEST, 2 = LEGS, 3 = FEET.
*/
private final NonNullList<ItemStack> armorInventory;
public DamselInventoryManager(AbstractTiedUpNpc entity) {
this.entity = entity;
this.npcInventory = NonNullList.withSize(9, ItemStack.EMPTY);
this.npcInventorySize = 9;
this.armorInventory = NonNullList.withSize(4, ItemStack.EMPTY);
}
// ========================================
// EQUIPMENT SLOTS
// ========================================
/**
* Get item in equipment slot.
* Implements Mob.getItemBySlot().
*/
public ItemStack getItemBySlot(EquipmentSlot slot) {
return switch (slot) {
case HEAD -> armorInventory.get(0);
case CHEST -> armorInventory.get(1);
case LEGS -> armorInventory.get(2);
case FEET -> armorInventory.get(3);
case MAINHAND -> this.entity.getEntityData().get(
AbstractTiedUpNpc.DATA_MAIN_HAND
);
case OFFHAND -> ItemStack.EMPTY;
};
}
/**
* Set item in equipment slot.
* Implements Mob.setItemSlot().
* Note: Entity should call verifyEquippedItem before delegating to this method.
*/
public void setItemSlot(EquipmentSlot slot, ItemStack stack) {
switch (slot) {
case HEAD -> armorInventory.set(0, stack);
case CHEST -> armorInventory.set(1, stack);
case LEGS -> armorInventory.set(2, stack);
case FEET -> armorInventory.set(3, stack);
case MAINHAND -> this.entity.getEntityData().set(
AbstractTiedUpNpc.DATA_MAIN_HAND,
stack
);
case OFFHAND -> {
} // Not supported
}
}
/**
* Get armor slots iterable.
* Implements Mob.getArmorSlots().
*/
public Iterable<ItemStack> getArmorSlots() {
return this.armorInventory;
}
/**
* Get main hand item.
* Implements Mob.getMainHandItem().
*/
public ItemStack getMainHandItem() {
return this.entity.getEntityData().get(AbstractTiedUpNpc.DATA_MAIN_HAND);
}
/**
* Set the main hand item.
*
* @param stack Item to hold
*/
public void setMainHandItem(ItemStack stack) {
this.entity.getEntityData().set(AbstractTiedUpNpc.DATA_MAIN_HAND, stack);
}
// ========================================
// NPC INVENTORY
// ========================================
/**
* Get the NPC's carry inventory.
*
* @return NonNullList of ItemStacks
*/
public NonNullList<ItemStack> getNpcInventory() {
return this.npcInventory;
}
/**
* Get the current NPC inventory size.
*
* @return Inventory size (9, 18, or 27)
*/
public int getNpcInventorySize() {
return this.npcInventorySize;
}
/**
* Set/upgrade the NPC inventory size.
*
* @param newSize New size (9, 18, or 27)
*/
public void setNpcInventorySize(int newSize) {
if (newSize != 9 && newSize != 18 && newSize != 27) {
TiedUpMod.LOGGER.warn(
"[DamselInventoryManager] Invalid inventory size: {}. Must be 9, 18, or 27.",
newSize
);
return;
}
if (newSize <= this.npcInventorySize) return; // Can't downgrade
// Create new inventory preserving existing items
NonNullList<ItemStack> newInventory = NonNullList.withSize(
newSize,
ItemStack.EMPTY
);
for (int i = 0; i < this.npcInventory.size(); i++) {
newInventory.set(i, this.npcInventory.get(i));
}
this.npcInventory = newInventory;
this.npcInventorySize = newSize;
}
// ========================================
// FEEDING SYSTEM
// ========================================
/**
* Check if the NPC has any edible item in their inventory.
*
* @return true if food is available
*/
public boolean hasEdibleInInventory() {
for (int i = 0; i < this.npcInventorySize; i++) {
ItemStack stack = this.npcInventory.get(i);
if (
!stack.isEmpty() && stack.getItem().getFoodProperties() != null
) {
return true;
}
}
return false;
}
/**
* Try to eat food from inventory to satisfy hunger.
* Used by job systems and AI goals.
*
* @param personalityState Personality state for needs access
* @return true if food was consumed
*/
public boolean tryEatFromInventory(PersonalityState personalityState) {
if (personalityState == null) return false;
NpcNeeds needs = personalityState.getNeeds();
if (needs.getHunger() >= 80.0f) return false; // Not hungry enough
// Find best food item
int bestSlot = -1;
int bestNutrition = 0;
for (int i = 0; i < this.npcInventorySize; i++) {
ItemStack stack = this.npcInventory.get(i);
if (stack.isEmpty()) continue;
FoodProperties food = stack.getItem().getFoodProperties();
if (food != null && food.getNutrition() > bestNutrition) {
bestSlot = i;
bestNutrition = food.getNutrition();
}
}
if (bestSlot >= 0) {
ItemStack foodStack = this.npcInventory.get(bestSlot);
FoodProperties food = foodStack.getItem().getFoodProperties();
if (food != null) {
// Consume food
foodStack.shrink(1);
needs.feed(food.getNutrition());
personalityState.modifyMood(2);
// Play eating sound
this.entity.level().playSound(
null,
this.entity.blockPosition(),
SoundEvents.GENERIC_EAT,
SoundSource.NEUTRAL,
0.5f,
1.0f
);
return true;
}
}
return false;
}
/**
* Try to eat food from a container (chest) when inventory is empty.
* Used by job systems for autonomous feeding.
*
* @param chestPos Position of the chest to eat from
* @param personalityState Personality state for needs access
* @return true if food was consumed
*/
public boolean tryEatFromChest(
BlockPos chestPos,
PersonalityState personalityState
) {
if (personalityState == null || chestPos == null) return false;
NpcNeeds needs = personalityState.getNeeds();
if (needs.getHunger() >= 80.0f) return false;
// Get chest container
if (
!(this.entity.level().getBlockEntity(chestPos) instanceof
ChestBlockEntity chest)
) {
return false;
}
Container container = ChestBlock.getContainer(
(ChestBlock) chest.getBlockState().getBlock(),
chest.getBlockState(),
this.entity.level(),
chestPos,
false
);
if (container == null) return false;
// Find food in chest
for (int i = 0; i < container.getContainerSize(); i++) {
ItemStack stack = container.getItem(i);
if (stack.isEmpty()) continue;
FoodProperties food = stack.getItem().getFoodProperties();
if (food != null) {
// Consume food from chest
stack.shrink(1);
needs.feed(food.getNutrition());
personalityState.modifyMood(2);
// Play eating sound
this.entity.level().playSound(
null,
this.entity.blockPosition(),
SoundEvents.GENERIC_EAT,
SoundSource.NEUTRAL,
0.5f,
1.0f
);
return true;
}
}
return false;
}
/**
* Feed the NPC directly with a food item.
* Effects: restore hunger, boost mood, heal HP, increase affinity.
*
* @param player The player feeding
* @param foodStack The food item (will be consumed)
* @param personalityState Personality state for needs and relationship access
* @return true if feeding was successful
*/
public boolean feedByPlayer(
Player player,
ItemStack foodStack,
PersonalityState personalityState
) {
if (foodStack.isEmpty()) return false;
if (this.entity.level().isClientSide) return false;
FoodEffects.FeedResult result = FoodEffects.calculateFeedEffects(
foodStack
);
if (result == null) return false;
if (personalityState == null) return false;
// Restore hunger
personalityState.getNeeds().feed(result.hunger());
// Boost mood
personalityState.modifyMood(result.mood());
// Heal HP
this.entity.heal(result.heal());
// Consume the food item
foodStack.shrink(1);
// Play eating sound
this.entity.playSound(SoundEvents.GENERIC_EAT, 0.5f, 1.0f);
// Spawn heart particles
if (this.entity.level() instanceof ServerLevel serverLevel) {
serverLevel.sendParticles(
ParticleTypes.HEART,
this.entity.getX(),
this.entity.getY() + this.entity.getBbHeight(),
this.entity.getZ(),
2,
0.3,
0.2,
0.3,
0.0
);
}
// Trigger dialogue based on hunger state
String dialogueKey = personalityState.getNeeds().isStarving()
? "action.feed.starving"
: "action.feed";
// Cast safe: feedByPlayer is only called from EntityDamsel which IS an EntityDamsel
if (this.entity instanceof com.tiedup.remake.entities.EntityDamsel damsel) {
com.tiedup.remake.dialogue.EntityDialogueManager.talkByDialogueId(
damsel,
player,
dialogueKey
);
}
return true;
}
// ========================================
// MENU PROVIDER
// ========================================
/**
* Create inventory menu for player access.
* Implements MenuProvider.createMenu().
*
* @param containerId Container ID
* @param playerInventory Player's inventory
* @param player The player opening the container
* @return NpcInventoryMenu instance
*/
public AbstractContainerMenu createMenu(
int containerId,
Inventory playerInventory,
Player player
) {
// Cast safe: createMenu is only called from EntityDamsel (MenuProvider)
com.tiedup.remake.entities.EntityDamsel damsel =
(com.tiedup.remake.entities.EntityDamsel) this.entity;
return new com.tiedup.remake.entities.NpcInventoryMenu(
containerId,
playerInventory,
damsel
);
}
// ========================================
// NBT SERIALIZATION
// ========================================
/**
* Save inventory data to NBT tag.
* Writes directly to top-level keys for backward compatibility.
*/
public void saveToTag(CompoundTag tag) {
// NPC Inventory
tag.putInt("NpcInventorySize", this.npcInventorySize);
ListTag inventoryTag = new ListTag();
for (int i = 0; i < this.npcInventory.size(); i++) {
ItemStack stack = this.npcInventory.get(i);
if (!stack.isEmpty()) {
CompoundTag slotTag = new CompoundTag();
slotTag.putByte("Slot", (byte) i);
stack.save(slotTag);
inventoryTag.add(slotTag);
}
}
tag.put("NpcInventory", inventoryTag);
// Equipment (Armor + Main Hand)
ListTag armorTag = new ListTag();
for (int i = 0; i < 4; i++) {
ItemStack stack = this.armorInventory.get(i);
if (!stack.isEmpty()) {
CompoundTag slotTag = new CompoundTag();
slotTag.putByte("Slot", (byte) i);
stack.save(slotTag);
armorTag.add(slotTag);
}
}
tag.put("ArmorInventory", armorTag);
if (!this.getMainHandItem().isEmpty()) {
tag.put(
"MainHandItem",
this.getMainHandItem().save(new CompoundTag())
);
}
}
/**
* Load inventory data from NBT tag.
* Reads from top-level keys for backward compatibility.
*/
public void loadFromTag(CompoundTag tag) {
// Restore NPC Inventory
if (tag.contains("NpcInventorySize")) {
this.npcInventorySize = tag.getInt("NpcInventorySize");
this.npcInventory = NonNullList.withSize(
this.npcInventorySize,
ItemStack.EMPTY
);
}
if (tag.contains("NpcInventory")) {
ListTag inventoryTag = tag.getList("NpcInventory", 10);
for (int i = 0; i < inventoryTag.size(); i++) {
CompoundTag slotTag = inventoryTag.getCompound(i);
int slot = slotTag.getByte("Slot") & 255;
if (slot < this.npcInventory.size()) {
this.npcInventory.set(slot, ItemStack.of(slotTag));
}
}
}
// Restore Equipment (Armor + Main Hand)
if (tag.contains("ArmorInventory")) {
ListTag armorTag = tag.getList("ArmorInventory", 10);
for (int i = 0; i < armorTag.size(); i++) {
CompoundTag slotTag = armorTag.getCompound(i);
int slot = slotTag.getByte("Slot") & 255;
if (slot < 4) {
this.armorInventory.set(slot, ItemStack.of(slotTag));
}
}
}
if (tag.contains("MainHandItem")) {
this.setMainHandItem(ItemStack.of(tag.getCompound("MainHandItem")));
}
}
}

View File

@@ -0,0 +1,673 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.dialogue.EntityDialogueManager;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.personality.*;
import java.util.*;
import org.jetbrains.annotations.Nullable;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
import net.minecraft.world.entity.ai.attributes.AttributeModifier;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
/**
* Manages all personality-related systems for EntityDamsel:
* - PersonalityState (personality type, training, commands, needs, relationships)
* - Dialogue systems (idle, approach, environment, needs)
* - Rest/fatigue modifiers
* - Command execution
*
* Phase 6: Extracted from EntityDamsel.java (~700 lines, 22 methods)
*/
public class DamselPersonalitySystem {
private final com.tiedup.remake.entities.EntityDamsel entity;
private final IPersonalityTickContext context;
// ========================================
// DIALOGUE SYSTEM CONSTANTS
// ========================================
/** Minimum cooldown between idle dialogues (4 minutes). */
private static final int IDLE_DIALOGUE_COOLDOWN_MIN = 4800;
/** Maximum cooldown between idle dialogues (10 minutes). */
private static final int IDLE_DIALOGUE_COOLDOWN_MAX = 12000;
/** Cooldown per player for approach detection (2 minutes). */
private static final int APPROACH_COOLDOWN = 2400;
/** Cooldown for environmental dialogue (6 minutes). */
private static final int ENVIRONMENT_DIALOGUE_COOLDOWN = 7200;
/** Approach detection radius (blocks). */
private static final double APPROACH_RADIUS = 5.0;
// ========================================
// REST SYSTEM CONSTANTS
// ========================================
/** UUID for rest-based speed modifier. */
private static final UUID REST_SPEED_MODIFIER_UUID = UUID.fromString(
"8f4c8c9e-0e5f-5d8b-9f60-2b3c4d5e6f7a"
);
/** UUID for rest-based damage modifier. */
private static final UUID REST_DAMAGE_MODIFIER_UUID = UUID.fromString(
"9a5d9d0f-1f6a-6e9c-0a70-3c4d5e6f7a8b"
);
/** Ticks between rest modifier updates (1 second). */
private static final int REST_MODIFIER_UPDATE_INTERVAL = 20;
// ========================================
// PERSONALITY STATE
// ========================================
/**
* Complete personality state including:
* - Personality type (TIMID, FIERCE, etc.)
* - Training level (WILD → DEVOTED)
* - Secondary traits (TRAINED, DEVOTED, SUBJUGATED, etc.)
* - Needs (hunger, comfort, rest, dignity)
* - Relationships (per-player affinity, trust, fear, respect)
* - Active command (FOLLOW, STAY, PATROL, etc.)
*
* Lazy-initialized on first access (server-side only).
*/
@Nullable
private PersonalityState personalityState;
// ========================================
// DIALOGUE COOLDOWNS
// ========================================
/** Cooldown for idle dialogue (in ticks). */
private int idleDialogueCooldown = 0;
/** Tracks cooldown for player approach reactions. */
private final Map<UUID, Integer> approachCooldowns = new HashMap<>();
/** Tracks which players were nearby last tick. */
private final Set<UUID> playersNearbyLastTick = new HashSet<>();
/** Throttle for approach detection - only scan every 10 ticks (0.5 sec). */
private int approachCheckCooldown = 0;
/** Cooldown for environment dialogue. */
private int environmentDialogueCooldown = 0;
/** Track last time each dialogue category was used */
private final Map<
EntityDialogueManager.DialogueCategory,
Long
> dialogueCooldowns = new HashMap<>();
// ========================================
// REST SYSTEM MODIFIERS
// ========================================
/** Last applied speed modifier from rest system */
private float lastRestSpeedMod = 0.0f;
/** Last applied damage modifier from rest system */
private float lastRestDamageMod = 0.0f;
// ========================================
// ANTI-FLEE SYSTEM
// ========================================
/**
* World time when last whipped.
* Used to prevent fleeing for a short duration after discipline.
*/
private long lastWhipTime = 0;
// ========================================
// CONSTRUCTOR
// ========================================
public DamselPersonalitySystem(
com.tiedup.remake.entities.EntityDamsel entity,
IPersonalityTickContext context
) {
this.entity = entity;
this.context = context;
}
// ========================================
// INITIALIZATION & SYNC
// ========================================
/**
* Initialize personality state with random generation.
* Called on first spawn or lazy init.
*/
public void initializePersonality() {
// Generate random personality for Damsel
this.personalityState = PersonalityState.generateForDamsel(
entity.getUUID()
);
// Sync to client
syncPersonalityData();
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} initialized with personality: {}",
context.getAppearance().getNpcName(),
this.personalityState.getPersonality().name()
);
}
/**
* Sync personality data to clients via EntityDataAccessors.
* Called after personality changes.
*/
public void syncPersonalityData() {
if (this.personalityState != null) {
entity
.getEntityData()
.set(
com.tiedup.remake.entities.EntityDamsel.DATA_PERSONALITY_TYPE,
this.personalityState.getPersonality().name()
);
entity
.getEntityData()
.set(
com.tiedup.remake.entities.EntityDamsel.DATA_ACTIVE_COMMAND,
this.personalityState.getActiveCommand().name()
);
}
}
// ========================================
// TICK LOGIC
// ========================================
/**
* Main personality tick - delegates to PersonalityState and handles side effects.
* Call this from EntityDamsel.aiStep().
*/
public void tickPersonality() {
if (this.personalityState == null) return;
// Get current master UUID from collar (if any)
UUID masterUUID = null;
if (context.hasCollar()) {
ItemStack collar = context.getEquipment(BodyRegionV2.NECK);
if (collar.getItem() instanceof ItemCollar collarItem) {
List<UUID> owners = collarItem.getOwners(collar);
if (!owners.isEmpty()) {
masterUUID = owners.get(0);
}
}
}
// Check if it's night (for rest decay)
boolean isNight = !context.level().isDay();
// Delegate tick to PersonalityState (handles needs, mood, relationships, leash effects)
var transitions = this.personalityState.tick(
entity,
isNight,
masterUUID,
context.getBondageManager().isCaptive()
);
// Hunger is a player→NPC feature only; keep hunger full for unowned NPCs
// (prevents camp kidnappers/traders/maids from starving and complaining)
if (masterUUID == null) {
this.personalityState.getNeeds().setHunger(NpcNeeds.MAX_VALUE);
}
// Trigger dialogues for need transitions (if not gagged)
if (transitions.hasAny() && !context.getBondageManager().isGagged()) {
triggerNeedDialogue(transitions);
}
// Update rest-based attribute modifiers (every 20 ticks)
if (context.getTickCount() % 20 == 0) {
updateRestModifiers();
}
}
/**
* Trigger dialogue when a need crosses a threshold.
* Priority: critical states (starving, exhausted) > regular (hungry, tired, etc.)
*
* @param transitions The need transitions that occurred this tick
*/
private void triggerNeedDialogue(NpcNeeds.NeedTransitions transitions) {
// Find closest player to speak to
Player nearestPlayer = context.level().getNearestPlayer(entity, 16);
if (nearestPlayer == null) return;
// Priority order: critical > regular
String dialogueId = null;
// Critical states first (use regular IDs for now, context makes them urgent)
if (transitions.becameStarving) {
dialogueId = "needs.starving";
} else if (transitions.becameTired) {
dialogueId = "needs.dignity_low";
}
// Regular states
else if (transitions.becameHungry) {
dialogueId = "needs.hungry";
}
if (dialogueId != null) {
EntityDialogueManager.talkByDialogueId(
entity,
nearestPlayer,
dialogueId
);
}
}
/**
* Update attribute modifiers based on rest level.
* Tired NPCs move slower and deal less damage.
* Called every REST_MODIFIER_UPDATE_INTERVAL ticks.
*/
private void updateRestModifiers() {
if (this.personalityState == null) return;
NpcNeeds needs = this.personalityState.getNeeds();
float rest = needs.getRest();
// Calculate modifiers based on rest level
float speedMod = 0.0f;
float damageMod = 0.0f;
if (rest < 25) {
// Exhausted
speedMod = -0.3f; // -30% speed
damageMod = -0.4f; // -40% damage
} else if (rest < 50) {
// Tired
speedMod = -0.15f; // -15% speed
damageMod = -0.2f; // -20% damage
}
// Only update if changed (avoid spamming attribute system)
if (speedMod != lastRestSpeedMod) {
AttributeInstance speedAttr = entity.getAttribute(
Attributes.MOVEMENT_SPEED
);
if (speedAttr != null) {
speedAttr.removeModifier(REST_SPEED_MODIFIER_UUID);
if (speedMod != 0) {
speedAttr.addTransientModifier(
new AttributeModifier(
REST_SPEED_MODIFIER_UUID,
"Rest fatigue",
speedMod,
AttributeModifier.Operation.MULTIPLY_TOTAL
)
);
}
}
lastRestSpeedMod = speedMod;
}
if (damageMod != lastRestDamageMod) {
AttributeInstance damageAttr = entity.getAttribute(
Attributes.ATTACK_DAMAGE
);
if (damageAttr != null) {
damageAttr.removeModifier(REST_DAMAGE_MODIFIER_UUID);
if (damageMod != 0) {
damageAttr.addTransientModifier(
new AttributeModifier(
REST_DAMAGE_MODIFIER_UUID,
"Rest fatigue",
damageMod,
AttributeModifier.Operation.MULTIPLY_TOTAL
)
);
}
}
lastRestDamageMod = damageMod;
}
}
// ========================================
// DIALOGUE SYSTEMS
// ========================================
/**
* Tick idle dialogue system (random chatter).
* Call this from EntityDamsel.aiStep().
*/
public void tickIdleDialogue() {
// Decrement cooldown
if (this.idleDialogueCooldown > 0) {
this.idleDialogueCooldown--;
return;
}
// Random chance to speak FIRST (~1 in 1000 per tick = once per ~50 seconds on average)
// Check this before expensive getNearestPlayer() call - saves 99.9% of lookups
if (entity.getRandom().nextFloat() > 0.001f) {
return;
}
// Don't speak if gagged
if (context.getBondageManager().isGagged()) {
return;
}
// Don't speak if following a command (busy)
if (
this.personalityState != null &&
this.personalityState.getActiveCommand() != NpcCommand.NONE
) {
return;
}
// Find a player to talk to
Player nearestPlayer = context.level().getNearestPlayer(entity, 16);
if (nearestPlayer == null) {
return;
}
// Select dialogue based on state
String dialogueId = selectIdleDialogueId();
if (dialogueId != null) {
EntityDialogueManager.talkByDialogueId(
entity,
nearestPlayer,
dialogueId
);
// Reset cooldown (4-10 minutes)
this.idleDialogueCooldown =
IDLE_DIALOGUE_COOLDOWN_MIN +
entity
.getRandom()
.nextInt(
IDLE_DIALOGUE_COOLDOWN_MAX - IDLE_DIALOGUE_COOLDOWN_MIN
);
}
}
/**
* Select appropriate idle dialogue ID based on current state.
* Priority: needs > mood > generic idle.
*
* @return Dialogue ID or null
*/
@Nullable
private String selectIdleDialogueId() {
if (this.personalityState == null) {
return "idle.free";
}
NpcNeeds needs = this.personalityState.getNeeds();
// Priority 1: Critical needs
if (needs.isStarving()) return "needs.starving";
// Priority 2: Regular needs
if (needs.isHungry()) return "needs.hungry";
if (needs.isTired()) return "needs.dignity_low";
// Priority 3: Mood-based (if very low)
float mood = this.personalityState.getMood();
if (mood < 10) return "mood.miserable";
if (mood < 40) return "mood.sad";
// Priority 4: State-based idle
if (context.getBondageManager().isTiedUp()) {
return "idle.captive";
}
return "idle.free";
}
/**
* Detect when players approach and trigger reaction dialogue.
* Uses per-player cooldowns to avoid spam.
* Throttled to every 10 ticks (0.5 sec) to reduce entity search overhead.
* Call this from EntityDamsel.aiStep().
*/
public void tickApproachDetection() {
// Throttle approach detection - only scan every 10 ticks (0.5 sec)
if (--this.approachCheckCooldown > 0) {
return;
}
this.approachCheckCooldown = 10;
// Decrement all cooldowns
approachCooldowns
.entrySet()
.removeIf(e -> {
int newVal = e.getValue() - 10; // Decrement by 10 since we only check every 10 ticks
if (newVal <= 0) return true;
e.setValue(newVal);
return false;
});
// Don't react if gagged
if (context.getBondageManager().isGagged()) {
playersNearbyLastTick.clear();
return;
}
// Find nearby players
Set<UUID> currentNearby = new HashSet<>();
List<Player> nearbyPlayers = context
.level()
.getEntitiesOfClass(
Player.class,
entity.getBoundingBox().inflate(APPROACH_RADIUS),
p -> p.isAlive() && entity.distanceTo(p) <= APPROACH_RADIUS
);
for (Player player : nearbyPlayers) {
UUID uuid = player.getUUID();
currentNearby.add(uuid);
// Skip if player was already nearby last tick
if (playersNearbyLastTick.contains(uuid)) {
continue;
}
// Skip if on cooldown
if (approachCooldowns.containsKey(uuid)) {
continue;
}
// Player just approached - trigger reaction
onPlayerApproach(player);
approachCooldowns.put(uuid, APPROACH_COOLDOWN);
}
// Update tracking set
playersNearbyLastTick.clear();
playersNearbyLastTick.addAll(currentNearby);
}
/**
* Called when a player approaches (enters APPROACH_RADIUS).
* Triggers appropriate dialogue based on relationship.
*/
private void onPlayerApproach(Player player) {
if (this.personalityState == null) return;
// Use DialogueTriggerSystem to select appropriate approach dialogue
String dialogueId =
com.tiedup.remake.dialogue.DialogueTriggerSystem.selectApproachDialogue(
entity,
player
);
if (dialogueId != null) {
EntityDialogueManager.talkByDialogueId(entity, player, dialogueId);
}
}
/**
* Tick environment dialogue (comments on weather/time).
* Call this from EntityDamsel.aiStep().
*/
public void tickEnvironmentDialogue() {
// Decrement cooldown
if (this.environmentDialogueCooldown > 0) {
this.environmentDialogueCooldown--;
return;
}
// Random chance (~1 in 2000 per tick)
if (entity.getRandom().nextFloat() > 0.0005f) {
return;
}
// Don't speak if gagged
if (context.getBondageManager().isGagged()) {
return;
}
// Find a player to talk to
Player nearestPlayer = context.level().getNearestPlayer(entity, 16);
if (nearestPlayer == null) {
return;
}
// Use DialogueTriggerSystem to select environment dialogue
String dialogueId =
com.tiedup.remake.dialogue.DialogueTriggerSystem.selectEnvironmentDialogue(
entity
);
if (dialogueId != null) {
EntityDialogueManager.talkByDialogueId(
entity,
nearestPlayer,
dialogueId
);
this.environmentDialogueCooldown = ENVIRONMENT_DIALOGUE_COOLDOWN;
}
}
// ========================================
// PUBLIC PERSONALITY API
// ========================================
/**
* Get the personality state (lazy-init on server side).
* @return PersonalityState instance, or null on client
*/
@Nullable
public PersonalityState getPersonalityState() {
// Lazy init on server side
if (this.personalityState == null && !context.level().isClientSide) {
initializePersonality();
}
return this.personalityState;
}
/**
* Get the personality type (client-safe via synced data).
* @return PersonalityType enum value
*/
public PersonalityType getPersonalityType() {
String typeName = entity
.getEntityData()
.get(com.tiedup.remake.entities.EntityDamsel.DATA_PERSONALITY_TYPE);
try {
return PersonalityType.valueOf(typeName);
} catch (IllegalArgumentException e) {
return PersonalityType.CALM; // Default fallback
}
}
/**
* Set the personality type (debug/testing only).
* Creates a new PersonalityState with the given type, preserving training XP.
*
* @param newType The new personality type
*/
public void setPersonalityType(PersonalityType newType) {
if (context.level().isClientSide) return;
// Create new state with new personality
this.personalityState = new PersonalityState(newType);
// Sync to clients
syncPersonalityData();
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} personality set to: {}",
context.getAppearance().getNpcName(),
newType.name()
);
}
/**
* Get the active command (client-safe via synced data).
* @return NpcCommand enum value
*/
public NpcCommand getActiveCommand() {
String cmdName = entity
.getEntityData()
.get(com.tiedup.remake.entities.EntityDamsel.DATA_ACTIVE_COMMAND);
return NpcCommand.fromString(cmdName);
}
/**
* Check if recently whipped.
* @return true if whipped within last 1200 ticks (1 minute)
*/
public boolean wasRecentlyWhipped() {
return (context.level().getGameTime() - lastWhipTime) < 1200;
}
/**
* Get the world time when this NPC was last whipped.
* @return World time of last whip, or 0 if never whipped
*/
public long getLastWhipTime() {
return this.lastWhipTime;
}
/**
* Set the world time when this NPC was whipped.
* @param time Current world game time
*/
public void setLastWhipTime(long time) {
this.lastWhipTime = time;
}
// ========================================
// NBT PERSISTENCE
// ========================================
/**
* Save personality data to NBT.
* @param tag The compound tag to save to
*/
public void saveToTag(CompoundTag tag) {
if (this.personalityState != null) {
tag.put("Personality", this.personalityState.save());
}
}
/**
* Load personality data from NBT.
* @param tag The compound tag to load from
*/
public void loadFromTag(CompoundTag tag) {
if (tag.contains("Personality")) {
this.personalityState = PersonalityState.load(
tag.getCompound("Personality")
);
// CRITICAL: Must sync to client immediately after load
syncPersonalityData();
}
}
}

View File

@@ -0,0 +1,136 @@
package com.tiedup.remake.entities.damsel.components;
import java.util.UUID;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/**
* Interface for AI controller callbacks to EntityDamsel.
* Provides access to entity state and methods needed by AI systems.
*
* Phase 5: Created for DamselAIController component.
*/
public interface IAIHost {
// ========================================
// BASIC ENTITY ACCESS
// ========================================
/**
* Get the entity's level/world.
*/
Level level();
/**
* Get the entity's UUID.
*/
UUID getUUID();
/**
* Get the entity's display name.
*/
String getNpcName();
/**
* Get the entity's random instance.
*/
net.minecraft.util.RandomSource getRandom();
/**
* Get the entity's bounding box.
*/
AABB getBoundingBox();
// ========================================
// LEASH SYSTEM ACCESS
// ========================================
/**
* Check if entity is leashed.
*/
boolean isLeashed();
/**
* Get the entity holding the leash.
*/
Entity getLeashHolder();
/**
* Get current movement delta.
*/
Vec3 getDeltaMovement();
/**
* Set movement delta.
*/
void setDeltaMovement(Vec3 motion);
/**
* Get max step height.
*/
float maxUpStep();
/**
* Set max step height.
*/
void setMaxUpStep(float height);
/**
* Teleport entity to coordinates.
*/
void teleportTo(double x, double y, double z);
/**
* Calculate distance to another entity.
*/
float distanceTo(Entity other);
/**
* Get entity X position.
*/
double getX();
/**
* Get entity Y position.
*/
double getY();
/**
* Get entity Z position.
*/
double getZ();
// ========================================
// COMPONENT ACCESS
// ========================================
/**
* Get bondage manager component.
*/
DamselBondageManager getBondageManager();
/**
* Get personality system component.
*/
DamselPersonalitySystem getPersonalitySystem();
// ========================================
// BONDAGE STATE QUERIES (delegated)
// ========================================
/**
* Check if entity is tied up.
*/
boolean isTiedUp();
/**
* Check if entity is gagged.
*/
boolean isGagged();
/**
* Check if entity is a captive.
*/
boolean isCaptive();
}

View File

@@ -0,0 +1,84 @@
package com.tiedup.remake.entities.damsel.components;
import net.minecraft.world.level.Level;
/**
* Interface for animation controller callbacks to EntityDamsel.
* Provides access to entity state and methods needed by animation systems.
*
* Phase 3: Created for DamselAnimationController component.
*/
public interface IAnimationHost {
// ========================================
// LEVEL ACCESS
// ========================================
/**
* Get the entity's level/world.
*/
Level level();
// ========================================
// BODY ROTATION ACCESS
// ========================================
/**
* Get current body rotation Y.
*/
float getYBodyRot();
/**
* Set body rotation Y.
*/
void setYBodyRot(float rot);
/**
* Get previous body rotation Y.
*/
float getYBodyRotO();
/**
* Set previous body rotation Y.
*/
void setYBodyRotO(float rot);
// ========================================
// POSE STATE QUERIES
// ========================================
/**
* Check if entity is in "dog pose" (hogtied with armbinder).
* This is determined by bondage manager.
*/
boolean isDogPose();
/**
* Check if entity is sitting (from synced entity data).
*/
boolean isSittingFromData();
/**
* Set sitting state (to synced entity data).
*/
void setSittingToData(boolean sitting);
/**
* Check if entity is kneeling (from synced entity data).
*/
boolean isKneelingFromData();
/**
* Set kneeling state (to synced entity data).
*/
void setKneelingToData(boolean kneeling);
/**
* Check if entity is struggling (from synced entity data).
*/
boolean isStrugglingFromData();
/**
* Set struggling state (to synced entity data).
*/
void setStrugglingToData(boolean struggling);
}

View File

@@ -0,0 +1,88 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.personality.PersonalityState;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
/**
* Interface for EntityDamsel to provide callbacks to DamselBondageManager.
* This prevents circular dependencies while allowing the bondage component
* to access necessary host entity functionality.
*
* Phase 7: Created for DamselBondageManager extraction.
*/
public interface IBondageHost {
/**
* Get the personality state for conditioning/training logic.
* @return personality state instance
*/
PersonalityState getPersonalityState();
/**
* Get the inventory manager for equipment access.
* @return inventory manager instance
*/
com.tiedup.remake.entities.damsel.components.DamselInventoryManager getInventory();
/**
* Drop an item stack at this entity's location.
* @param stack item to drop
*/
void dropItemStack(ItemStack stack);
/**
* Play a sound at this entity's location.
* @param sound sound event to play
*/
void playSound(SoundEvent sound);
/**
* Set the entity's health.
* @param health new health value
*/
void setHealth(float health);
/**
* Remove this entity from the world.
* @param reason removal reason
*/
void remove(Entity.RemovalReason reason);
/**
* Get the level this entity is in.
* @return level instance
*/
Level level();
/**
* Get the block position of this entity.
* @return block position
*/
BlockPos blockPosition();
/**
* Get the UUID of this entity.
* @return entity UUID
*/
UUID getUUID();
/**
* Get the damsel's name for logging.
* @return damsel name
*/
String getNpcName();
/**
* Broadcast dialogue to nearby players.
* @param category dialogue category
* @param radius search radius
*/
void talkToPlayersInRadius(
com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category,
int radius
);
}

View File

@@ -0,0 +1,39 @@
package com.tiedup.remake.entities.damsel.components;
import java.util.List;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB;
/**
* Interface for dialogue handler callbacks to EntityDamsel.
* Provides access to entity state and methods needed by dialogue systems.
*
* Phase 4: Created for DamselDialogueHandler component.
*/
public interface IDialogueHost {
/**
* Get the entity's level/world.
*/
Level level();
/**
* Get the entity's bounding box for radius detection.
*/
AABB getBoundingBox();
/**
* Get the entity itself for dialogue source.
*/
com.tiedup.remake.entities.EntityDamsel getEntity();
/**
* Get current game time for cooldown tracking.
*/
long getGameTime();
/**
* Find nearby players within radius.
*/
List<Player> findNearbyPlayers(double radius);
}

View File

@@ -0,0 +1,113 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
/**
* Interface for EntityDamsel to provide callbacks to DamselPersonalitySystem.
* This prevents circular dependencies while allowing the personality component
* to access necessary host entity functionality.
*
* Phase 6: Created for DamselPersonalitySystem extraction.
*/
public interface IPersonalityTickContext {
/**
* Get the level this entity is in.
* @return level instance
*/
Level level();
/**
* Get the current tick count for this entity.
* @return tick count
*/
int getTickCount();
/**
* Check if this entity is currently leashed.
* @return true if leashed
*/
boolean isLeashed();
/**
* Get the entity's current movement delta.
* @return movement vector
*/
Vec3 getDeltaMovement();
/**
* Check if this entity has a collar equipped.
* @return true if collar present
*/
boolean hasCollar();
/**
* Get the item equipped in a V2 body region.
* @param region The body region to query
* @return The equipped ItemStack, or empty if not present
*/
ItemStack getEquipment(BodyRegionV2 region);
/**
* Get the bondage manager for checking bondage state.
* @return bondage manager instance
*/
DamselBondageManager getBondageManager();
/**
* Get the inventory manager for feeding logic.
* @return inventory manager instance
*/
DamselInventoryManager getInventoryManager();
/**
* Get the appearance manager for name access.
* @return appearance instance
*/
DamselAppearance getAppearance();
/**
* Get the entity's UUID.
* @return entity UUID
*/
UUID getUUID();
/**
* Get the block position of this entity.
* @return block position
*/
BlockPos blockPosition();
/**
* Stop the entity's navigation.
*/
void stopNavigation();
/**
* Get the entity's leash holder entity.
* @return leash holder, or null if not leashed
*/
net.minecraft.world.entity.Entity getLeashHolder();
/**
* Talk to nearby players with a dialogue message.
* @param category dialogue category
* @param radius search radius
*/
void talkToPlayersInRadius(
com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory category,
int radius
);
/**
* Talk directly to a specific player.
* @param player target player
* @param message message text
*/
void talkTo(Player player, String message);
}

View File

@@ -0,0 +1,305 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.state.ICaptor;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.util.RestraintEffectUtils;
import java.util.UUID;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
/**
* Captivity lifecycle for NPCs: capture, free, transfer, leash, captor restoration.
*
* <p>Extracted from DamselBondageManager (H9 split). Owns the captor reference
* and pending UUID for restoration after world load.</p>
*/
public class NpcCaptivityManager {
// ========================================
// FIELDS
// ========================================
private final AbstractTiedUpNpc entity;
private final IBondageHost host;
private final NpcEquipmentManager equipment;
/** Current captor (if captured) */
private ICaptor captor;
/** Pending captor UUID for restoration after world load */
private UUID pendingCaptorUUID;
// ========================================
// CONSTRUCTOR
// ========================================
public NpcCaptivityManager(
AbstractTiedUpNpc entity,
IBondageHost host,
NpcEquipmentManager equipment
) {
this.entity = entity;
this.host = host;
this.equipment = equipment;
}
// ========================================
// CAPTIVITY STATE
// ========================================
public boolean isCaptive() {
return entity.isLeashed();
}
public boolean isEnslavable() {
if (entity.isLeashed()) return false;
return equipment.isTiedUp();
}
public ICaptor getCaptor() {
return captor;
}
/**
* Clear the captor reference without triggering free() or untie().
* Used when player detaches leash -- only clears the internal reference.
*/
public void clearCaptor() {
this.captor = null;
}
// ========================================
// CAPTURE LIFECYCLE
// ========================================
public boolean getCapturedBy(ICaptor newCaptor) {
if (newCaptor == null || !isEnslavable()) {
return false;
}
if (!newCaptor.canCapture(this.asFacadeKidnapped())) {
return false;
}
// Use vanilla leash mechanic
Entity captorEntity = newCaptor.getEntity();
if (captorEntity != null) {
entity.setLeashedTo(captorEntity, true);
this.captor = newCaptor;
newCaptor.addCaptive(entity);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} captured by {}",
host.getNpcName(),
captorEntity.getName().getString()
);
return true;
}
return false;
}
/**
* Force capture for managed camp operations (dogwalk, extract).
* Bypasses canCapture() PrisonerManager state check since the
* caller is responsible for managing state transitions.
*/
public boolean forceCapturedBy(ICaptor newCaptor) {
if (newCaptor == null || !isEnslavable()) {
return false;
}
// Skip canCapture() - caller manages PrisonerManager state
Entity captorEntity = newCaptor.getEntity();
if (captorEntity != null) {
entity.setLeashedTo(captorEntity, true);
this.captor = newCaptor;
newCaptor.addCaptive(entity);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} force-captured by {} (managed op)",
host.getNpcName(),
captorEntity.getName().getString()
);
return true;
}
return false;
}
public void free() {
free(true);
}
public void free(boolean transportState) {
if (!isCaptive()) return;
if (transportState) {
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} freed from captivity",
host.getNpcName()
);
// Broadcast freed dialogue
if (!entity.level().isClientSide()) {
host.talkToPlayersInRadius(
com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.DAMSEL_FREED,
ModConfig.SERVER.dialogueRadius.get()
);
}
} else {
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} transferred (leash dropped)",
host.getNpcName()
);
}
// Drop leash (only drop item on true freedom, not internal transfers like imprisonment)
entity.dropLeash(true, transportState);
// Clear captor
if (captor != null) {
captor.removeCaptive(entity, transportState);
captor = null;
}
}
public void transferCaptivityTo(ICaptor newCaptor) {
if (!isCaptive()) return;
ICaptor currentCaptor = getCaptor();
if (currentCaptor == null) return;
if (!currentCaptor.allowCaptiveTransfer()) {
TiedUpMod.LOGGER.warn(
"[EntityDamsel] {} transfer blocked by current captor",
host.getNpcName()
);
return;
}
// Free from current captor (keep transport entity)
free(false);
// Capture by new captor
getCapturedBy(newCaptor);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} transferred to new captor",
host.getNpcName()
);
}
// ========================================
// POLE / LEASH
// ========================================
public boolean isTiedToPole() {
Entity leashHolder = entity.getLeashHolder();
return (
leashHolder instanceof
net.minecraft.world.entity.decoration.LeashFenceKnotEntity
);
}
public boolean tieToClosestPole(int searchRadius) {
return RestraintEffectUtils.tieToClosestPole(entity, searchRadius);
}
public boolean canBeKidnappedByEvents() {
return !isCaptive() && !equipment.hasCollar();
}
// ========================================
// CAPTOR RESTORATION
// ========================================
/**
* Attempt to restore captor from pending UUID.
* Call this from EntityDamsel.tick() until successful.
*/
public void restoreCaptorFromUUID() {
if (pendingCaptorUUID == null) return;
// Search for entity with matching UUID
for (Entity searchEntity : entity
.level()
.getEntities(entity, entity.getBoundingBox().inflate(64))) {
if (searchEntity.getUUID().equals(pendingCaptorUUID)) {
if (searchEntity instanceof ICaptor kidnapper) {
captor = kidnapper;
kidnapper.addCaptive(entity);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} restored captor relationship with {}",
host.getNpcName(),
searchEntity.getName().getString()
);
}
pendingCaptorUUID = null;
return;
}
}
// Also check players (PlayerBindState.getCaptorManager() is ICaptor)
for (net.minecraft.world.entity.player.Player player : entity
.level()
.players()) {
if (player.getUUID().equals(pendingCaptorUUID)) {
PlayerBindState bindState =
PlayerBindState.getInstance(player);
if (bindState != null) {
ICaptor kidnapper = bindState.getCaptorManager();
if (kidnapper != null) {
captor = kidnapper;
kidnapper.addCaptive(entity);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} restored captor relationship with player {}",
host.getNpcName(),
player.getName().getString()
);
}
}
pendingCaptorUUID = null;
return;
}
}
}
// ========================================
// PERSISTENCE
// ========================================
/**
* Save captivity-related state to NBT.
*/
public void saveCaptivityToTag(CompoundTag tag) {
if (captor != null && captor.getEntity() != null) {
tag.putUUID("CaptorUUID", captor.getEntity().getUUID());
}
}
/**
* Load captivity-related state from NBT.
*/
public void loadCaptivityFromTag(CompoundTag tag) {
if (tag.contains("CaptorUUID")) {
pendingCaptorUUID = tag.getUUID("CaptorUUID");
} else if (tag.contains("MasterUUID")) {
// Legacy support
pendingCaptorUUID = tag.getUUID("MasterUUID");
}
}
// ========================================
// INTERNAL
// ========================================
/**
* Get the facade IRestrainable reference for canCapture() calls.
* This is the entity itself (AbstractTiedUpNpc implements IRestrainable via delegation).
*/
private com.tiedup.remake.state.IRestrainable asFacadeKidnapped() {
return (com.tiedup.remake.state.IRestrainable) entity;
}
}

View File

@@ -0,0 +1,940 @@
package com.tiedup.remake.entities.damsel.components;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.state.IRestrainableEntity;
import com.tiedup.remake.util.RestraintEffectUtils;
import com.tiedup.remake.util.TiedUpSounds;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipment;
import java.util.Map;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.Tag;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
/**
* Equipment CRUD, coercion, bulk operations, and permissions for NPC bondage items.
*
* <p>Extracted from DamselBondageManager (H9 split). Owns all equipment state
* and delegates to V2BondageEquipment for storage. Does NOT own captivity state.</p>
*
* <p>Cross-cutting note: {@code untie()} stays in the facade because it calls
* both equipment and captivity methods.</p>
*/
public class NpcEquipmentManager {
// ========================================
// FIELDS
// ========================================
private final AbstractTiedUpNpc entity;
private final IBondageHost host;
/** Maps legacy V1 NBT key names to V2 body regions for migration. */
static final Map<String, BodyRegionV2> V1_TO_V2 = Map.of(
"Bind", BodyRegionV2.ARMS,
"Gag", BodyRegionV2.MOUTH,
"Blindfold", BodyRegionV2.EYES,
"Earplugs", BodyRegionV2.EARS,
"Collar", BodyRegionV2.NECK,
"Clothes", BodyRegionV2.TORSO,
"Mittens", BodyRegionV2.HANDS
);
// ========================================
// CONSTRUCTOR
// ========================================
public NpcEquipmentManager(AbstractTiedUpNpc entity, IBondageHost host) {
this.entity = entity;
this.host = host;
}
// ========================================
// V2 EQUIPMENT HELPERS
// ========================================
/** Get the V2 equipment storage from the parent entity, cast to concrete type. */
V2BondageEquipment getEquipment() {
return (V2BondageEquipment) entity.getV2Equipment();
}
/** Get the item equipped in a V2 body region. */
public ItemStack getInRegion(BodyRegionV2 region) {
return getEquipment().getInRegion(region);
}
/** Serialize V2 equipment to synched entity data (copy-on-write). */
void syncToEntityData() {
entity.syncV2Equipment();
}
// ========================================
// STATE QUERIES (14 methods)
// ========================================
public boolean isTiedUp() {
return getEquipment().isRegionOccupied(BodyRegionV2.ARMS);
}
public boolean isGagged() {
return getEquipment().isRegionOccupied(BodyRegionV2.MOUTH);
}
public boolean isBlindfolded() {
return getEquipment().isRegionOccupied(BodyRegionV2.EYES);
}
public boolean hasEarplugs() {
return getEquipment().isRegionOccupied(BodyRegionV2.EARS);
}
public boolean hasCollar() {
return getEquipment().isRegionOccupied(BodyRegionV2.NECK);
}
public boolean hasClothes() {
return getEquipment().isRegionOccupied(BodyRegionV2.TORSO);
}
public boolean hasMittens() {
return getEquipment().isRegionOccupied(BodyRegionV2.HANDS);
}
public boolean isBoundAndGagged() {
return isTiedUp() && isGagged();
}
public boolean hasGaggingEffect() {
ItemStack gag = getCurrentGag();
if (gag.isEmpty()) return false;
return (
gag.getItem() instanceof
com.tiedup.remake.items.base.IHasGaggingEffect
);
}
public boolean hasBlindingEffect() {
ItemStack blindfold = getCurrentBlindfold();
if (blindfold.isEmpty()) return false;
return (
blindfold.getItem() instanceof
com.tiedup.remake.items.base.IHasBlindingEffect
);
}
public boolean hasKnives() {
return false; // NPCs don't have inventories for knives
}
public boolean hasClothesWithSmallArms() {
// For damsels, delegate to appearance component
return entity.hasSlimArms();
}
public boolean hasLockedCollar() {
ItemStack collar = getCurrentCollar();
if (collar.isEmpty()) return false;
if (collar.getItem() instanceof ILockable lockable) {
return lockable.isLocked(collar);
}
return false;
}
public boolean hasNamedCollar() {
ItemStack collar = getCurrentCollar();
if (collar.isEmpty()) return false;
return collar.hasCustomHoverName();
}
// ========================================
// EQUIPMENT GETTERS (7 methods)
// ========================================
public ItemStack getCurrentBind() {
return getEquipment().getInRegion(BodyRegionV2.ARMS);
}
public ItemStack getCurrentGag() {
return getEquipment().getInRegion(BodyRegionV2.MOUTH);
}
public ItemStack getCurrentBlindfold() {
return getEquipment().getInRegion(BodyRegionV2.EYES);
}
public ItemStack getCurrentEarplugs() {
return getEquipment().getInRegion(BodyRegionV2.EARS);
}
public ItemStack getCurrentCollar() {
return getEquipment().getInRegion(BodyRegionV2.NECK);
}
public ItemStack getCurrentClothes() {
return getEquipment().getInRegion(BodyRegionV2.TORSO);
}
public ItemStack getCurrentMittens() {
return getEquipment().getInRegion(BodyRegionV2.HANDS);
}
// ========================================
// EQUIPMENT PUTTERS (7 methods)
// ========================================
public void putBindOn(ItemStack bind) {
if (bind.isEmpty()) return;
boolean wasAlreadyTied = isTiedUp();
getEquipment().setInRegion(BodyRegionV2.ARMS, bind.copy());
syncToEntityData();
// Call onEquipped hook
if (bind.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onEquipped(bind, entity);
}
// Apply speed reduction
RestraintEffectUtils.applyBindSpeedReduction(entity);
// Play sound
TiedUpSounds.playBindSound(entity);
// Stop movement
entity.getNavigation().stop();
// Broadcast captured dialogue if just got tied
if (!wasAlreadyTied && !entity.level().isClientSide()) {
host.talkToPlayersInRadius(
com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.DAMSEL_CAPTURED,
ModConfig.SERVER.dialogueRadius.get()
);
}
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} tied up",
host.getNpcName()
);
}
public void putGagOn(ItemStack gag) {
if (gag.isEmpty()) return;
getEquipment().setInRegion(BodyRegionV2.MOUTH, gag.copy());
syncToEntityData();
if (gag.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onEquipped(gag, entity);
}
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} gagged",
host.getNpcName()
);
}
public void putBlindfoldOn(ItemStack blindfold) {
if (blindfold.isEmpty()) return;
getEquipment().setInRegion(BodyRegionV2.EYES, blindfold.copy());
syncToEntityData();
if (blindfold.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onEquipped(blindfold, entity);
}
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} blindfolded",
host.getNpcName()
);
}
public void putEarplugsOn(ItemStack earplugs) {
if (earplugs.isEmpty()) return;
getEquipment().setInRegion(BodyRegionV2.EARS, earplugs.copy());
syncToEntityData();
if (earplugs.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onEquipped(earplugs, entity);
}
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} has earplugs",
host.getNpcName()
);
}
public void putCollarOn(ItemStack collar) {
if (collar.isEmpty()) return;
getEquipment().setInRegion(BodyRegionV2.NECK, collar.copy());
syncToEntityData();
if (collar.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onEquipped(collar, entity);
}
// Play lock sound
TiedUpSounds.playLockSound(entity);
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} collared",
host.getNpcName()
);
}
public void putClothesOn(ItemStack clothes) {
if (clothes.isEmpty()) return;
getEquipment().setInRegion(BodyRegionV2.TORSO, clothes.copy());
syncToEntityData();
if (clothes.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onEquipped(clothes, entity);
}
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} changed clothes",
host.getNpcName()
);
}
public void putMittensOn(ItemStack mittens) {
if (mittens.isEmpty()) return;
getEquipment().setInRegion(BodyRegionV2.HANDS, mittens.copy());
syncToEntityData();
if (mittens.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onEquipped(mittens, entity);
}
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} has mittens",
host.getNpcName()
);
}
// ========================================
// EQUIPMENT REMOVERS (8 methods + 1 force-remove)
// ========================================
/**
* Force-remove the item from a region, bypassing ILockable lock checks.
* Runs the same side effects as the per-region takeXOff methods (sync, onUnequipped, etc.).
* Used by {@link DamselBondageManager#forceUnequip(BodyRegionV2)}.
*
* @param region The body region to force-remove from
* @return The removed ItemStack, or {@link ItemStack#EMPTY} if the slot was empty
*/
public ItemStack forceRemoveFromRegion(BodyRegionV2 region) {
// NECK and TORSO already have correct force/no-lock handling
if (region == BodyRegionV2.NECK) return takeCollarOff(true);
if (region == BodyRegionV2.TORSO) return takeClothesOff();
ItemStack current = getInRegion(region);
if (current.isEmpty()) return ItemStack.EMPTY;
// Clear slot + sync (no lock check — that's the point of force)
getEquipment().setInRegion(region, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
// Region-specific side effects
if (region == BodyRegionV2.ARMS) {
RestraintEffectUtils.removeBindSpeedReduction(entity);
TiedUpMod.LOGGER.debug("[EntityDamsel] {} force-untied", host.getNpcName());
}
return current;
}
public ItemStack takeBindOff() {
ItemStack current = getCurrentBind();
if (current.isEmpty()) return ItemStack.EMPTY;
// Check if locked
if (
current.getItem() instanceof ILockable lockable &&
lockable.isLocked(current)
) {
return ItemStack.EMPTY;
}
getEquipment().setInRegion(BodyRegionV2.ARMS, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
// Remove speed reduction
RestraintEffectUtils.removeBindSpeedReduction(entity);
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} untied",
host.getNpcName()
);
return current;
}
public ItemStack takeGagOff() {
ItemStack current = getCurrentGag();
if (current.isEmpty()) return ItemStack.EMPTY;
if (
current.getItem() instanceof ILockable lockable &&
lockable.isLocked(current)
) {
return ItemStack.EMPTY;
}
getEquipment().setInRegion(BodyRegionV2.MOUTH, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
return current;
}
public ItemStack takeBlindfoldOff() {
ItemStack current = getCurrentBlindfold();
if (current.isEmpty()) return ItemStack.EMPTY;
if (
current.getItem() instanceof ILockable lockable &&
lockable.isLocked(current)
) {
return ItemStack.EMPTY;
}
getEquipment().setInRegion(BodyRegionV2.EYES, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
return current;
}
public ItemStack takeEarplugsOff() {
ItemStack current = getCurrentEarplugs();
if (current.isEmpty()) return ItemStack.EMPTY;
if (
current.getItem() instanceof ILockable lockable &&
lockable.isLocked(current)
) {
return ItemStack.EMPTY;
}
getEquipment().setInRegion(BodyRegionV2.EARS, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
return current;
}
public ItemStack takeCollarOff() {
return takeCollarOff(false);
}
public ItemStack takeCollarOff(boolean force) {
ItemStack current = getCurrentCollar();
if (current.isEmpty()) return ItemStack.EMPTY;
if (
!force &&
current.getItem() instanceof ILockable lockable &&
lockable.isLocked(current)
) {
return ItemStack.EMPTY;
}
getEquipment().setInRegion(BodyRegionV2.NECK, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
// Play unlock sound
TiedUpSounds.playUnlockSound(entity);
return current;
}
public ItemStack takeClothesOff() {
ItemStack current = getCurrentClothes();
if (current.isEmpty()) return ItemStack.EMPTY;
getEquipment().setInRegion(BodyRegionV2.TORSO, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
return current;
}
public ItemStack takeMittensOff() {
ItemStack current = getCurrentMittens();
if (current.isEmpty()) return ItemStack.EMPTY;
if (
current.getItem() instanceof ILockable lockable &&
lockable.isLocked(current)
) {
return ItemStack.EMPTY;
}
getEquipment().setInRegion(BodyRegionV2.HANDS, ItemStack.EMPTY);
syncToEntityData();
if (current.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(current, entity);
}
return current;
}
// ========================================
// EQUIPMENT REPLACERS (14 methods)
// ========================================
public ItemStack replaceBind(ItemStack newBind) {
ItemStack oldBind = takeBindOff();
if (!oldBind.isEmpty() || !newBind.isEmpty()) {
putBindOn(newBind);
}
return oldBind;
}
public ItemStack replaceBind(ItemStack newBind, boolean force) {
ItemStack oldBind = force ? getCurrentBind() : takeBindOff();
if (!oldBind.isEmpty() || !newBind.isEmpty()) {
if (force && !oldBind.isEmpty()) {
getEquipment().setInRegion(BodyRegionV2.ARMS, ItemStack.EMPTY);
// Don't sync here -- putBindOn will sync
if (oldBind.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(oldBind, entity);
}
}
putBindOn(newBind);
}
return oldBind;
}
public ItemStack replaceGag(ItemStack newGag) {
return replaceGag(newGag, false);
}
public ItemStack replaceGag(ItemStack newGag, boolean force) {
ItemStack oldGag = force ? getCurrentGag() : takeGagOff();
if (!oldGag.isEmpty() || !newGag.isEmpty()) {
if (force && !oldGag.isEmpty()) {
getEquipment().setInRegion(BodyRegionV2.MOUTH, ItemStack.EMPTY);
if (oldGag.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(oldGag, entity);
}
}
putGagOn(newGag);
}
return oldGag;
}
public ItemStack replaceBlindfold(ItemStack newBlindfold) {
return replaceBlindfold(newBlindfold, false);
}
public ItemStack replaceBlindfold(ItemStack newBlindfold, boolean force) {
ItemStack oldBlindfold = force
? getCurrentBlindfold()
: takeBlindfoldOff();
if (!oldBlindfold.isEmpty() || !newBlindfold.isEmpty()) {
if (force && !oldBlindfold.isEmpty()) {
getEquipment().setInRegion(BodyRegionV2.EYES, ItemStack.EMPTY);
if (oldBlindfold.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(oldBlindfold, entity);
}
}
putBlindfoldOn(newBlindfold);
}
return oldBlindfold;
}
public ItemStack replaceEarplugs(ItemStack newEarplugs) {
return replaceEarplugs(newEarplugs, false);
}
public ItemStack replaceEarplugs(ItemStack newEarplugs, boolean force) {
ItemStack oldEarplugs = force
? getCurrentEarplugs()
: takeEarplugsOff();
if (!oldEarplugs.isEmpty() || !newEarplugs.isEmpty()) {
if (force && !oldEarplugs.isEmpty()) {
getEquipment().setInRegion(BodyRegionV2.EARS, ItemStack.EMPTY);
if (oldEarplugs.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(oldEarplugs, entity);
}
}
putEarplugsOn(newEarplugs);
}
return oldEarplugs;
}
public ItemStack replaceCollar(ItemStack newCollar) {
return replaceCollar(newCollar, false);
}
public ItemStack replaceCollar(ItemStack newCollar, boolean force) {
ItemStack oldCollar = takeCollarOff(force);
if (!oldCollar.isEmpty() || !newCollar.isEmpty()) {
putCollarOn(newCollar);
}
return oldCollar;
}
public ItemStack replaceClothes(ItemStack newClothes) {
return replaceClothes(newClothes, false);
}
public ItemStack replaceClothes(ItemStack newClothes, boolean force) {
ItemStack oldClothes = takeClothesOff();
if (!oldClothes.isEmpty() || !newClothes.isEmpty()) {
putClothesOn(newClothes);
}
return oldClothes;
}
public ItemStack replaceMittens(ItemStack newMittens) {
return replaceMittens(newMittens, false);
}
public ItemStack replaceMittens(ItemStack newMittens, boolean force) {
ItemStack oldMittens = force ? getCurrentMittens() : takeMittensOff();
if (!oldMittens.isEmpty() || !newMittens.isEmpty()) {
if (force && !oldMittens.isEmpty()) {
getEquipment().setInRegion(BodyRegionV2.HANDS, ItemStack.EMPTY);
if (oldMittens.getItem() instanceof IV2BondageItem bondageItem) {
bondageItem.onUnequipped(oldMittens, entity);
}
}
putMittensOn(newMittens);
}
return oldMittens;
}
// ========================================
// BULK OPERATIONS
// ========================================
public void applyBondage(
ItemStack bind,
ItemStack gag,
ItemStack blindfold,
ItemStack earplugs,
ItemStack collar,
ItemStack clothes
) {
if (!bind.isEmpty()) putBindOn(bind);
if (!gag.isEmpty()) putGagOn(gag);
if (!blindfold.isEmpty()) putBlindfoldOn(blindfold);
if (!earplugs.isEmpty()) putEarplugsOn(earplugs);
if (!collar.isEmpty()) putCollarOn(collar);
if (!clothes.isEmpty()) putClothesOn(clothes);
TiedUpMod.LOGGER.info(
"[EntityDamsel] {} fully restrained",
host.getNpcName()
);
}
/**
* Drop bondage items on the ground. Convenience overload.
*/
public void dropBondageItems(boolean drop) {
dropBondageItems(drop, true, true, true, true, true, true);
}
/**
* Drop bondage items with optional bind control. Convenience overload.
*/
public void dropBondageItems(boolean drop, boolean dropBind) {
dropBondageItems(drop, dropBind, true, true, true, true, true);
}
/**
* Drop bondage items with full granular control.
*
* @param drop master switch -- if false, nothing is dropped
*/
public void dropBondageItems(
boolean drop,
boolean dropBind,
boolean dropGag,
boolean dropBlindfold,
boolean dropEarplugs,
boolean dropCollar,
boolean dropClothes
) {
if (!drop) return;
if (dropBind) {
ItemStack bind = takeBindOff();
if (!bind.isEmpty()) host.dropItemStack(bind);
}
if (dropGag) {
ItemStack gag = takeGagOff();
if (!gag.isEmpty()) host.dropItemStack(gag);
}
if (dropBlindfold) {
ItemStack blindfold = takeBlindfoldOff();
if (!blindfold.isEmpty()) host.dropItemStack(blindfold);
}
if (dropEarplugs) {
ItemStack earplugs = takeEarplugsOff();
if (!earplugs.isEmpty()) host.dropItemStack(earplugs);
}
if (dropCollar) {
ItemStack collar = takeCollarOff();
if (!collar.isEmpty()) host.dropItemStack(collar);
}
if (dropClothes) {
ItemStack clothes = takeClothesOff();
if (!clothes.isEmpty()) host.dropItemStack(clothes);
}
// Always drop mittens if dropping
ItemStack mittens = takeMittensOff();
if (!mittens.isEmpty()) host.dropItemStack(mittens);
}
public void dropClothes() {
ItemStack clothes = takeClothesOff();
if (!clothes.isEmpty()) {
host.dropItemStack(clothes);
}
}
public int getBondageItemsWhichCanBeRemovedCount() {
int count = 0;
if (!getCurrentBind().isEmpty() && !isLocked(getCurrentBind())) count++;
if (!getCurrentGag().isEmpty() && !isLocked(getCurrentGag())) count++;
if (!getCurrentBlindfold().isEmpty() && !isLocked(getCurrentBlindfold())) count++;
if (!getCurrentEarplugs().isEmpty() && !isLocked(getCurrentEarplugs())) count++;
if (!getCurrentCollar().isEmpty() && !isLocked(getCurrentCollar())) count++;
if (!getCurrentClothes().isEmpty() && !isLocked(getCurrentClothes())) count++;
return count;
}
// ========================================
// ADVANCED QUERIES
// ========================================
public boolean canBeTiedUp() {
return !isTiedUp();
}
// ========================================
// PERMISSIONS
// ========================================
public boolean canTakeOffClothes(Player player) {
return true; // NPCs don't have permission restrictions
}
public boolean canChangeClothes(Player player) {
return true;
}
public boolean canChangeClothes() {
return true;
}
// ========================================
// COLLAR OWNER CHECK
// ========================================
/**
* Check if a player is an owner of this NPC's collar.
* Returns true if no collar (anyone can interact with uncolored NPCs).
*/
public boolean isCollarOwner(Player player) {
if (!hasCollar()) return true;
ItemStack collar = getCurrentCollar();
if (!(collar.getItem() instanceof ItemCollar collarItem)) return true;
return collarItem.isOwner(collar, player);
}
// ========================================
// COERCION
// ========================================
public void tighten(Player tightener) {
if (!isTiedUp()) return;
TiedUpSounds.playSound(
entity,
net.minecraft.sounds.SoundEvents.PLAYER_ATTACK_NODAMAGE,
1.0f
);
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} tightened by {}",
host.getNpcName(),
tightener.getName().getString()
);
}
public void applyChloroform(int duration) {
RestraintEffectUtils.applyChloroformEffects(entity, duration);
}
public void shockKidnapped() {
shockKidnapped("", 1.0f);
}
public void shockKidnapped(String messageAddon, float damage) {
// Play shock sound
TiedUpSounds.playSound(
entity,
net.minecraft.sounds.SoundEvents.LIGHTNING_BOLT_IMPACT,
0.5f
);
// Apply damage
entity.hurt(entity.damageSources().magic(), damage);
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} shocked ({} damage)",
host.getNpcName(),
damage
);
}
public void takeBondageItemBy(IRestrainableEntity taker, int slotIndex) {
ItemStack taken = ItemStack.EMPTY;
switch (slotIndex) {
case 0 -> taken = takeBindOff();
case 1 -> taken = takeGagOff();
case 2 -> taken = takeBlindfoldOff();
case 3 -> taken = takeEarplugsOff();
case 4 -> taken = takeCollarOff();
case 5 -> taken = takeClothesOff();
}
if (!taken.isEmpty()) {
taker.kidnappedDropItem(taken);
TiedUpMod.LOGGER.debug(
"[EntityDamsel] {} item taken by {}",
host.getNpcName(),
taker.getKidnappedName()
);
}
}
// ========================================
// INTERNAL - UNLOCK ALL
// ========================================
/**
* Unlock all locked bondage items on this entity.
* Used by the facade in onDeathKidnapped.
* Epic 4B: Uses V2 regions, syncs once at end.
*/
public void unlockAllItems() {
BodyRegionV2[] regions = {
BodyRegionV2.ARMS, BodyRegionV2.MOUTH, BodyRegionV2.EYES,
BodyRegionV2.EARS, BodyRegionV2.NECK
};
boolean changed = false;
for (BodyRegionV2 region : regions) {
ItemStack stack = getEquipment().getInRegion(region);
if (
!stack.isEmpty() &&
stack.getItem() instanceof ILockable lockable &&
lockable.isLocked(stack)
) {
lockable.setLocked(stack, false);
changed = true;
}
}
if (changed) {
syncToEntityData();
}
}
/**
* Clear all V2 regions at once and sync. Used by untie(drop=false) in facade.
*/
public void clearAllAndSync() {
getEquipment().clearAll();
syncToEntityData();
}
// ========================================
// PERSISTENCE
// ========================================
/**
* Save equipment-related state to NBT.
* Epic 4B: Serializes V2BondageEquipment as a single CompoundTag.
*/
public void saveEquipmentToTag(CompoundTag tag) {
tag.put("V2Equipment", getEquipment().serializeNBT());
}
/**
* Load equipment-related state from NBT.
* Supports both V2 format and legacy V1 migration.
*/
public void loadEquipmentFromTag(CompoundTag tag) {
if (tag.contains("V2Equipment", Tag.TAG_COMPOUND)) {
// V2 format: deserialize directly
getEquipment().deserializeNBT(tag.getCompound("V2Equipment"));
} else {
// Legacy V1 migration: load individual item keys into V2 regions
for (Map.Entry<String, BodyRegionV2> entry : V1_TO_V2.entrySet()) {
if (tag.contains(entry.getKey(), Tag.TAG_COMPOUND)) {
ItemStack stack = ItemStack.of(tag.getCompound(entry.getKey()));
if (!stack.isEmpty()) {
getEquipment().setInRegion(entry.getValue(), stack);
}
}
}
}
syncToEntityData();
}
// ========================================
// PRIVATE HELPERS
// ========================================
/**
* Check if an item is locked (convenience -- no force parameter).
*/
private boolean isLocked(ItemStack stack) {
return (
stack.getItem() instanceof ILockable lockable &&
lockable.isLocked(stack)
);
}
}

View File

@@ -0,0 +1,148 @@
package com.tiedup.remake.entities.damsel.hosts;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.damsel.components.DamselBondageManager;
import com.tiedup.remake.entities.damsel.components.DamselPersonalitySystem;
import com.tiedup.remake.entities.damsel.components.IAIHost;
import java.util.UUID;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/**
* Host implementation for AIController callbacks.
* Extracted from EntityDamsel inner class for better organization.
*
* Phase 9: Extracted from EntityDamsel.AIHostImpl
*/
public class AIHost implements IAIHost {
private final EntityDamsel entity;
public AIHost(EntityDamsel entity) {
this.entity = entity;
}
// ========================================
// BASIC ENTITY ACCESS
// ========================================
@Override
public Level level() {
return entity.level();
}
@Override
public UUID getUUID() {
return entity.getUUID();
}
@Override
public String getNpcName() {
return entity.getNpcName();
}
@Override
public RandomSource getRandom() {
return entity.getRandom();
}
@Override
public AABB getBoundingBox() {
return entity.getBoundingBox();
}
// ========================================
// LEASH SYSTEM ACCESS
// ========================================
@Override
public boolean isLeashed() {
return entity.isLeashed();
}
@Override
public Entity getLeashHolder() {
return entity.getLeashHolder();
}
@Override
public Vec3 getDeltaMovement() {
return entity.getDeltaMovement();
}
@Override
public void setDeltaMovement(Vec3 motion) {
entity.setDeltaMovement(motion);
}
@Override
public float maxUpStep() {
return entity.maxUpStep();
}
@Override
public void setMaxUpStep(float height) {
entity.setMaxUpStep(height);
}
@Override
public void teleportTo(double x, double y, double z) {
entity.teleportTo(x, y, z);
}
@Override
public float distanceTo(Entity other) {
return entity.distanceTo(other);
}
@Override
public double getX() {
return entity.getX();
}
@Override
public double getY() {
return entity.getY();
}
@Override
public double getZ() {
return entity.getZ();
}
// ========================================
// COMPONENT ACCESS
// ========================================
@Override
public DamselBondageManager getBondageManager() {
return entity.getBondageManager();
}
@Override
public DamselPersonalitySystem getPersonalitySystem() {
return entity.getPersonalitySystem();
}
// ========================================
// BONDAGE STATE QUERIES (delegated)
// ========================================
@Override
public boolean isTiedUp() {
return entity.isTiedUp();
}
@Override
public boolean isGagged() {
return entity.isGagged();
}
@Override
public boolean isCaptive() {
return entity.isCaptive();
}
}

View File

@@ -0,0 +1,81 @@
package com.tiedup.remake.entities.damsel.hosts;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.entities.damsel.components.IAnimationHost;
import net.minecraft.world.level.Level;
/**
* Host implementation for AnimationController callbacks.
* Extracted from EntityDamsel inner class for better organization.
*
* Phase 9: Extracted from EntityDamsel.AnimationHostImpl
* Phase 3 audit: Updated to use AbstractTiedUpNpc
*/
public class AnimationHost implements IAnimationHost {
private final AbstractTiedUpNpc entity;
public AnimationHost(AbstractTiedUpNpc entity) {
this.entity = entity;
}
@Override
public Level level() {
return entity.level();
}
@Override
public float getYBodyRot() {
return entity.yBodyRot;
}
@Override
public void setYBodyRot(float rot) {
entity.yBodyRot = rot;
}
@Override
public float getYBodyRotO() {
return entity.yBodyRotO;
}
@Override
public void setYBodyRotO(float rot) {
entity.yBodyRotO = rot;
}
@Override
public boolean isDogPose() {
return entity.isDogPose();
}
@Override
public boolean isSittingFromData() {
return entity.getEntityData().get(AbstractTiedUpNpc.DATA_SITTING);
}
@Override
public void setSittingToData(boolean sitting) {
entity.getEntityData().set(AbstractTiedUpNpc.DATA_SITTING, sitting);
}
@Override
public boolean isKneelingFromData() {
return entity.getEntityData().get(AbstractTiedUpNpc.DATA_KNEELING);
}
@Override
public void setKneelingToData(boolean kneeling) {
entity.getEntityData().set(AbstractTiedUpNpc.DATA_KNEELING, kneeling);
}
@Override
public boolean isStrugglingFromData() {
return entity.getEntityData().get(AbstractTiedUpNpc.DATA_STRUGGLING);
}
@Override
public void setStrugglingToData(boolean struggling) {
entity.getEntityData().set(AbstractTiedUpNpc.DATA_STRUGGLING, struggling);
}
}

View File

@@ -0,0 +1,88 @@
package com.tiedup.remake.entities.damsel.hosts;
import com.tiedup.remake.dialogue.EntityDialogueManager;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.damsel.components.DamselInventoryManager;
import com.tiedup.remake.entities.damsel.components.IBondageHost;
import com.tiedup.remake.personality.PersonalityState;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
/**
* Host implementation for BondageManager callbacks.
* Extracted from EntityDamsel inner class for better organization.
*
* Phase 9: Extracted from EntityDamsel.BondageHostImpl
*/
public class BondageHost implements IBondageHost {
private final EntityDamsel entity;
public BondageHost(EntityDamsel entity) {
this.entity = entity;
}
@Override
public PersonalityState getPersonalityState() {
return entity.getPersonalitySystem() != null
? entity.getPersonalitySystem().getPersonalityState()
: null;
}
@Override
public DamselInventoryManager getInventory() {
return entity.getInventoryManager();
}
@Override
public void dropItemStack(ItemStack stack) {
entity.spawnAtLocation(stack);
}
@Override
public void playSound(SoundEvent sound) {
entity.playSound(sound, 1.0f, 1.0f);
}
@Override
public void setHealth(float health) {
entity.setHealth(health);
}
@Override
public void remove(Entity.RemovalReason reason) {
entity.remove(reason);
}
@Override
public Level level() {
return entity.level();
}
@Override
public BlockPos blockPosition() {
return entity.blockPosition();
}
@Override
public UUID getUUID() {
return entity.getUUID();
}
@Override
public String getNpcName() {
return entity.getNpcName();
}
@Override
public void talkToPlayersInRadius(
EntityDialogueManager.DialogueCategory category,
int radius
) {
entity.talkToPlayersInRadius(category, radius);
}
}

View File

@@ -0,0 +1,53 @@
package com.tiedup.remake.entities.damsel.hosts;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.damsel.components.IDialogueHost;
import java.util.List;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB;
/**
* Host implementation for DialogueHandler callbacks.
* Extracted from EntityDamsel inner class for better organization.
*
* Phase 9: Extracted from EntityDamsel.DialogueHostImpl
*/
public class DialogueHost implements IDialogueHost {
private final EntityDamsel entity;
public DialogueHost(EntityDamsel entity) {
this.entity = entity;
}
@Override
public Level level() {
return entity.level();
}
@Override
public AABB getBoundingBox() {
return entity.getBoundingBox();
}
@Override
public EntityDamsel getEntity() {
return entity;
}
@Override
public long getGameTime() {
return entity.level().getGameTime();
}
@Override
public List<Player> findNearbyPlayers(double radius) {
return entity
.level()
.getEntitiesOfClass(
Player.class,
entity.getBoundingBox().inflate(radius)
);
}
}

View File

@@ -0,0 +1,109 @@
package com.tiedup.remake.entities.damsel.hosts;
import com.tiedup.remake.dialogue.EntityDialogueManager;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.damsel.components.DamselAppearance;
import com.tiedup.remake.entities.damsel.components.DamselBondageManager;
import com.tiedup.remake.entities.damsel.components.DamselInventoryManager;
import com.tiedup.remake.entities.damsel.components.IPersonalityTickContext;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
/**
* Host implementation for PersonalitySystem callbacks.
* Extracted from EntityDamsel inner class for better organization.
*
* Phase 9: Extracted from EntityDamsel.PersonalityTickContextImpl
*/
public class PersonalityTickContextHost implements IPersonalityTickContext {
private final EntityDamsel entity;
public PersonalityTickContextHost(EntityDamsel entity) {
this.entity = entity;
}
@Override
public Level level() {
return entity.level();
}
@Override
public int getTickCount() {
return entity.tickCount;
}
@Override
public boolean isLeashed() {
return entity.isLeashed();
}
@Override
public Vec3 getDeltaMovement() {
return entity.getDeltaMovement();
}
@Override
public boolean hasCollar() {
return entity.getBondageManager().hasCollar();
}
@Override
public ItemStack getEquipment(BodyRegionV2 region) {
return entity.getBondageManager().getEquipment(region);
}
@Override
public DamselBondageManager getBondageManager() {
return entity.getBondageManager();
}
@Override
public DamselInventoryManager getInventoryManager() {
return entity.getInventoryManager();
}
@Override
public DamselAppearance getAppearance() {
return entity.getAppearance();
}
@Override
public UUID getUUID() {
return entity.getUUID();
}
@Override
public BlockPos blockPosition() {
return entity.blockPosition();
}
@Override
public void stopNavigation() {
entity.getNavigation().stop();
}
@Override
public Entity getLeashHolder() {
return entity.getLeashHolder();
}
@Override
public void talkToPlayersInRadius(
EntityDialogueManager.DialogueCategory category,
int radius
) {
entity.talkToPlayersInRadius(category, radius);
}
@Override
public void talkTo(Player player, String message) {
entity.talkTo(player, message);
}
}