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,510 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.state.IRestrainable;
import java.util.EnumSet;
import java.util.List;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.player.Player;
/**
* AI Goal for EntityMaster to approach and buy a player from a Kidnapper.
*
* Flow:
* 1. Master spawns 50 blocks away
* 2. Master walks towards the Kidnapper
* 3. Master arrives and greets Kidnapper (RP dialogue)
* 4. Master negotiates/inspects the "merchandise"
* 5. Master completes purchase
* 6. Kidnapper leaves, Master takes ownership
*/
public class MasterBuyPlayerGoal extends Goal {
private final EntityMaster master;
/** Search radius for kidnappers (if not pre-set) */
private static final double SEARCH_RADIUS = 64.0;
/** Distance to start negotiation dialogue */
private static final double GREETING_DISTANCE = 8.0;
/** Distance to complete purchase */
private static final double PURCHASE_DISTANCE = 3.0;
/** Target kidnapper selling a player */
private EntityKidnapper targetKidnapper = null;
/** Purchase phase */
private PurchasePhase phase = PurchasePhase.APPROACHING;
/** Timer for current phase */
private int phaseTimer = 0;
/** Phase durations (in ticks) */
private static final int GREETING_DURATION = 60; // 3 seconds
private static final int INSPECT_DURATION = 80; // 4 seconds
private static final int NEGOTIATE_DURATION = 60; // 3 seconds
private static final int PURCHASE_DURATION = 60; // 3 seconds
/** Whether we've said the approach dialogue */
private boolean hasSaidApproach = false;
/** Pathfinding recalculation cooldown to avoid path thrashing */
private int pathRecalcCooldown = 0;
private enum PurchasePhase {
APPROACHING, // Walking to kidnapper
GREETING, // Arrived, greeting
INSPECTING, // Looking at the merchandise
NEGOTIATING, // Discussing price
PURCHASING, // Completing purchase
COMPLETE, // Done
}
public MasterBuyPlayerGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
// Preserve state for ALL phases in progress (including APPROACHING)
// This prevents goal from restarting during approach
if (targetKidnapper != null && phase != PurchasePhase.COMPLETE) {
// Validate target is still valid
if (targetKidnapper.isAlive()) {
return true; // Continue without restarting
}
// Target died - reset
targetKidnapper = null;
phase = PurchasePhase.APPROACHING;
}
// Must be in purchasing state or idle without pet
MasterState state = master.getStateManager().getCurrentState();
if (state != MasterState.PURCHASING && state != MasterState.IDLE) {
return false;
}
// Don't search if already has a pet
if (master.hasPet()) {
return false;
}
// First check if we have a pre-set selling kidnapper
if (master.hasSellingKidnapper()) {
targetKidnapper = master.getSellingKidnapper();
return true;
}
// Otherwise search for one nearby
targetKidnapper = findSellingKidnapper();
return targetKidnapper != null;
}
@Override
public boolean canContinueToUse() {
// Once we've started greeting, we're committed to the purchase
if (
phase.ordinal() >= PurchasePhase.GREETING.ordinal() &&
phase != PurchasePhase.COMPLETE
) {
// Only stop if kidnapper dies or we completed
return targetKidnapper != null && targetKidnapper.isAlive();
}
// During approach phase, check if target is still valid
if (targetKidnapper == null || !targetKidnapper.isAlive()) {
return false;
}
IRestrainable captive = targetKidnapper.getCaptive();
if (captive == null) {
return false;
}
// Stop if already has pet (purchase completed)
return !master.hasPet() && phase != PurchasePhase.COMPLETE;
}
@Override
public void start() {
// Don't reset if we're already in progress (goal was briefly interrupted or resuming)
// This includes APPROACHING phase - only reset when starting from scratch or COMPLETE
if (targetKidnapper != null && phase != PurchasePhase.COMPLETE) {
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] Resuming {} at phase {}",
master.getNpcName(),
phase
);
// Ensure state is set
master.setMasterState(MasterState.PURCHASING);
return;
}
// Starting fresh - reset everything
this.phaseTimer = 0;
this.phase = PurchasePhase.APPROACHING;
this.hasSaidApproach = false;
this.pathRecalcCooldown = 0;
master.setMasterState(MasterState.PURCHASING);
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] {} approaching {} to buy player",
master.getNpcName(),
targetKidnapper != null
? targetKidnapper.getNpcName()
: "unknown"
);
}
@Override
public void stop() {
master.getNavigation().stop();
// Only fully reset if purchase completed or truly failed
if (phase == PurchasePhase.COMPLETE || master.hasPet()) {
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] {} completed purchase, transitioning to FOLLOWING",
master.getNpcName()
);
this.targetKidnapper = null;
this.phaseTimer = 0;
this.phase = PurchasePhase.APPROACHING;
this.hasSaidApproach = false;
master.clearSellingKidnapper();
master.setMasterState(MasterState.FOLLOWING);
}
// Otherwise keep state for resume (goal was just temporarily interrupted)
}
@Override
public void tick() {
if (targetKidnapper == null) return;
// Look at kidnapper
master.getLookControl().setLookAt(targetKidnapper, 30.0F, 30.0F);
double distSq = master.distanceToSqr(targetKidnapper);
double dist = Math.sqrt(distSq);
switch (phase) {
case APPROACHING -> tickApproaching(dist);
case GREETING -> tickGreeting();
case INSPECTING -> tickInspecting();
case NEGOTIATING -> tickNegotiating();
case PURCHASING -> tickPurchasing();
case COMPLETE -> {
} // Do nothing
}
}
private void tickApproaching(double dist) {
// Approach dialogue when getting close (once)
if (!hasSaidApproach && dist < 30) {
IRestrainable captive = targetKidnapper.getCaptive();
if (
captive != null &&
captive.asLivingEntity() instanceof Player player
) {
DialogueBridge.talkTo(master, player, "purchase.interested");
}
hasSaidApproach = true;
}
if (dist > GREETING_DISTANCE) {
// Recalculate path every 20 ticks (1 second) instead of every tick
// This prevents path thrashing which dramatically slows movement
if (pathRecalcCooldown <= 0) {
master.getNavigation().moveTo(targetKidnapper, 1.0);
pathRecalcCooldown = 20;
} else {
pathRecalcCooldown--;
}
} else {
// Arrived - start greeting
master.getNavigation().stop();
phase = PurchasePhase.GREETING;
phaseTimer = 0;
pathRecalcCooldown = 0;
// Greeting dialogue
IRestrainable captive = targetKidnapper.getCaptive();
if (
captive != null &&
captive.asLivingEntity() instanceof Player player
) {
DialogueBridge.talkTo(master, player, "idle.greeting_stranger");
}
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] {} arrived, greeting {}",
master.getNpcName(),
targetKidnapper.getNpcName()
);
}
}
private void tickGreeting() {
phaseTimer++;
// Move closer during greeting
double dist = Math.sqrt(master.distanceToSqr(targetKidnapper));
if (dist > PURCHASE_DISTANCE) {
master.getNavigation().moveTo(targetKidnapper, 0.5);
}
if (phaseTimer >= GREETING_DURATION) {
phase = PurchasePhase.INSPECTING;
phaseTimer = 0;
// Kidnapper offers sale dialogue
IRestrainable captive = targetKidnapper.getCaptive();
if (
captive != null &&
captive.asLivingEntity() instanceof Player player
) {
targetKidnapper.talkTo(
player,
com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.SALE_OFFER
);
}
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] {} inspecting merchandise",
master.getNpcName()
);
}
}
private void tickInspecting() {
phaseTimer++;
// Look at the captive during inspection
IRestrainable captive = targetKidnapper.getCaptive();
if (captive != null) {
master
.getLookControl()
.setLookAt(captive.asLivingEntity(), 30.0F, 30.0F);
}
if (phaseTimer >= INSPECT_DURATION) {
phase = PurchasePhase.NEGOTIATING;
phaseTimer = 0;
// Master negotiates
if (
captive != null &&
captive.asLivingEntity() instanceof Player player
) {
DialogueBridge.talkTo(master, player, "purchase.negotiating");
}
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] {} negotiating",
master.getNpcName()
);
}
}
private void tickNegotiating() {
phaseTimer++;
// Look back at kidnapper
master.getLookControl().setLookAt(targetKidnapper, 30.0F, 30.0F);
if (phaseTimer >= NEGOTIATE_DURATION) {
phase = PurchasePhase.PURCHASING;
phaseTimer = 0;
// Sale complete dialogue from kidnapper
IRestrainable captive = targetKidnapper.getCaptive();
if (
captive != null &&
captive.asLivingEntity() instanceof Player player
) {
targetKidnapper.talkTo(
player,
com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory.SALE_COMPLETE
);
}
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] {} completing purchase",
master.getNpcName()
);
}
}
private void tickPurchasing() {
phaseTimer++;
if (phaseTimer >= PURCHASE_DURATION) {
completePurchase();
phase = PurchasePhase.COMPLETE;
}
}
/**
* Find a nearby kidnapper who is selling a player.
*/
private EntityKidnapper findSellingKidnapper() {
List<EntityKidnapper> kidnappers = master
.level()
.getEntitiesOfClass(
EntityKidnapper.class,
master.getBoundingBox().inflate(SEARCH_RADIUS),
k -> {
if (!k.isAlive() || k.isTiedUp()) return false;
IRestrainable captive = k.getCaptive();
if (captive == null) return false;
if (!captive.isForSell()) return false;
// Only buy players, not NPCs
return captive.asLivingEntity() instanceof Player;
}
);
if (kidnappers.isEmpty()) return null;
// Return closest one
return kidnappers
.stream()
.min((a, b) ->
Double.compare(master.distanceToSqr(a), master.distanceToSqr(b))
)
.orElse(null);
}
/**
* Complete the purchase - take the player from kidnapper.
* Properly transfers the captive with their bindings to the Master.
*/
private void completePurchase() {
if (targetKidnapper == null) return;
IRestrainable captive = targetKidnapper.getCaptive();
if (captive == null) return;
if (!(captive.asLivingEntity() instanceof ServerPlayer player)) {
TiedUpMod.LOGGER.warn(
"[MasterBuyPlayerGoal] Captive is not a player!"
);
return;
}
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] {} bought {} from {}",
master.getNpcName(),
player.getName().getString(),
targetKidnapper.getNpcName()
);
// Purchase complete dialogue to player
DialogueBridge.talkTo(master, player, "purchase.complete");
// Cancel the sale before transfer
captive.cancelSale();
// IMPORTANT: Enable captive transfer flag on Kidnapper BEFORE transfer
// This is required for transferCaptivityTo() to work
targetKidnapper.setAllowCaptiveTransferFlag(true);
// Use transferCaptivityTo for proper leash transfer
// This detaches the leash from Kidnapper and attaches it to Master
captive.transferCaptivityTo(master);
// Disable transfer flag after transfer
targetKidnapper.setAllowCaptiveTransferFlag(false);
// Set up pet relationship in Master's state manager
master.setPetPlayer(player);
// IMPORTANT: Detach leash after purchase
// The leash should only be attached during specific activities (walks, etc.)
// Pet play mode uses collar control, not constant leashing
if (
player instanceof com.tiedup.remake.state.IPlayerLeashAccess access
) {
access.tiedup$detachLeash();
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] Detached leash from {} after purchase",
player.getName().getString()
);
}
// Replace shock collar with choke collar for pet play
master.putPetCollar(player);
// Remove bindings (arms/legs) but keep the collar
// The pet starts fresh without restraints (events can add them later)
removeBindingsFromNewPet(captive);
// Collaring dialogue
DialogueBridge.talkTo(master, player, "purchase.collaring");
// Notify kidnapper that purchase is complete via the WaitForBuyer goal
var buyerGoal = targetKidnapper.getWaitForBuyerGoal();
if (buyerGoal != null) {
buyerGoal.onMasterPurchaseComplete();
} else {
// Fallback: just set get out state directly
targetKidnapper.setGetOutState(true);
}
// Introduction dialogue
DialogueBridge.talkTo(master, player, "purchase.introduction");
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] Transfer complete - {} now owns {}",
master.getNpcName(),
player.getName().getString()
);
targetKidnapper = null;
}
/**
* Remove bindings from the new pet (but keep the collar and clothes).
* The pet starts with only the choke collar - events may add restraints later.
*
* <p><b>Note:</b> We cannot use {@code untie(false)} because that clears ALL slots
* including the collar. Instead, we manually remove specific regions.</p>
*
* @param captive The captive IRestrainable state
*/
private void removeBindingsFromNewPet(IRestrainable captive) {
if (!(captive.asLivingEntity() instanceof ServerPlayer player)) {
return;
}
// Manually remove each region EXCEPT neck (collar) and torso (clothes)
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion(
player, com.tiedup.remake.v2.BodyRegionV2.ARMS, true
);
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion(
player, com.tiedup.remake.v2.BodyRegionV2.MOUTH, true
);
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion(
player, com.tiedup.remake.v2.BodyRegionV2.EYES, true
);
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion(
player, com.tiedup.remake.v2.BodyRegionV2.EARS, true
);
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.unequipFromRegion(
player, com.tiedup.remake.v2.BodyRegionV2.HANDS, true
);
// V1 speed reduction handled by MovementStyleManager (V2 tick-based).
// See H6 fix — removing V1 calls prevents double stacking.
TiedUpMod.LOGGER.debug(
"[MasterBuyPlayerGoal] Removed bindings from new pet {}",
captive.getKidnappedName()
);
}
}

View File

@@ -0,0 +1,386 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.state.IPlayerLeashAccess;
import com.tiedup.remake.state.PlayerBindState;
import java.util.EnumSet;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
/**
* AI Goal for EntityMaster dogwalk mode.
*
* Two modes:
* - Master leads (masterLeads=true): Master walks, player is pulled by leash
* - Pet leads (masterLeads=false): Master follows the pet at close distance
*
* The leash physics automatically pulls the player when Master moves away,
* handled by the LeashProxyEntity and player leash mixin.
*/
public class MasterDogwalkGoal extends Goal {
private final EntityMaster master;
/** If true, Master walks and pulls pet. If false, Master follows pet. */
private boolean masterLeads = false;
/** Distance to maintain from pet when following */
private static final double LEASH_DISTANCE_PET_LEADS = 3.5;
/** Maximum distance before waiting for pet (when master leads) */
private static final double MAX_DISTANCE = 10.0;
/** Navigation speed (used by masterLeads) */
private static final double WALK_SPEED = 0.6;
/** Speed when pet is close (3.5-6 blocks) - gentle follow */
private static final double FOLLOW_CLOSE_SPEED = 0.5;
/** Speed when pet is far (6+ blocks) - catching up */
private static final double FOLLOW_CATCHUP_SPEED = 0.9;
/** Stop following when closer than this */
private static final double FOLLOW_STOP_DISTANCE = 2.5;
/** Timer for random direction changes when master leads */
private int directionChangeTimer = 0;
/** Current walk destination (when master leads) */
private Vec3 walkTarget = null;
/** Cooldown for "wait for pet" dialogue */
private int waitDialogueCooldown = 0;
/** Maximum dogwalk duration (ticks) - 1-2 minutes */
private static final int MIN_WALK_DURATION = 1200; // 1 minute
private static final int MAX_WALK_DURATION = 2400; // 2 minutes
/** Current walk duration for this session */
private int walkDuration = 0;
/** Walk timer */
private int walkTimer = 0;
/** FIX: Stuck timer - counts ticks while waiting for pet */
private int stuckTimer = 0;
/** FIX: Maximum stuck time before teleporting pet (15 seconds) */
private static final int MAX_STUCK_TIME = 300;
public MasterDogwalkGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
/**
* Set whether Master leads the walk.
*/
public void setMasterLeads(boolean masterLeads) {
this.masterLeads = masterLeads;
}
@Override
public boolean canUse() {
return (
master.getStateManager().getCurrentState() == MasterState.DOGWALK &&
master.hasPet()
);
}
@Override
public boolean canContinueToUse() {
if (!master.hasPet()) {
return false;
}
ServerPlayer pet = master.getPetPlayer();
if (pet == null || !pet.isAlive()) {
return false;
}
// End walk if duration exceeded
if (walkTimer >= walkDuration) {
return false;
}
return (
master.getStateManager().getCurrentState() == MasterState.DOGWALK
);
}
@Override
public void start() {
this.directionChangeTimer = 0;
this.walkTarget = null;
this.waitDialogueCooldown = 0;
this.walkTimer = 0;
this.stuckTimer = 0;
// Random walk duration between 2-3 minutes
this.walkDuration =
MIN_WALK_DURATION +
master.getRandom().nextInt(MAX_WALK_DURATION - MIN_WALK_DURATION);
// Get current mode from Master
this.masterLeads = master.isDogwalkMasterLeads();
// When pet leads, give extra leash slack so they can walk ahead
if (!masterLeads) {
ServerPlayer pet = master.getPetPlayer();
if (pet instanceof IPlayerLeashAccess access) {
access.tiedup$setLeashSlack(5.0); // 3+5=8 blocks before pull
}
}
TiedUpMod.LOGGER.debug(
"[MasterDogwalkGoal] {} started dogwalk (masterLeads={}, duration={} ticks)",
master.getNpcName(),
masterLeads,
walkDuration
);
}
@Override
public void stop() {
master.getNavigation().stop();
this.walkTarget = null;
// Clean up: remove dogbind and detach leash
cleanupDogwalk();
// FIX: Transition out of DOGWALK state so the goal doesn't restart
if (master.getStateManager().getCurrentState() == MasterState.DOGWALK) {
master.setMasterState(MasterState.FOLLOWING);
}
TiedUpMod.LOGGER.debug(
"[MasterDogwalkGoal] {} stopped dogwalk",
master.getNpcName()
);
}
/**
* Clean up after dogwalk ends.
* Removes dogbind from player and detaches leash.
*/
private void cleanupDogwalk() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Reset leash slack
if (pet instanceof IPlayerLeashAccess access) {
access.tiedup$setLeashSlack(0.0);
}
// Detach leash
master.detachLeashFromPet();
// Remove dogbind from player
PlayerBindState state = PlayerBindState.getInstance(pet);
if (state != null && state.isTiedUp()) {
state.unequip(BodyRegionV2.ARMS);
TiedUpMod.LOGGER.debug(
"[MasterDogwalkGoal] Removed dogbind from {} after walk",
pet.getName().getString()
);
}
// Dialogue for walk end
DialogueBridge.talkTo(master, pet, "petplay.walk_end");
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Always look at pet
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
double dist = master.distanceTo(pet);
// Increment walk timer
walkTimer++;
// Decrement dialogue cooldown
if (waitDialogueCooldown > 0) {
waitDialogueCooldown--;
}
if (masterLeads) {
tickMasterLeads(pet, dist);
} else {
tickMasterFollows(pet, dist);
}
}
/**
* Tick when Master leads the walk.
* Master walks in a direction, pet is pulled by leash physics.
*/
private void tickMasterLeads(ServerPlayer pet, double dist) {
// If pet is too far behind, wait for them
if (dist > MAX_DISTANCE) {
master.getNavigation().stop();
stuckTimer++;
// Occasionally tell pet to keep up
if (waitDialogueCooldown <= 0) {
DialogueBridge.talkTo(master, pet, "petplay.wait_pet");
waitDialogueCooldown = 200; // 10 seconds cooldown
}
// FIX: If stuck too long, teleport pet to master
if (stuckTimer >= MAX_STUCK_TIME) {
teleportPetToMaster(pet);
stuckTimer = 0;
}
return;
}
// Pet caught up, reset stuck timer
stuckTimer = 0;
// Update direction timer
directionChangeTimer--;
// Pick new random direction periodically or if no target
if (
directionChangeTimer <= 0 ||
walkTarget == null ||
!master.getNavigation().isInProgress()
) {
pickNewWalkDirection();
directionChangeTimer = 100 + master.getRandom().nextInt(100); // 5-10 seconds
}
// Navigate to target
if (walkTarget != null && !master.getNavigation().isInProgress()) {
master
.getNavigation()
.moveTo(walkTarget.x, walkTarget.y, walkTarget.z, WALK_SPEED);
}
}
/**
* Pick a new random direction for walking.
*/
private void pickNewWalkDirection() {
// Random angle
double angle = master.getRandom().nextDouble() * Math.PI * 2;
double distance = 8.0 + master.getRandom().nextDouble() * 8.0; // 8-16 blocks
double x = master.getX() + Math.cos(angle) * distance;
double z = master.getZ() + Math.sin(angle) * distance;
// FIX: Use safe ground finder instead of heightmap (works indoors)
int groundY = findSafeGroundY(
master.level(),
(int) x,
(int) z,
(int) master.getY() + 10
);
double y = groundY;
walkTarget = new Vec3(x, y, z);
TiedUpMod.LOGGER.debug(
"[MasterDogwalkGoal] {} picked new walk target: ({}, {}, {})",
master.getNpcName(),
(int) x,
(int) y,
(int) z
);
}
/**
* Find a safe ground Y position by scanning downward.
* Works correctly indoors (doesn't return roof height like heightmap).
*
* @param level The level
* @param x Target X coordinate
* @param z Target Z coordinate
* @param startY Starting Y to scan from (usually entity Y + some offset)
* @return A safe Y position with solid ground below and 2 air blocks above
*/
private int findSafeGroundY(Level level, int x, int z, int startY) {
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(
x,
startY,
z
);
// Scan downward to find a solid floor with 2 air blocks above
for (int y = startY; y > level.getMinBuildHeight(); y--) {
pos.setY(y);
BlockState below = level.getBlockState(pos.below());
BlockState at = level.getBlockState(pos);
BlockState above = level.getBlockState(pos.above());
// Solid floor + 2 blocks of clearance above
if (below.isSolid() && !at.isSolid() && !above.isSolid()) {
return y;
}
}
return startY; // Fallback if nothing found
}
/**
* Tick when Master follows the pet.
* Uses progressive speed ramp: slow when close, faster to catch up.
*/
private void tickMasterFollows(ServerPlayer pet, double dist) {
if (dist > LEASH_DISTANCE_PET_LEADS) {
// Speed ramp: slow when close, faster to catch up
double speed;
if (dist > 6.0) {
speed = FOLLOW_CATCHUP_SPEED; // 0.9 - catching up
} else {
speed = FOLLOW_CLOSE_SPEED; // 0.5 - gentle follow
}
master.getNavigation().moveTo(pet, speed);
} else if (dist < FOLLOW_STOP_DISTANCE) {
master.getNavigation().stop();
}
// Between FOLLOW_STOP_DISTANCE and LEASH_DISTANCE_PET_LEADS:
// keep current movement, don't snap
}
/**
* FIX: Teleport pet to master when stuck too long.
*/
private void teleportPetToMaster(ServerPlayer pet) {
double angle = master.getRandom().nextDouble() * Math.PI * 2;
double distance = 1.5;
double x = master.getX() + Math.cos(angle) * distance;
double z = master.getZ() + Math.sin(angle) * distance;
double y = master.getY();
pet.teleportTo(x, y, z);
DialogueBridge.talkTo(master, pet, "petplay.come_here");
TiedUpMod.LOGGER.debug(
"[MasterDogwalkGoal] {} teleported stuck pet {} to master",
master.getNpcName(),
pet.getName().getString()
);
}
/**
* End the dogwalk and return to FOLLOWING state.
* Cleanup (remove dogbind, detach leash) happens automatically in stop().
*/
public void endDogwalk() {
// Just change state - stop() will be called automatically and handle cleanup
master.setMasterState(MasterState.FOLLOWING);
}
}

View File

@@ -0,0 +1,302 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.util.teleport.Position;
import com.tiedup.remake.util.teleport.TeleportHelper;
import java.util.EnumSet;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.state.BlockState;
/**
* AI Goal for EntityMaster to follow their pet player.
*
* Unlike normal follower mechanics, the Master follows the player.
* Maintains a distance of 2-8 blocks from the player.
*/
public class MasterFollowPlayerGoal extends Goal {
private final EntityMaster master;
/** Minimum distance to maintain from pet */
private static final double MIN_DISTANCE = 2.0;
/** Ideal distance to maintain from pet (active following) */
private static final double IDEAL_DISTANCE = 4.0;
/** Maximum distance before starting to follow */
private static final double MAX_DISTANCE = 8.0;
/** Distance at which to teleport (if too far) */
private static final double TELEPORT_DISTANCE = 32.0;
/** Navigation speed */
private static final double FOLLOW_SPEED = 1.0;
/** Path recalculation cooldown (ticks) */
private static final int PATH_RECALC_COOLDOWN = 10;
private int pathRecalcCooldown = 0;
private double targetX, targetY, targetZ;
public MasterFollowPlayerGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
// Must have a pet
if (!master.hasPet()) {
return false;
}
// Must be in a following-compatible state
MasterState state = master.getStateManager().getCurrentState();
if (
state != MasterState.FOLLOWING &&
state != MasterState.OBSERVING &&
state != MasterState.DISTRACTED
) {
return false;
}
// Pet must be online
ServerPlayer pet = master.getPetPlayer();
if (pet == null || !pet.isAlive()) {
return false;
}
// FIX: Always activate in FOLLOWING state - distance is managed in tick()
// This fixes the bug where Master wouldn't move after buying player
return true;
}
@Override
public boolean canContinueToUse() {
if (!master.hasPet()) {
return false;
}
ServerPlayer pet = master.getPetPlayer();
if (pet == null || !pet.isAlive()) {
return false;
}
// Only continue in following-compatible states
MasterState state = master.getStateManager().getCurrentState();
if (
state != MasterState.FOLLOWING &&
state != MasterState.OBSERVING &&
state != MasterState.DISTRACTED
) {
return false;
}
return true;
}
@Override
public void start() {
this.pathRecalcCooldown = 0;
TiedUpMod.LOGGER.debug(
"[MasterFollowPlayerGoal] {} started following pet",
master.getNpcName()
);
}
@Override
public void stop() {
master.getNavigation().stop();
TiedUpMod.LOGGER.debug(
"[MasterFollowPlayerGoal] {} stopped following pet",
master.getNpcName()
);
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Check for dimension change - teleport to pet's dimension if different
if (!master.level().dimension().equals(pet.level().dimension())) {
teleportToPetDimension(pet);
return;
}
// Look at pet
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
double distSq = master.distanceToSqr(pet);
// Teleport if too far (pet probably teleported within same dimension)
if (distSq > TELEPORT_DISTANCE * TELEPORT_DISTANCE) {
teleportNearPet(pet);
return;
}
// Update path periodically
this.pathRecalcCooldown--;
if (this.pathRecalcCooldown <= 0) {
this.pathRecalcCooldown = PATH_RECALC_COOLDOWN;
// FIX: Active following behavior
// Problem: If Master stops in "comfort zone" (2-8 blocks) and player
// is on leash, nobody moves = deadlock.
// Solution: Master actively follows, maintaining IDEAL_DISTANCE from pet.
if (distSq < MIN_DISTANCE * MIN_DISTANCE) {
// Too close - stop and let pet have some space
master.getNavigation().stop();
} else if (distSq > IDEAL_DISTANCE * IDEAL_DISTANCE) {
// Beyond ideal distance - follow the pet
master.getNavigation().moveTo(pet, FOLLOW_SPEED);
} else if (!master.getNavigation().isInProgress()) {
// At ideal distance and not moving - pick a random spot near pet
// This creates natural wandering behavior around the pet
wanderNearPet(pet);
}
}
}
/**
* Wander to a random position near the pet.
* Creates natural movement behavior instead of standing still.
*/
private void wanderNearPet(ServerPlayer pet) {
// Pick a random angle and position around the pet
double angle = master.getRandom().nextDouble() * Math.PI * 2;
double distance =
IDEAL_DISTANCE + (master.getRandom().nextDouble() - 0.5) * 2;
double x = pet.getX() + Math.cos(angle) * distance;
double z = pet.getZ() + Math.sin(angle) * distance;
// FIX: Use safe ground finder instead of heightmap (works indoors)
int groundY = findSafeGroundY(
pet.level(),
(int) x,
(int) z,
(int) pet.getY() + 10
);
double y = groundY;
// Move to the spot at slower speed (wandering, not chasing)
master.getNavigation().moveTo(x, y, z, FOLLOW_SPEED * 0.6);
}
/**
* Teleport master near pet when too far (same dimension).
*/
private void teleportNearPet(ServerPlayer pet) {
double angle = master.getRandom().nextDouble() * Math.PI * 2;
double distance = MIN_DISTANCE + master.getRandom().nextDouble() * 2;
double x = pet.getX() + Math.cos(angle) * distance;
double z = pet.getZ() + Math.sin(angle) * distance;
// FIX: Use safe ground finder instead of heightmap (works indoors)
int groundY = findSafeGroundY(
pet.level(),
(int) x,
(int) z,
(int) pet.getY() + 10
);
double y = groundY;
master.teleportTo(x, y, z);
TiedUpMod.LOGGER.debug(
"[MasterFollowPlayerGoal] {} teleported near pet {}",
master.getNpcName(),
pet.getName().getString()
);
}
/**
* Teleport master to pet's dimension.
* Used when pet changes dimension (nether, end, etc.)
*/
private void teleportToPetDimension(ServerPlayer pet) {
// Calculate position near pet
double angle = master.getRandom().nextDouble() * Math.PI * 2;
double distance = MIN_DISTANCE + master.getRandom().nextDouble() * 2;
double x = pet.getX() + Math.cos(angle) * distance;
double z = pet.getZ() + Math.sin(angle) * distance;
// FIX: Use safe ground finder instead of heightmap (works indoors)
int groundY = findSafeGroundY(
pet.level(),
(int) x,
(int) z,
(int) pet.getY() + 10
);
double y = groundY;
// Create position with dimension info
Position targetPos = new Position(
x,
y,
z,
master.getYRot(),
master.getXRot(),
pet.level().dimension()
);
TiedUpMod.LOGGER.debug(
"[MasterFollowPlayerGoal] {} teleporting to pet's dimension: {} -> {}",
master.getNpcName(),
master.level().dimension().location(),
pet.level().dimension().location()
);
// Use TeleportHelper for cross-dimension teleportation
TeleportHelper.teleportEntity(master, targetPos);
TiedUpMod.LOGGER.debug(
"[MasterFollowPlayerGoal] {} arrived in {} near pet {}",
master.getNpcName(),
pet.level().dimension().location(),
pet.getName().getString()
);
}
/**
* Find a safe ground Y position by scanning downward.
* Works correctly indoors (doesn't return roof height like heightmap).
*
* @param level The level
* @param x Target X coordinate
* @param z Target Z coordinate
* @param startY Starting Y to scan from (usually entity Y + some offset)
* @return A safe Y position with solid ground below and 2 air blocks above
*/
private int findSafeGroundY(Level level, int x, int z, int startY) {
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(
x,
startY,
z
);
// Scan downward to find a solid floor with 2 air blocks above
for (int y = startY; y > level.getMinBuildHeight(); y--) {
pos.setY(y);
BlockState below = level.getBlockState(pos.below());
BlockState at = level.getBlockState(pos);
BlockState above = level.getBlockState(pos.above());
// Solid floor + 2 blocks of clearance above
if (below.isSolid() && !at.isSolid() && !above.isSolid()) {
return y;
}
}
return startY; // Fallback if nothing found
}
}

View File

@@ -0,0 +1,488 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.BindVariant;
import com.tiedup.remake.state.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
import java.util.EnumSet;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
/**
* AI Goal for EntityMaster to use the pet as human furniture.
*
* The pet is forced on all fours (dogbind pose without visible restraint),
* frozen in place, while the Master sits on them.
* Lasts ~2 minutes. During this time the Master does idle behaviors:
* looks around, comments, observes nearby entities.
*
* Flow:
* 1. APPROACHING: Master walks to pet
* 2. SETTLING: Master positions on pet, applies pose + freeze
* 3. SITTING: Main phase - Master sits, does idle stuff
* 4. GETTING_UP: Master stands, cleanup
*/
public class MasterHumanChairGoal extends Goal {
private final EntityMaster master;
/** Duration of the sitting phase (ticks) - ~2 minutes */
private static final int SITTING_DURATION = 2400;
/** Time to settle into position (ticks) */
private static final int SETTLE_DURATION = 40;
/** Approach distance to start settling */
private static final double APPROACH_DISTANCE = 1.5;
/** Interval between idle comments (ticks) */
private static final int IDLE_COMMENT_INTERVAL = 400; // 20 seconds
/** Y offset to place the master on the pet's back (compensates sitting animation lowering ~0.25 blocks) */
private static final double PET_BACK_Y_OFFSET = 0.55;
/** NBT tag to mark the temporary chair dogbind */
private static final String NBT_HUMAN_CHAIR_BIND = HumanChairHelper.NBT_KEY;
private enum Phase {
APPROACHING,
SETTLING,
SITTING,
GETTING_UP,
}
private Phase phase = Phase.APPROACHING;
private int phaseTimer = 0;
private int totalTimer = 0;
private int lastCommentTime = 0;
private boolean hasAppliedPose = false;
/** Pet facing direction locked at pose start — decoupled from camera */
private float lockedPetFacing = 0f;
/** Persistent look target — refreshed every tick to prevent vanilla LookControl snap-back */
private double lastLookX, lastLookY, lastLookZ;
public MasterHumanChairGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
return (
master.hasPet() &&
master.getPetPlayer() != null &&
master.getStateManager().getCurrentState() ==
MasterState.HUMAN_CHAIR
);
}
@Override
public boolean canContinueToUse() {
ServerPlayer pet = master.getPetPlayer();
return (
pet != null &&
pet.isAlive() &&
master.getStateManager().getCurrentState() ==
MasterState.HUMAN_CHAIR &&
phase != Phase.GETTING_UP
);
}
@Override
public void start() {
this.phase = Phase.APPROACHING;
this.phaseTimer = 0;
this.totalTimer = 0;
this.lastCommentTime = 0;
this.hasAppliedPose = false;
TiedUpMod.LOGGER.debug(
"[MasterHumanChairGoal] {} starting human chair",
master.getNpcName()
);
}
@Override
public void stop() {
// Cleanup: remove pose, effects, and standing up
cleanupPetPose();
// Master stands up
master.setSitting(false);
master.setNoGravity(false);
master.noPhysics = false;
// Return to following
if (master.hasPet()) {
master.setMasterState(MasterState.FOLLOWING);
}
this.phase = Phase.APPROACHING;
this.phaseTimer = 0;
this.totalTimer = 0;
this.hasAppliedPose = false;
TiedUpMod.LOGGER.debug(
"[MasterHumanChairGoal] {} ended human chair",
master.getNpcName()
);
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
totalTimer++;
switch (phase) {
case APPROACHING -> tickApproaching(pet);
case SETTLING -> tickSettling(pet);
case SITTING -> tickSitting(pet);
case GETTING_UP -> {
} // handled by canContinueToUse returning false
}
}
// ========================================
// PHASE: APPROACHING
// ========================================
private void tickApproaching(ServerPlayer pet) {
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
double distSq = master.distanceToSqr(pet);
if (distSq > APPROACH_DISTANCE * APPROACH_DISTANCE) {
master.getNavigation().moveTo(pet, 1.0);
} else {
// Close enough - start settling
master.getNavigation().stop();
phase = Phase.SETTLING;
phaseTimer = 0;
// Tell pet what's about to happen
DialogueBridge.talkTo(master, pet, "petplay.human_chair_command");
}
}
// ========================================
// PHASE: SETTLING
// ========================================
private void tickSettling(ServerPlayer pet) {
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
master.getNavigation().stop();
// Apply the pose on first tick of settling
if (!hasAppliedPose) {
lockedPetFacing = pet.getYRot();
applyPetPose(pet);
hasAppliedPose = true;
}
phaseTimer++;
if (phaseTimer >= SETTLE_DURATION) {
// Settled - Master sits down
master.setSitting(true);
master.setNoGravity(true);
master.noPhysics = true;
// Position master centered on pet's back
positionOnPet(pet);
// Initialize look target: forward along sideways direction
float sideYaw = lockedPetFacing + 90f;
float sideRad = (float) Math.toRadians(sideYaw);
lastLookX = master.getX() + (-Math.sin(sideRad)) * 5;
lastLookY = master.getEyeY();
lastLookZ = master.getZ() + Math.cos(sideRad) * 5;
phase = Phase.SITTING;
phaseTimer = 0;
lastCommentTime = totalTimer;
DialogueBridge.talkTo(master, pet, "petplay.human_chair_sitting");
TiedUpMod.LOGGER.debug(
"[MasterHumanChairGoal] {} settled on {}",
master.getNpcName(),
pet.getName().getString()
);
}
}
// ========================================
// PHASE: SITTING
// ========================================
private void tickSitting(ServerPlayer pet) {
phaseTimer++;
// Force master position on pet every tick to prevent drifting
positionOnPet(pet);
// Refresh slowness to keep pet frozen
if (phaseTimer % 60 == 0) {
pet.addEffect(
new MobEffectInstance(
MobEffects.MOVEMENT_SLOWDOWN,
80,
255,
false,
false,
false
)
);
pet.addEffect(
new MobEffectInstance(
MobEffects.JUMP,
80,
128,
false,
false,
false
)
);
}
// Idle behaviors during sitting (may update lastLook target)
tickIdleBehavior(pet);
// Refresh look target every tick to prevent vanilla LookControl snap-back
// (vanilla lookAtCooldown = 2 → target expires after 2 ticks → head snaps to center)
master
.getLookControl()
.setLookAt(lastLookX, lastLookY, lastLookZ, 10f, 10f);
// Check if duration expired
if (phaseTimer >= SITTING_DURATION) {
phase = Phase.GETTING_UP;
DialogueBridge.talkTo(master, pet, "petplay.human_chair_end");
TiedUpMod.LOGGER.debug(
"[MasterHumanChairGoal] {} getting up from {}",
master.getNpcName(),
pet.getName().getString()
);
}
}
// ========================================
// IDLE BEHAVIORS DURING SITTING
// ========================================
private void tickIdleBehavior(ServerPlayer pet) {
// Periodically look at nearby entities or comment
if (totalTimer - lastCommentTime >= IDLE_COMMENT_INTERVAL) {
lastCommentTime = totalTimer;
// 50% chance: look at a nearby entity, 50% chance: idle comment
if (master.getRandom().nextFloat() < 0.5f) {
lookAtNearbyEntity(pet);
} else {
// Idle comment about sitting/pet
String[] idleDialogues = {
"petplay.human_chair_idle",
"idle.content",
"idle.observing",
};
String dialogue = idleDialogues[master
.getRandom()
.nextInt(idleDialogues.length)];
DialogueBridge.talkTo(master, pet, dialogue);
}
}
// Between comments, slowly look around
if (phaseTimer % 100 == 0) {
float randomYaw =
master.getYRot() +
(master.getRandom().nextFloat() - 0.5f) * 120;
lastLookX = master.getX() + Math.sin(Math.toRadians(randomYaw)) * 5;
lastLookY = master.getEyeY();
lastLookZ = master.getZ() + Math.cos(Math.toRadians(randomYaw)) * 5;
}
}
/**
* Look at a random nearby living entity (NPC, player, mob).
*/
private void lookAtNearbyEntity(ServerPlayer pet) {
AABB searchBox = master.getBoundingBox().inflate(12.0);
var nearbyEntities = master
.level()
.getEntitiesOfClass(
LivingEntity.class,
searchBox,
e -> e != master && e != pet && e.isAlive()
);
if (!nearbyEntities.isEmpty()) {
LivingEntity target = nearbyEntities.get(
master.getRandom().nextInt(nearbyEntities.size())
);
lastLookX = target.getX();
lastLookY = target.getEyeY();
lastLookZ = target.getZ();
// Comment about the entity
if (target instanceof Player) {
DialogueBridge.talkTo(
master,
pet,
"petplay.human_chair_notice_player"
);
} else {
DialogueBridge.talkTo(
master,
pet,
"petplay.human_chair_notice_entity"
);
}
}
}
// ========================================
// POSITIONING
// ========================================
/**
* Position master on pet's back, facing sideways (perpendicular).
* Called every tick during SITTING to prevent any drift from collision/physics.
* Body rotation is locked so only the head turns when looking at entities.
*/
private void positionOnPet(ServerPlayer pet) {
// Use locked facing — decoupled from player camera
float petYaw = (float) Math.toRadians(lockedPetFacing);
double offsetX = -Math.sin(petYaw) * 0.15;
double offsetZ = Math.cos(petYaw) * 0.15;
// Face perpendicular to the pet (sitting sideways)
float sideYaw = lockedPetFacing + 90f;
master.moveTo(
pet.getX() + offsetX,
pet.getY() + PET_BACK_Y_OFFSET,
pet.getZ() + offsetZ,
sideYaw,
0.0F
);
// Lock body rotation so look-at only turns the head
master.yBodyRot = sideYaw;
master.yBodyRotO = sideYaw;
// Zero out velocity to prevent physics drift
master.setDeltaMovement(0, 0, 0);
}
// ========================================
// POSE MANAGEMENT
// ========================================
/**
* Apply the human chair pose to the pet:
* - Temporary invisible dogbind for the on-all-fours animation
* - Slowness 255 to freeze movement
* - Jump boost negative to prevent jumping
*/
private void applyPetPose(ServerPlayer pet) {
PlayerBindState bindState = PlayerBindState.getInstance(pet);
if (bindState == null) return;
// Apply invisible dogbind for the pose animation
if (!bindState.isTiedUp()) {
ItemStack dogbind = new ItemStack(
ModItems.getBind(BindVariant.DOGBINDER)
);
CompoundTag tag = dogbind.getOrCreateTag();
tag.putBoolean(NBT_HUMAN_CHAIR_BIND, true);
tag.putBoolean("tempMasterEvent", true);
tag.putLong(
"expirationTime",
master.level().getGameTime() + SITTING_DURATION + 200
);
tag.putUUID("masterUUID", master.getUUID());
tag.putFloat(HumanChairHelper.NBT_FACING_KEY, pet.getYRot());
bindState.equip(BodyRegionV2.ARMS, dogbind);
}
// Freeze the pet
pet.addEffect(
new MobEffectInstance(
MobEffects.MOVEMENT_SLOWDOWN,
SITTING_DURATION + 100,
255,
false,
false,
false
)
);
pet.addEffect(
new MobEffectInstance(
MobEffects.JUMP,
SITTING_DURATION + 100,
128,
false,
false,
false
)
);
TiedUpMod.LOGGER.debug(
"[MasterHumanChairGoal] Applied chair pose to {}",
pet.getName().getString()
);
}
/**
* Remove the human chair pose from the pet.
*/
private void cleanupPetPose() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Remove the temporary dogbind
PlayerBindState bindState = PlayerBindState.getInstance(pet);
if (bindState != null && bindState.isTiedUp()) {
ItemStack bind = bindState.getEquipment(BodyRegionV2.ARMS);
if (!bind.isEmpty()) {
CompoundTag tag = bind.getTag();
if (tag != null && tag.getBoolean(NBT_HUMAN_CHAIR_BIND)) {
bindState.unequip(BodyRegionV2.ARMS);
TiedUpMod.LOGGER.debug(
"[MasterHumanChairGoal] Removed chair bind from {}",
pet.getName().getString()
);
}
}
}
// Remove slowness and jump effects
pet.removeEffect(MobEffects.MOVEMENT_SLOWDOWN);
pet.removeEffect(MobEffects.JUMP);
}
/**
* Check if an item is a human chair temporary bind.
*/
public static boolean isHumanChairBind(ItemStack stack) {
if (stack.isEmpty()) return false;
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean(NBT_HUMAN_CHAIR_BIND);
}
}

View File

@@ -0,0 +1,212 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.items.ItemTaser;
import com.tiedup.remake.items.ModItems;
import java.util.EnumSet;
import java.util.List;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.monster.Creeper;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
/**
* AI Goal for Masters to hunt and kill monsters near their pet.
*
* Purpose: Protect the pet player from hostile mobs
*
* This goal:
* 1. Only activates when master has a pet
* 2. Scans for monsters near the pet (not the master)
* 3. Equips taser and attacks with stun effects
* 4. Returns to pet after killing monster
*
* Combat bonuses vs monsters:
* - 2x damage dealt (masters are elite fighters)
* - Taser stun effects (Slowness + Weakness)
* - Creeper explosion cancelled on hit
*
* Priority: 1 (high - pet protection is paramount)
*/
public class MasterHuntMonstersGoal extends Goal {
private final EntityMaster master;
private LivingEntity targetMonster;
/** Scan radius around pet */
private static final int SCAN_RADIUS = 12;
/** Ticks between attacks */
private static final int ATTACK_COOLDOWN = 15;
/** Damage multiplier against monsters */
private static final float MONSTER_DAMAGE_MULTIPLIER = 2.0f;
/** Current attack cooldown timer */
private int attackCooldown = 0;
public MasterHuntMonstersGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
// Must have a pet to protect
if (!master.hasPet()) {
return false;
}
ServerPlayer pet = master.getPetPlayer();
if (pet == null || !pet.isAlive()) {
return false;
}
// Find monsters near the pet (not the master)
this.targetMonster = findNearestMonsterNearPet(pet);
return this.targetMonster != null;
}
@Override
public boolean canContinueToUse() {
if (this.targetMonster == null || !this.targetMonster.isAlive()) {
return false;
}
// Stop if pet is gone
if (!master.hasPet()) {
return false;
}
// Stop if monster is too far from master (chase limit)
return master.distanceToSqr(this.targetMonster) < 400; // 20 blocks
}
@Override
public void start() {
this.attackCooldown = 0;
// Equip taser
this.master.setItemInHand(
InteractionHand.MAIN_HAND,
new ItemStack(ModItems.TASER.get())
);
}
@Override
public void stop() {
this.targetMonster = null;
this.master.getNavigation().stop();
// Unequip taser
this.master.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
}
@Override
public void tick() {
if (this.targetMonster == null) {
return;
}
// Look at the monster
this.master.getLookControl().setLookAt(this.targetMonster);
double distSq = this.master.distanceToSqr(this.targetMonster);
// Move closer if not in attack range
if (distSq > 4.0) {
this.master.getNavigation().moveTo(this.targetMonster, 1.3);
} else {
// In attack range - stop and attack
this.master.getNavigation().stop();
if (this.attackCooldown <= 0) {
attackMonsterWithBonus(this.targetMonster);
this.attackCooldown = ATTACK_COOLDOWN;
}
}
// Decrement cooldown
if (this.attackCooldown > 0) {
this.attackCooldown--;
}
}
/**
* Attack a monster with taser - bonus damage and stun effects.
* Masters deal 2x damage to monsters and apply taser stun.
* Creepers have their explosion cancelled.
*
* @param target The monster to attack
*/
private void attackMonsterWithBonus(LivingEntity target) {
// Swing arm animation
this.master.swing(InteractionHand.MAIN_HAND);
// Get base attack damage and apply multiplier
float baseDamage = (float) this.master.getAttributeValue(
Attributes.ATTACK_DAMAGE
);
float bonusDamage = baseDamage * MONSTER_DAMAGE_MULTIPLIER;
// Deal damage directly with bonus
boolean damaged = target.hurt(
this.master.damageSources().mobAttack(this.master),
bonusDamage
);
// Apply taser effects if damage was dealt
if (damaged) {
ItemStack heldItem = this.master.getItemInHand(
InteractionHand.MAIN_HAND
);
if (heldItem.getItem() instanceof ItemTaser taserItem) {
taserItem.hurtEnemy(heldItem, target, this.master);
}
// Special: Cancel creeper explosion
if (target instanceof Creeper creeper) {
creeper.setSwellDir(-1);
}
}
}
/**
* Find the nearest monster within scan radius of the pet.
*
* @param pet The pet player to protect
* @return The nearest monster, or null if none found
*/
private LivingEntity findNearestMonsterNearPet(ServerPlayer pet) {
AABB searchBox = pet.getBoundingBox().inflate(SCAN_RADIUS);
List<Monster> monsters = this.master.level().getEntitiesOfClass(
Monster.class,
searchBox,
m -> m.isAlive() && !m.isSpectator()
);
if (monsters.isEmpty()) {
return null;
}
// Find nearest to pet
Monster nearest = null;
double nearestDist = Double.MAX_VALUE;
for (Monster m : monsters) {
double dist = pet.distanceToSqr(m);
if (dist < nearestDist) {
nearestDist = dist;
nearest = m;
}
}
return nearest;
}
}

View File

@@ -0,0 +1,293 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import java.util.EnumSet;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.phys.Vec3;
/**
* AI Goal for EntityMaster to perform idle micro-behaviors.
*
* Fills the gap between tasks/events with small, natural-looking actions.
* Lower priority than tasks/events/inspect, but higher than look-at goals.
*/
public class MasterIdleBehaviorGoal extends Goal {
private final EntityMaster master;
/** Chance to start idle behavior per tick */
private static final float IDLE_CHANCE = 0.003f; // ~6% per 10 seconds
/** Minimum time between idle behaviors (ticks) - 20 seconds */
private static final int IDLE_COOLDOWN = 400;
/** Distance to approach pet for close behaviors */
private static final double APPROACH_DISTANCE = 2.0;
/** Distance to walk away for CHECK_SURROUNDINGS */
private static final double SCOUT_DISTANCE = 8.0;
/**
* Types of idle micro-behaviors.
*/
private enum IdleBehavior {
LOOK_AROUND(60), // 3s - random look angles
EXAMINE_PET(80), // 4s - walk to pet, stare
PAT_HEAD(60), // 3s - approach, pat, step back
ADJUST_COLLAR(60), // 3s - approach, adjust collar dialogue
IDLE_COMMENT(20), // 1s - random idle comment
STRETCH(40), // 2s - stop, do nothing
CHECK_SURROUNDINGS(100); // 5s - walk away, look around, return
final int duration;
IdleBehavior(int duration) {
this.duration = duration;
}
}
private long lastIdleTime = 0;
private IdleBehavior currentBehavior = null;
private int behaviorTimer = 0;
private boolean hasPerformedAction = false;
/** For CHECK_SURROUNDINGS: the position to walk to */
private Vec3 scoutTarget = null;
/** For CHECK_SURROUNDINGS: whether we've reached the scout target */
private boolean reachedScoutTarget = false;
public MasterIdleBehaviorGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
if (!master.hasPet()) return false;
MasterState state = master.getStateManager().getCurrentState();
// Only idle behaviors from FOLLOWING or OBSERVING
if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) {
return false;
}
// No idle behaviors if there's an active task
if (master.hasActiveTask()) return false;
// No idle behaviors during cold shoulder
if (master.isGivingColdShoulder()) return false;
// Check cooldown
long currentTime = master.level().getGameTime();
if (currentTime - lastIdleTime < IDLE_COOLDOWN) {
return false;
}
// Random chance (modulated by engagement cadence)
float multiplier = master.getEngagementMultiplier();
return (
multiplier > 0 &&
master.getRandom().nextFloat() < IDLE_CHANCE * multiplier
);
}
@Override
public boolean canContinueToUse() {
if (!master.hasPet()) return false;
if (currentBehavior == null) return false;
if (behaviorTimer >= currentBehavior.duration) return false;
MasterState state = master.getStateManager().getCurrentState();
return state == MasterState.FOLLOWING || state == MasterState.OBSERVING;
}
@Override
public void start() {
this.behaviorTimer = 0;
this.hasPerformedAction = false;
this.scoutTarget = null;
this.reachedScoutTarget = false;
// Select random idle behavior
IdleBehavior[] behaviors = IdleBehavior.values();
this.currentBehavior = behaviors[master
.getRandom()
.nextInt(behaviors.length)];
TiedUpMod.LOGGER.debug(
"[MasterIdleBehaviorGoal] {} starting idle: {}",
master.getNpcName(),
currentBehavior
);
}
@Override
public void stop() {
lastIdleTime = master.level().getGameTime();
master.markEngagement();
this.currentBehavior = null;
this.behaviorTimer = 0;
this.hasPerformedAction = false;
this.scoutTarget = null;
this.reachedScoutTarget = false;
master.getNavigation().stop();
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
behaviorTimer++;
switch (currentBehavior) {
case LOOK_AROUND -> tickLookAround();
case EXAMINE_PET -> tickExaminePet(pet);
case PAT_HEAD -> tickPatHead(pet);
case ADJUST_COLLAR -> tickAdjustCollar(pet);
case IDLE_COMMENT -> tickIdleComment(pet);
case STRETCH -> tickStretch();
case CHECK_SURROUNDINGS -> tickCheckSurroundings(pet);
}
}
private void tickLookAround() {
// Randomly change look direction every 20 ticks
if (behaviorTimer % 20 == 1) {
float yaw =
master.getYRot() +
(master.getRandom().nextFloat() - 0.5f) * 120;
float pitch = (master.getRandom().nextFloat() - 0.5f) * 40;
master
.getLookControl()
.setLookAt(
master.getX() + Math.sin(Math.toRadians(-yaw)) * 5,
master.getEyeY() + pitch * 0.1,
master.getZ() + Math.cos(Math.toRadians(-yaw)) * 5
);
}
master.getNavigation().stop();
}
private void tickExaminePet(ServerPlayer pet) {
double dist = master.distanceTo(pet);
if (dist > APPROACH_DISTANCE) {
master.getNavigation().moveTo(pet, 0.8);
} else {
master.getNavigation().stop();
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
if (!hasPerformedAction) {
DialogueBridge.talkTo(master, pet, "idle.examine");
hasPerformedAction = true;
}
}
}
private void tickPatHead(ServerPlayer pet) {
double dist = master.distanceTo(pet);
if (dist > APPROACH_DISTANCE) {
master.getNavigation().moveTo(pet, 0.8);
} else {
master.getNavigation().stop();
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
if (!hasPerformedAction) {
DialogueBridge.talkTo(master, pet, "idle.pat_head");
hasPerformedAction = true;
}
}
}
private void tickAdjustCollar(ServerPlayer pet) {
double dist = master.distanceTo(pet);
if (dist > APPROACH_DISTANCE) {
master.getNavigation().moveTo(pet, 0.8);
} else {
master.getNavigation().stop();
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
if (!hasPerformedAction) {
DialogueBridge.talkTo(master, pet, "idle.adjust_collar");
hasPerformedAction = true;
}
}
}
private void tickIdleComment(ServerPlayer pet) {
master.getNavigation().stop();
if (!hasPerformedAction) {
// Pick randomly between existing idle dialogues
String dialogueId = master.getRandom().nextBoolean()
? "idle.content"
: "idle.bored";
DialogueBridge.talkTo(master, pet, dialogueId);
hasPerformedAction = true;
}
}
private void tickStretch() {
// Do nothing - natural pause
master.getNavigation().stop();
}
private void tickCheckSurroundings(ServerPlayer pet) {
if (scoutTarget == null) {
// Pick a random direction to walk
double angle = master.getRandom().nextDouble() * Math.PI * 2;
double dist = 5 + master.getRandom().nextDouble() * 3; // 5-8 blocks
scoutTarget = new Vec3(
master.getX() + Math.cos(angle) * dist,
master.getY(),
master.getZ() + Math.sin(angle) * dist
);
}
if (!reachedScoutTarget) {
// Walk to scout target
master
.getNavigation()
.moveTo(scoutTarget.x, scoutTarget.y, scoutTarget.z, 0.8);
// Check if we arrived or enough time passed
double distToTarget = master.position().distanceTo(scoutTarget);
if (
distToTarget < 2.0 ||
behaviorTimer > currentBehavior.duration / 2
) {
reachedScoutTarget = true;
master.getNavigation().stop();
if (!hasPerformedAction) {
DialogueBridge.talkTo(
master,
pet,
"idle.check_surroundings"
);
hasPerformedAction = true;
}
}
} else {
// Look around at scout target, then return is handled by goal ending
// (follow goal will bring master back to pet)
if (behaviorTimer % 20 == 0) {
float yaw =
master.getYRot() +
(master.getRandom().nextFloat() - 0.5f) * 180;
master
.getLookControl()
.setLookAt(
master.getX() + Math.sin(Math.toRadians(-yaw)) * 5,
master.getEyeY(),
master.getZ() + Math.cos(Math.toRadians(-yaw)) * 5
);
}
}
}
}

View File

@@ -0,0 +1,249 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityMaster;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.TagKey;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/**
* AI Goal for EntityMaster to inspect pet's inventory for contraband.
*
* Contraband items:
* - Lockpicks, knives, shears
* - Weapons (swords, axes)
* - Items tagged with tiedup:escape_tool or tiedup:weapon
*
* Behavior:
* 1. Periodically checks inventory (random chance)
* 2. Confiscates contraband items
* 3. Punishes if contraband found
*/
public class MasterInventoryInspectGoal extends Goal {
private final EntityMaster master;
/** Chance to start inspection when following (per tick) */
private static final float INSPECT_CHANCE = 0.002f; // ~4% per 10 seconds
/** Minimum time between inspections (ticks) - 2.5 minutes */
private static final int INSPECT_COOLDOWN = 3000;
/** Distance to inspect */
private static final double INSPECT_DISTANCE = 2.0;
/** Inspection duration (ticks) */
private static final int INSPECT_DURATION = 40; // 2 seconds
private int inspectTimer = 0;
private long lastInspectTime = 0;
private List<ItemStack> confiscatedItems = new ArrayList<>();
/** Tag for escape tools */
private static final TagKey<Item> ESCAPE_TOOL_TAG = TagKey.create(
Registries.ITEM,
ResourceLocation.fromNamespaceAndPath("tiedup", "escape_tool")
);
/** Tag for weapons */
private static final TagKey<Item> WEAPON_TAG = TagKey.create(
Registries.ITEM,
ResourceLocation.fromNamespaceAndPath("tiedup", "weapon")
);
public MasterInventoryInspectGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
if (!master.hasPet()) return false;
MasterState state = master.getStateManager().getCurrentState();
// Already inspecting
if (state == MasterState.INSPECT) return true;
// Can start new inspection
if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) {
return false;
}
// Check cooldown
long currentTime = master.level().getGameTime();
if (currentTime - lastInspectTime < INSPECT_COOLDOWN) {
return false;
}
// Random chance to inspect (modulated by engagement cadence)
float multiplier = master.getEngagementMultiplier();
return (
multiplier > 0 &&
master.getRandom().nextFloat() < INSPECT_CHANCE * multiplier
);
}
@Override
public boolean canContinueToUse() {
return (
master.hasPet() &&
master.getStateManager().getCurrentState() == MasterState.INSPECT &&
inspectTimer < INSPECT_DURATION
);
}
@Override
public void start() {
this.inspectTimer = 0;
this.confiscatedItems.clear();
master.setMasterState(MasterState.INSPECT);
master.markEngagement();
TiedUpMod.LOGGER.debug(
"[MasterInventoryInspectGoal] {} starting inspection",
master.getNpcName()
);
}
@Override
public void stop() {
lastInspectTime = master.level().getGameTime();
// Announce results
ServerPlayer pet = master.getPetPlayer();
if (pet != null && !confiscatedItems.isEmpty()) {
pet.sendSystemMessage(
Component.literal(
master.getNpcName() +
" confiscated " +
confiscatedItems.size() +
" contraband item(s) from you!"
).withStyle(
Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR)
)
);
// Transition to punish state
master.setMasterState(MasterState.PUNISH);
} else if (master.hasPet()) {
// Clean inspection
master.setMasterState(MasterState.FOLLOWING);
}
this.inspectTimer = 0;
this.confiscatedItems.clear();
TiedUpMod.LOGGER.debug(
"[MasterInventoryInspectGoal] {} inspection complete",
master.getNpcName()
);
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Look at pet
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
double distSq = master.distanceToSqr(pet);
if (distSq > INSPECT_DISTANCE * INSPECT_DISTANCE) {
// Move close
master.getNavigation().moveTo(pet, 1.0);
} else {
master.getNavigation().stop();
// Perform inspection on first tick when close
if (inspectTimer == 0) {
performInspection(pet);
}
}
inspectTimer++;
}
/**
* Inspect pet's inventory and confiscate contraband.
*/
private void performInspection(ServerPlayer pet) {
pet.sendSystemMessage(
Component.literal(
master.getNpcName() + " is inspecting your inventory..."
).withStyle(Style.EMPTY.withColor(0xFFFF00))
);
// Check all inventory slots
for (int i = 0; i < pet.getInventory().getContainerSize(); i++) {
ItemStack stack = pet.getInventory().getItem(i);
if (stack.isEmpty()) continue;
if (isContraband(stack)) {
// Confiscate
confiscatedItems.add(stack.copy());
pet.getInventory().setItem(i, ItemStack.EMPTY);
TiedUpMod.LOGGER.debug(
"[MasterInventoryInspectGoal] {} confiscated {} from {}",
master.getNpcName(),
stack.getDisplayName().getString(),
pet.getName().getString()
);
}
}
}
/**
* Check if an item is contraband.
*/
private boolean isContraband(ItemStack stack) {
Item item = stack.getItem();
// Check tags
if (stack.is(ESCAPE_TOOL_TAG) || stack.is(WEAPON_TAG)) {
return true;
}
// Check specific vanilla items
if (
item == Items.SHEARS ||
item == Items.IRON_SWORD ||
item == Items.DIAMOND_SWORD ||
item == Items.NETHERITE_SWORD ||
item == Items.IRON_AXE ||
item == Items.DIAMOND_AXE ||
item == Items.NETHERITE_AXE ||
item == Items.BOW ||
item == Items.CROSSBOW
) {
return true;
}
// Check for mod items by registry name
ResourceLocation registryName =
net.minecraftforge.registries.ForgeRegistries.ITEMS.getKey(item);
if (
registryName != null && registryName.getNamespace().equals("tiedup")
) {
String path = registryName.getPath();
// Lockpicks and knives are contraband
if (path.contains("lockpick") || path.contains("knife")) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,114 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityMaster;
import java.util.EnumSet;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
/**
* AI Goal for EntityMaster to observe their pet player.
*
* In OBSERVING state, the Master watches the player intently.
* This state prevents escape attempts from succeeding.
*
* The Master periodically enters DISTRACTED state, creating
* windows where the player can attempt to struggle free.
*/
public class MasterObservePlayerGoal extends Goal {
private final EntityMaster master;
/** How long to observe before potentially doing something else (ticks) */
private static final int OBSERVE_DURATION = 200; // 10 seconds
/** Chance to transition to OBSERVING each tick when FOLLOWING */
private static final float OBSERVE_CHANCE = 0.005f; // ~10% per 10 seconds
private int observeTimer = 0;
public MasterObservePlayerGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
// Must have a pet
if (!master.hasPet()) {
return false;
}
MasterState state = master.getStateManager().getCurrentState();
// If already observing, continue
if (state == MasterState.OBSERVING) {
return true;
}
// Random chance to start observing when following
if (state == MasterState.FOLLOWING) {
return master.getRandom().nextFloat() < OBSERVE_CHANCE;
}
return false;
}
@Override
public boolean canContinueToUse() {
if (!master.hasPet()) {
return false;
}
ServerPlayer pet = master.getPetPlayer();
if (pet == null || !pet.isAlive()) {
return false;
}
// Continue observing until timer expires
return (
observeTimer < OBSERVE_DURATION &&
master.getStateManager().getCurrentState() == MasterState.OBSERVING
);
}
@Override
public void start() {
this.observeTimer = 0;
master.setMasterState(MasterState.OBSERVING);
TiedUpMod.LOGGER.debug(
"[MasterObservePlayerGoal] {} started observing pet",
master.getNpcName()
);
}
@Override
public void stop() {
this.observeTimer = 0;
// Return to following if still has pet
if (
master.hasPet() &&
master.getStateManager().getCurrentState() == MasterState.OBSERVING
) {
master.setMasterState(MasterState.FOLLOWING);
}
TiedUpMod.LOGGER.debug(
"[MasterObservePlayerGoal] {} stopped observing",
master.getNpcName()
);
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Stare at pet intensely
master.getLookControl().setLookAt(pet, 180.0F, 180.0F);
observeTimer++;
}
}

View File

@@ -0,0 +1,454 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.v2.V2Blocks;
import com.tiedup.remake.v2.blocks.PetBedBlock;
import com.tiedup.remake.v2.blocks.PetBowlBlock;
import com.tiedup.remake.v2.blocks.PetBowlBlockEntity;
import java.util.EnumSet;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
/**
* AI Goal for EntityMaster to place Bowl/Bed blocks for pet.
*
* This goal is triggered by specific pet needs:
* - Hungry: Place PET_BOWL, wait for pet to eat, then retrieve
* - Tired: Place PET_BED, wait for pet to rest, then retrieve
*
* The Master places blocks temporarily and retrieves them after use.
* Can be triggered automatically (pet hunger) or manually via menu.
*
* Uses V2 blocks:
* - Bowl: V2Blocks.PET_BOWL for pet feeding
* - Bed: V2Blocks.PET_BED for pet sleeping
*/
public class MasterPlaceBlockGoal extends Goal {
private final EntityMaster master;
/** Current action being performed */
private PlaceAction currentAction = PlaceAction.NONE;
/** Position where block was placed */
private BlockPos placedBlockPos = null;
/** Timer for current action */
private int actionTimer = 0;
/** Distance to place blocks from pet */
private static final double PLACE_DISTANCE = 2.0;
/** Time to wait for pet to use the block (ticks) */
private static final int USE_WAIT_TIME = 600; // 30 seconds (increased from 10s)
/** Time to wait before retrieving block (ticks) */
private static final int RETRIEVE_DELAY = 100; // 5 seconds after use
private enum PlaceAction {
NONE,
PLACING_BOWL, // Walking to place bowl
WAITING_EAT, // Waiting for pet to eat
PLACING_PET_BED, // Walking to place pet bed
WAITING_SLEEP, // Waiting for pet to rest
RETRIEVING, // Picking up blocks
}
public MasterPlaceBlockGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE));
}
@Override
public boolean canUse() {
// Must have a pet
if (!master.hasPet()) return false;
// If an action is triggered manually (via triggerFeeding/triggerResting), always allow
if (currentAction != PlaceAction.NONE) return true;
// For automatic triggers, must be in following state
MasterState state = master.getStateManager().getCurrentState();
if (state != MasterState.FOLLOWING) return false;
// Check pet needs (only trigger on actual needs)
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return false;
// Check if pet is hungry (food level < 10)
if (pet.getFoodData().getFoodLevel() < 10) {
currentAction = PlaceAction.PLACING_BOWL;
return true;
}
// Check if pet is tired (could check for sleep deprivation effect, etc.)
// For now, this is disabled - only triggered manually or via events
// if (isPetTired(pet)) {
// currentAction = PlaceAction.PLACING_PET_BED;
// return true;
// }
return false;
}
@Override
public boolean canContinueToUse() {
if (!master.hasPet()) return false;
return currentAction != PlaceAction.NONE;
}
@Override
public void start() {
this.actionTimer = 0;
this.placedBlockPos = null;
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Announce intent based on action
switch (currentAction) {
case PLACING_BOWL -> {
DialogueBridge.talkTo(master, pet, "petplay.feeding");
TiedUpMod.LOGGER.debug(
"[MasterPlaceBlockGoal] {} placing food bowl for {}",
master.getNpcName(),
pet.getName().getString()
);
}
case PLACING_PET_BED -> {
DialogueBridge.talkTo(master, pet, "petplay.resting");
TiedUpMod.LOGGER.debug(
"[MasterPlaceBlockGoal] {} placing pet bed for {}",
master.getNpcName(),
pet.getName().getString()
);
}
default -> {
}
}
}
@Override
public void stop() {
// Cleanup - retrieve any placed blocks
if (
placedBlockPos != null &&
master.level() instanceof ServerLevel serverLevel
) {
serverLevel.setBlock(
placedBlockPos,
Blocks.AIR.defaultBlockState(),
3
);
placedBlockPos = null;
}
this.currentAction = PlaceAction.NONE;
this.actionTimer = 0;
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) {
currentAction = PlaceAction.NONE;
return;
}
actionTimer++;
switch (currentAction) {
case PLACING_BOWL -> tickPlacingBowl(pet);
case WAITING_EAT -> tickWaitingEat(pet);
case PLACING_PET_BED -> tickPlacingPetBed(pet);
case WAITING_SLEEP -> tickWaitingSleep(pet);
case RETRIEVING -> tickRetrieving();
default -> {
}
}
}
private void tickPlacingBowl(ServerPlayer pet) {
// Move near pet
double distSq = master.distanceToSqr(pet);
if (distSq > PLACE_DISTANCE * PLACE_DISTANCE + 4) {
master.getNavigation().moveTo(pet, 1.0);
return;
}
master.getNavigation().stop();
// Find placement position and place bowl (facing toward the pet)
BlockPos pos = findPlacementPos(pet);
Direction facing = Direction.fromYRot(master.getYRot()).getOpposite();
BlockState bowlState = V2Blocks.PET_BOWL.get()
.defaultBlockState()
.setValue(PetBowlBlock.FACING, facing);
if (pos != null && placeBlock(pos, bowlState)) {
placedBlockPos = pos;
currentAction = PlaceAction.WAITING_EAT;
actionTimer = 0;
// Fill the bowl with food (20 = full)
if (master.level() instanceof ServerLevel serverLevel) {
if (
serverLevel.getBlockEntity(pos) instanceof
PetBowlBlockEntity bowl
) {
bowl.fillBowl(20);
}
}
// Tell pet to eat
DialogueBridge.talkTo(master, pet, "petplay.eat_command");
TiedUpMod.LOGGER.debug(
"[MasterPlaceBlockGoal] {} placed food bowl at {}",
master.getNpcName(),
pos
);
} else {
// Can't place, abort
TiedUpMod.LOGGER.warn(
"[MasterPlaceBlockGoal] {} couldn't find place for bowl",
master.getNpcName()
);
currentAction = PlaceAction.NONE;
}
}
private void tickWaitingEat(ServerPlayer pet) {
// Look at pet
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
// Check if pet has eaten (food level restored)
if (pet.getFoodData().getFoodLevel() >= 16) {
// Pet finished eating
DialogueBridge.talkTo(master, pet, "petplay.good_pet");
currentAction = PlaceAction.RETRIEVING;
actionTimer = 0;
return;
}
// Timeout - pet didn't eat
if (actionTimer > USE_WAIT_TIME) {
DialogueBridge.talkTo(master, pet, "petplay.disappointed");
currentAction = PlaceAction.RETRIEVING;
actionTimer = 0;
}
}
private void tickPlacingPetBed(ServerPlayer pet) {
// Move near pet
double distSq = master.distanceToSqr(pet);
if (distSq > PLACE_DISTANCE * PLACE_DISTANCE + 4) {
master.getNavigation().moveTo(pet, 1.0);
return;
}
master.getNavigation().stop();
// Find placement position and place bed (facing toward the pet)
BlockPos pos = findPlacementPos(pet);
Direction facing = Direction.fromYRot(master.getYRot()).getOpposite();
BlockState bedState = V2Blocks.PET_BED.get()
.defaultBlockState()
.setValue(PetBedBlock.FACING, facing);
if (pos != null && placeBlock(pos, bedState)) {
placedBlockPos = pos;
currentAction = PlaceAction.WAITING_SLEEP;
actionTimer = 0;
// Tell pet to rest
DialogueBridge.talkTo(master, pet, "petplay.rest_command");
TiedUpMod.LOGGER.debug(
"[MasterPlaceBlockGoal] {} placed pet bed at {}",
master.getNpcName(),
pos
);
} else {
// Can't place, abort
currentAction = PlaceAction.NONE;
}
}
private void tickWaitingSleep(ServerPlayer pet) {
// Look at pet
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
// FIX: Check if pet is currently sleeping
if (pet.isSleeping()) {
// Pet is sleeping - reset timer to let them sleep as long as they want
// Only start counting down after they wake up
actionTimer = 0;
return;
}
// Pet is not sleeping (either hasn't started or has woken up)
// Timeout - retrieve pet bed after some time of NOT sleeping
if (actionTimer > USE_WAIT_TIME) {
DialogueBridge.talkTo(master, pet, "petplay.wake_up");
currentAction = PlaceAction.RETRIEVING;
actionTimer = 0;
}
}
private void tickRetrieving() {
if (actionTimer > RETRIEVE_DELAY) {
// FIX: Safety check - don't remove block if pet is still sleeping
ServerPlayer pet = master.getPetPlayer();
if (pet != null && pet.isSleeping()) {
// Pet is still sleeping - wait for them to wake up
actionTimer = 0; // Reset timer
return;
}
// Remove the placed block
if (
placedBlockPos != null &&
master.level() instanceof ServerLevel serverLevel
) {
serverLevel.setBlock(
placedBlockPos,
Blocks.AIR.defaultBlockState(),
3
);
TiedUpMod.LOGGER.debug(
"[MasterPlaceBlockGoal] {} retrieved block at {}",
master.getNpcName(),
placedBlockPos
);
placedBlockPos = null;
}
// Done with this action
currentAction = PlaceAction.NONE;
}
}
/**
* Find a suitable position to place a block near the pet.
*/
private BlockPos findPlacementPos(ServerPlayer pet) {
BlockPos petPos = pet.blockPosition();
// Search for valid placement nearby
for (int dx = -2; dx <= 2; dx++) {
for (int dz = -2; dz <= 2; dz++) {
if (dx == 0 && dz == 0) continue; // Not on pet
BlockPos pos = petPos.offset(dx, 0, dz);
if (isValidPlacement(pos)) {
return pos;
}
// Try one block lower
BlockPos lower = pos.below();
if (isValidPlacement(lower)) {
return lower;
}
}
}
return null;
}
/**
* Check if position is valid for block placement.
*/
private boolean isValidPlacement(BlockPos pos) {
BlockState below = master.level().getBlockState(pos.below());
BlockState at = master.level().getBlockState(pos);
return below.isSolidRender(master.level(), pos.below()) && at.isAir();
}
/**
* Place a block at the given position.
*/
private boolean placeBlock(BlockPos pos, BlockState state) {
if (!(master.level() instanceof ServerLevel serverLevel)) {
return false;
}
serverLevel.setBlock(pos, state, 3);
return true;
}
/**
* Manually trigger feeding action (called from events/commands).
*/
public void triggerFeeding() {
if (currentAction == PlaceAction.NONE && master.hasPet()) {
currentAction = PlaceAction.PLACING_BOWL;
}
}
/**
* Manually trigger resting action (called from events/commands).
*/
public void triggerResting() {
if (currentAction == PlaceAction.NONE && master.hasPet()) {
currentAction = PlaceAction.PLACING_PET_BED;
}
}
// ========================================
// FIX: NBT PERSISTENCE FOR PLACED BLOCKS
// ========================================
/**
* Get the currently placed block position (for NBT saving).
*/
public BlockPos getPlacedBlockPos() {
return placedBlockPos;
}
/**
* Set the placed block position (for NBT loading).
* Also cleans up the block if position is set on load.
*/
public void setPlacedBlockPos(BlockPos pos) {
this.placedBlockPos = pos;
}
/**
* Cleanup orphaned blocks after entity load.
* Called from EntityMaster after loading NBT if a position was saved.
*/
public void cleanupOrphanedBlock() {
if (
placedBlockPos != null &&
master.level() instanceof ServerLevel serverLevel
) {
BlockState state = serverLevel.getBlockState(placedBlockPos);
// Only remove if it's one of our V2 pet blocks
if (
state.is(V2Blocks.PET_BOWL.get()) ||
state.is(V2Blocks.PET_BED.get())
) {
serverLevel.setBlock(
placedBlockPos,
Blocks.AIR.defaultBlockState(),
3
);
TiedUpMod.LOGGER.info(
"[MasterPlaceBlockGoal] {} cleaned up orphaned block at {}",
master.getNpcName(),
placedBlockPos
);
}
placedBlockPos = null;
}
}
}

View File

@@ -0,0 +1,555 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.items.ItemChokeCollar;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.BindVariant;
import com.tiedup.remake.items.base.BlindfoldVariant;
import com.tiedup.remake.items.base.GagVariant;
import com.tiedup.remake.items.base.MittensVariant;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
/**
* AI Goal for EntityMaster to punish their pet player.
*
* Triggered when:
* - Struggle attempt detected while master was watching
* - Contraband found during inspection
* - Task failure
* - Pet attacks the master (dual punishment: choke + physical restraint)
*
* Punishment types (selected from available options):
* - Choke collar activation (requires choke collar)
* - Temporary blindfold (requires empty blindfold slot)
* - Temporary gag (requires empty gag slot)
* - Temporary mittens (requires empty mittens slot)
* - Tighten restraints / apply armbinder (requires pet unbound)
* - Cold shoulder (always available)
* - Leash tug (always available)
*/
public class MasterPunishGoal extends Goal {
private final EntityMaster master;
/** Approach distance for punishment */
private static final double PUNISH_DISTANCE = 2.0;
/** Duration of punishment sequence (ticks) */
private static final int PUNISH_DURATION = 80;
/** Maximum choke duration before deactivating (ticks) - 3 seconds */
private static final int MAX_CHOKE_TIME = 60;
/** Duration of temporary punishment items (2 minutes) */
private static final int TEMP_ITEM_DURATION = 2400;
/** Fallback damage if no punishment method available */
private static final float FALLBACK_DAMAGE = 2.0f;
/** Delay between primary choke and secondary restraint (ticks) - 0.75s */
private static final int SECONDARY_PUNISHMENT_DELAY = 15;
/** Maximum punishment duration safety cap (ticks) - 8 seconds */
private static final int MAX_PUNISH_DURATION = 160;
/** NBT tags for temporary punishment items */
private static final String NBT_TEMP_MASTER_EVENT = "tempMasterEvent";
private static final String NBT_EXPIRATION_TIME = "expirationTime";
private int punishTimer = 0;
private boolean hasAppliedPunishment = false;
private PunishmentType selectedPunishment = null;
/** Timer for tracking choke duration - 0 means not choking */
private int chokeActiveTimer = 0;
/** Reference to the active choke collar for deactivation */
private ItemStack activeChokeCollar = ItemStack.EMPTY;
/** Leash tug timer */
private int leashTugTimer = 0;
// --- Attack punishment (dual: choke + physical restraint) ---
/** Whether this is an attack-triggered dual punishment */
private boolean isAttackPunishment = false;
/** The physical restraint to apply after the choke */
private PunishmentType secondaryPunishment = null;
/** Whether the secondary restraint has been applied */
private boolean hasAppliedSecondary = false;
/** Tick count when the primary punishment was applied (for delay timing) */
private int primaryAppliedAtTick = -1;
public MasterPunishGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
return (
master.getStateManager().getCurrentState() == MasterState.PUNISH &&
master.hasPet() &&
master.getPetPlayer() != null
);
}
@Override
public boolean canContinueToUse() {
if (master.getStateManager().getCurrentState() != MasterState.PUNISH) {
return false;
}
// Extend duration if waiting for secondary punishment, with safety cap
if (
isAttackPunishment &&
!hasAppliedSecondary &&
punishTimer < MAX_PUNISH_DURATION
) {
return true;
}
return punishTimer < PUNISH_DURATION;
}
@Override
public void start() {
this.punishTimer = 0;
this.hasAppliedPunishment = false;
this.chokeActiveTimer = 0;
this.activeChokeCollar = ItemStack.EMPTY;
this.leashTugTimer = 0;
this.isAttackPunishment = false;
this.secondaryPunishment = null;
this.hasAppliedSecondary = false;
this.primaryAppliedAtTick = -1;
// Check if this is an attack-triggered punishment (dual: choke + restraint)
if (master.consumeAttackPunishment()) {
this.isAttackPunishment = true;
this.selectedPunishment = PunishmentType.CHOKE_COLLAR;
this.secondaryPunishment = selectPhysicalRestraint();
TiedUpMod.LOGGER.debug(
"[MasterPunishGoal] {} starting ATTACK punishment: {} + {}",
master.getNpcName(),
selectedPunishment,
secondaryPunishment
);
} else {
this.selectedPunishment = selectPunishment();
TiedUpMod.LOGGER.debug(
"[MasterPunishGoal] {} starting punishment: {}",
master.getNpcName(),
selectedPunishment
);
}
master.markEngagement();
}
@Override
public void stop() {
deactivateChoke();
this.punishTimer = 0;
this.hasAppliedPunishment = false;
this.chokeActiveTimer = 0;
this.activeChokeCollar = ItemStack.EMPTY;
this.leashTugTimer = 0;
this.selectedPunishment = null;
this.isAttackPunishment = false;
this.secondaryPunishment = null;
this.hasAppliedSecondary = false;
this.primaryAppliedAtTick = -1;
if (master.hasPet()) {
master.setMasterState(MasterState.FOLLOWING);
}
TiedUpMod.LOGGER.debug(
"[MasterPunishGoal] {} punishment complete",
master.getNpcName()
);
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
double distSq = master.distanceToSqr(pet);
// Move close for punishment
if (distSq > PUNISH_DISTANCE * PUNISH_DISTANCE) {
master.getNavigation().moveTo(pet, 1.2);
} else {
master.getNavigation().stop();
if (!hasAppliedPunishment) {
applyPunishment(pet);
hasAppliedPunishment = true;
primaryAppliedAtTick = punishTimer;
}
}
// Apply secondary punishment after delay (attack punishment only)
if (
isAttackPunishment &&
!hasAppliedSecondary &&
primaryAppliedAtTick >= 0 &&
punishTimer >= primaryAppliedAtTick + SECONDARY_PUNISHMENT_DELAY
) {
applySecondaryPunishment(pet);
hasAppliedSecondary = true;
}
// Track choke duration
if (chokeActiveTimer > 0) {
chokeActiveTimer++;
if (chokeActiveTimer >= MAX_CHOKE_TIME) {
deactivateChoke();
}
}
// Track leash tug
if (leashTugTimer > 0) {
leashTugTimer--;
if (leashTugTimer <= 0) {
master.detachLeashFromPet();
}
}
punishTimer++;
}
/**
* Select a punishment type from available options (normal punishments).
*/
private PunishmentType selectPunishment() {
// Check for forced punishment (e.g., from other systems)
PunishmentType forced = master.consumeForcedPunishment();
if (forced != null) return forced;
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return PunishmentType.COLD_SHOULDER;
PlayerBindState bindState = PlayerBindState.getInstance(pet);
List<PunishmentType> available = new ArrayList<>();
// CHOKE: only if pet has choke collar
if (bindState != null && bindState.hasCollar()) {
ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK);
if (collar.getItem() instanceof ItemChokeCollar) {
available.add(PunishmentType.CHOKE_COLLAR);
}
}
// BLINDFOLD: only if eyes region is empty
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.EYES)) {
available.add(PunishmentType.BLINDFOLD);
}
// GAG: only if mouth region is empty
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.MOUTH)) {
available.add(PunishmentType.GAG);
}
// MITTENS: only if hands region is empty
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.HANDS)) {
available.add(PunishmentType.MITTENS);
}
// TIGHTEN: only if pet is not already bound
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.ARMS)) {
available.add(PunishmentType.TIGHTEN_RESTRAINTS);
}
// COLD_SHOULDER and LEASH_TUG: always available
available.add(PunishmentType.COLD_SHOULDER);
available.add(PunishmentType.LEASH_TUG);
return available.get(master.getRandom().nextInt(available.size()));
}
/**
* Select a physical restraint for attack punishment secondary.
* Excludes CHOKE_COLLAR (already primary) and COLD_SHOULDER (not physical).
* Always includes LEASH_TUG as fallback when all equipment slots are full.
*/
private PunishmentType selectPhysicalRestraint() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return PunishmentType.LEASH_TUG;
List<PunishmentType> available = new ArrayList<>();
// BLINDFOLD: only if eyes region is empty
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.EYES)) {
available.add(PunishmentType.BLINDFOLD);
}
// GAG: only if mouth region is empty
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.MOUTH)) {
available.add(PunishmentType.GAG);
}
// MITTENS: only if hands region is empty
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.HANDS)) {
available.add(PunishmentType.MITTENS);
}
// TIGHTEN: only if pet is not already bound
if (!V2EquipmentHelper.isRegionOccupied(pet, BodyRegionV2.ARMS)) {
available.add(PunishmentType.TIGHTEN_RESTRAINTS);
}
// LEASH_TUG: always available as fallback
available.add(PunishmentType.LEASH_TUG);
return available.get(master.getRandom().nextInt(available.size()));
}
/**
* Apply the selected punishment to the pet.
*/
private void applyPunishment(ServerPlayer pet) {
if (selectedPunishment == null) {
selectedPunishment = PunishmentType.COLD_SHOULDER;
}
// Send dialogue
DialogueBridge.talkTo(master, pet, selectedPunishment.getDialogueId());
switch (selectedPunishment) {
case CHOKE_COLLAR -> applyChoke(pet);
case BLINDFOLD -> applyTempAccessory(pet, BodyRegionV2.EYES);
case GAG -> applyTempAccessory(pet, BodyRegionV2.MOUTH);
case MITTENS -> applyTempAccessory(pet, BodyRegionV2.HANDS);
case TIGHTEN_RESTRAINTS -> applyTighten(pet);
case COLD_SHOULDER -> applyColdShoulder();
case LEASH_TUG -> applyLeashTug(pet);
}
TiedUpMod.LOGGER.info(
"[MasterPunishGoal] {} applied {} to {}",
master.getNpcName(),
selectedPunishment,
pet.getName().getString()
);
}
/**
* Apply the secondary physical restraint after the choke (attack punishment only).
*/
private void applySecondaryPunishment(ServerPlayer pet) {
if (secondaryPunishment == null) return;
// Send dialogue for the secondary punishment
DialogueBridge.talkTo(master, pet, secondaryPunishment.getDialogueId());
switch (secondaryPunishment) {
case BLINDFOLD -> applyTempAccessory(pet, BodyRegionV2.EYES);
case GAG -> applyTempAccessory(pet, BodyRegionV2.MOUTH);
case MITTENS -> applyTempAccessory(pet, BodyRegionV2.HANDS);
case TIGHTEN_RESTRAINTS -> applyTighten(pet);
case LEASH_TUG -> applyLeashTug(pet);
default -> {
} // CHOKE_COLLAR and COLD_SHOULDER excluded from secondary
}
TiedUpMod.LOGGER.info(
"[MasterPunishGoal] {} applied secondary {} to {}",
master.getNpcName(),
secondaryPunishment,
pet.getName().getString()
);
}
/**
* Apply choke collar punishment (existing logic).
*/
private void applyChoke(ServerPlayer pet) {
PlayerBindState bindState = PlayerBindState.getInstance(pet);
if (bindState != null && bindState.hasCollar()) {
ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK);
if (collar.getItem() instanceof ItemChokeCollar chokeCollar) {
chokeCollar.setChoking(collar, true);
this.activeChokeCollar = collar;
this.chokeActiveTimer = 1;
pet
.level()
.playSound(
null,
pet.getX(),
pet.getY(),
pet.getZ(),
SoundEvents.PLAYER_HURT,
SoundSource.HOSTILE,
0.8f,
0.5f + master.getRandom().nextFloat() * 0.2f
);
return;
}
}
// Fallback
pet.hurt(pet.damageSources().magic(), FALLBACK_DAMAGE);
}
/**
* Apply a temporary accessory (blindfold, gag, or mittens) as punishment.
* Uses the same temp-item pattern as MasterRandomEventGoal.
*/
private void applyTempAccessory(ServerPlayer pet, BodyRegionV2 region) {
ItemStack accessory = createAccessory(region);
if (accessory.isEmpty()) return;
// Mark as temporary with expiration
long expirationTime = master.level().getGameTime() + TEMP_ITEM_DURATION;
CompoundTag tag = accessory.getOrCreateTag();
tag.putBoolean(NBT_TEMP_MASTER_EVENT, true);
tag.putLong(NBT_EXPIRATION_TIME, expirationTime);
tag.putUUID("masterUUID", master.getUUID());
IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(pet);
if (equip != null) {
equip.setInRegion(region, accessory);
V2EquipmentHelper.sync(pet);
}
pet
.level()
.playSound(
null,
pet.getX(),
pet.getY(),
pet.getZ(),
SoundEvents.ARMOR_EQUIP_LEATHER,
SoundSource.HOSTILE,
1.0f,
0.8f
);
}
/**
* Create an accessory item for the given body region.
*/
private ItemStack createAccessory(BodyRegionV2 region) {
return switch (region) {
case EYES -> new ItemStack(
ModItems.getBlindfold(BlindfoldVariant.CLASSIC)
);
case MOUTH -> new ItemStack(ModItems.getGag(GagVariant.BALL_GAG));
case HANDS -> new ItemStack(
ModItems.getMittens(MittensVariant.LEATHER)
);
default -> ItemStack.EMPTY;
};
}
/**
* Apply armbinder as punishment.
*/
private void applyTighten(ServerPlayer pet) {
ItemStack armbinder = new ItemStack(
ModItems.getBind(BindVariant.ARMBINDER)
);
// Mark as temporary
long expirationTime = master.level().getGameTime() + TEMP_ITEM_DURATION;
CompoundTag tag = armbinder.getOrCreateTag();
tag.putBoolean(NBT_TEMP_MASTER_EVENT, true);
tag.putLong(NBT_EXPIRATION_TIME, expirationTime);
tag.putUUID("masterUUID", master.getUUID());
IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(pet);
if (equip != null) {
equip.setInRegion(BodyRegionV2.ARMS, armbinder);
V2EquipmentHelper.sync(pet);
}
pet
.level()
.playSound(
null,
pet.getX(),
pet.getY(),
pet.getZ(),
SoundEvents.ARMOR_EQUIP_CHAIN,
SoundSource.HOSTILE,
1.0f,
0.7f
);
}
/**
* Apply cold shoulder - master ignores pet interactions.
*/
private void applyColdShoulder() {
master.startColdShoulder(
PunishmentType.COLD_SHOULDER.getDurationTicks()
);
}
/**
* Apply leash tug - yank pet toward master.
*/
private void applyLeashTug(ServerPlayer pet) {
// Attach temp leash
master.attachLeashToPet();
this.leashTugTimer = PunishmentType.LEASH_TUG.getDurationTicks();
// Apply velocity toward master
Vec3 direction = master.position().subtract(pet.position()).normalize();
pet.setDeltaMovement(direction.scale(1.2));
pet.hurtMarked = true;
pet
.level()
.playSound(
null,
pet.getX(),
pet.getY(),
pet.getZ(),
SoundEvents.CHAIN_HIT,
SoundSource.HOSTILE,
1.0f,
1.0f
);
}
/**
* Deactivate the choke collar if active.
*/
private void deactivateChoke() {
if (
!activeChokeCollar.isEmpty() &&
activeChokeCollar.getItem() instanceof ItemChokeCollar chokeCollar
) {
chokeCollar.setChoking(activeChokeCollar, false);
}
this.activeChokeCollar = ItemStack.EMPTY;
this.chokeActiveTimer = 0;
}
}

View File

@@ -0,0 +1,428 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.BindVariant;
import com.tiedup.remake.items.base.BlindfoldVariant;
import com.tiedup.remake.items.base.GagVariant;
import com.tiedup.remake.items.base.MittensVariant;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.EnumSet;
import java.util.List;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
/**
* AI Goal for EntityMaster to trigger random events:
* - Random bind/accessory placement (temporary)
* - Master-initiated dogwalk
*
* Events are less frequent than tasks and add variety to gameplay.
*/
public class MasterRandomEventGoal extends Goal {
private final EntityMaster master;
/** Chance to trigger an event per tick */
private static final float EVENT_CHANCE = 0.002f; // ~4% per 10 seconds
/** Minimum time between events (ticks) - 2 minutes */
private static final int EVENT_COOLDOWN = 2400;
/** Duration of random bind event (ticks) - 2 minutes */
private static final int RANDOM_BIND_DURATION = 2400;
/** NBT tag key for temporary master event items */
private static final String NBT_TEMP_MASTER_EVENT = "tempMasterEvent";
private static final String NBT_EXPIRATION_TIME = "expirationTime";
/** Event types that can be triggered randomly */
private enum RandomEvent {
RANDOM_BIND,
DOGWALK,
HUMAN_CHAIR,
}
/** Accessory regions that can be randomly equipped (eyes, mouth, hands) */
private static final List<BodyRegionV2> RANDOM_ACCESSORY_REGIONS = List.of(
BodyRegionV2.EYES,
BodyRegionV2.MOUTH,
BodyRegionV2.HANDS
);
private long lastEventTime = 0;
private RandomEvent currentEvent = null;
private int eventTimer = 0;
/** For RANDOM_BIND: the body region that was equipped */
private BodyRegionV2 appliedAccessoryRegion = null;
private ItemStack appliedAccessory = ItemStack.EMPTY;
public MasterRandomEventGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
if (!master.hasPet()) return false;
MasterState state = master.getStateManager().getCurrentState();
// Don't trigger events during other activities
if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) {
return false;
}
// Don't trigger if there's an active task
if (master.hasActiveTask()) return false;
// Check cooldown
long currentTime = master.level().getGameTime();
if (currentTime - lastEventTime < EVENT_COOLDOWN) {
return false;
}
// Random chance (modulated by engagement cadence)
float multiplier = master.getEngagementMultiplier();
return (
multiplier > 0 &&
master.getRandom().nextFloat() < EVENT_CHANCE * multiplier
);
}
@Override
public boolean canContinueToUse() {
// Only continue for timed events like RANDOM_BIND
return (
currentEvent == RandomEvent.RANDOM_BIND &&
eventTimer < RANDOM_BIND_DURATION
);
}
@Override
public void start() {
this.eventTimer = 0;
this.appliedAccessoryRegion = null;
this.appliedAccessory = ItemStack.EMPTY;
// Select random event
RandomEvent[] events = RandomEvent.values();
this.currentEvent = events[master.getRandom().nextInt(events.length)];
master.markEngagement();
triggerEvent();
TiedUpMod.LOGGER.debug(
"[MasterRandomEventGoal] {} triggered event: {}",
master.getNpcName(),
currentEvent
);
}
@Override
public void stop() {
lastEventTime = master.level().getGameTime();
// Only remove accessory on natural completion (timer expired),
// NOT on goal preemption (e.g. MasterObservePlayerGoal taking over LOOK flag).
// If preempted, the NBT expiration timer in cleanupExpiredTempItems() handles removal.
if (
currentEvent == RandomEvent.RANDOM_BIND &&
appliedAccessoryRegion != null &&
eventTimer >= RANDOM_BIND_DURATION
) {
removeAppliedAccessory();
}
this.currentEvent = null;
this.eventTimer = 0;
this.appliedAccessoryRegion = null;
this.appliedAccessory = ItemStack.EMPTY;
TiedUpMod.LOGGER.debug(
"[MasterRandomEventGoal] {} event completed",
master.getNpcName()
);
}
@Override
public void tick() {
if (currentEvent == RandomEvent.RANDOM_BIND) {
eventTimer++;
}
}
/**
* Trigger the selected event.
*/
private void triggerEvent() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
switch (currentEvent) {
case RANDOM_BIND -> triggerRandomBind(pet);
case DOGWALK -> triggerDogwalk(pet);
case HUMAN_CHAIR -> triggerHumanChair(pet);
}
}
/**
* Trigger random bind event - apply a temporary accessory.
*/
private void triggerRandomBind(ServerPlayer pet) {
PlayerBindState bindState = PlayerBindState.getInstance(pet);
if (bindState == null) return;
// Collect all empty accessory regions, then pick one at random
List<BodyRegionV2> emptyRegions = new java.util.ArrayList<>();
for (BodyRegionV2 region : RANDOM_ACCESSORY_REGIONS) {
if (!V2EquipmentHelper.isRegionOccupied(pet, region)) {
emptyRegions.add(region);
}
}
BodyRegionV2 selectedRegion = null;
if (!emptyRegions.isEmpty()) {
selectedRegion = emptyRegions.get(
master.getRandom().nextInt(emptyRegions.size())
);
} else if (master.getRandom().nextFloat() < 0.3f) {
// If no empty region, pick random and replace (less likely)
selectedRegion = RANDOM_ACCESSORY_REGIONS.get(
master.getRandom().nextInt(RANDOM_ACCESSORY_REGIONS.size())
);
}
if (selectedRegion == null) {
// Pet is fully accessorized, skip event
currentEvent = null;
return;
}
// Create the accessory item
ItemStack accessory = createRandomAccessory(selectedRegion);
if (accessory.isEmpty()) {
currentEvent = null;
return;
}
// Mark the accessory as temporary with expiration time
// This ensures cleanup even after server restart
long expirationTime =
master.level().getGameTime() + RANDOM_BIND_DURATION;
CompoundTag tag = accessory.getOrCreateTag();
tag.putBoolean(NBT_TEMP_MASTER_EVENT, true);
tag.putLong(NBT_EXPIRATION_TIME, expirationTime);
tag.putUUID("masterUUID", master.getUUID());
// Apply the accessory
this.appliedAccessoryRegion = selectedRegion;
this.appliedAccessory = accessory;
IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(pet);
if (equip != null) {
equip.setInRegion(selectedRegion, accessory);
V2EquipmentHelper.sync(pet);
}
// Dialogue - both messages sent (random_bind is the action, random_bind_done is the completion)
DialogueBridge.talkTo(master, pet, "petplay.random_bind");
TiedUpMod.LOGGER.debug(
"[MasterRandomEventGoal] {} applied {} to {}",
master.getNpcName(),
selectedRegion,
pet.getName().getString()
);
}
/**
* Create a random accessory item for the given body region.
*/
private ItemStack createRandomAccessory(BodyRegionV2 region) {
Item item = switch (region) {
case EYES -> ModItems.getBlindfold(BlindfoldVariant.CLASSIC);
case MOUTH -> ModItems.getGag(GagVariant.BALL_GAG);
case HANDS -> ModItems.getMittens(MittensVariant.LEATHER);
default -> null;
};
if (item == null) return ItemStack.EMPTY;
return new ItemStack(item);
}
/**
* Remove the applied accessory when event ends.
*/
private void removeAppliedAccessory() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
if (appliedAccessoryRegion != null) {
// Check if it's still the same item we applied
ItemStack current = V2EquipmentHelper.getInRegion(
pet,
appliedAccessoryRegion
);
if (current.getItem() == appliedAccessory.getItem()) {
V2EquipmentHelper.unequipFromRegion(
pet,
appliedAccessoryRegion,
true
);
// Dialogue
DialogueBridge.talkTo(
master,
pet,
"petplay.random_bind_remove"
);
TiedUpMod.LOGGER.debug(
"[MasterRandomEventGoal] {} removed {} from {}",
master.getNpcName(),
appliedAccessoryRegion,
pet.getName().getString()
);
}
}
}
/**
* Trigger dogwalk event - Master initiates a walk.
*/
private void triggerDogwalk(ServerPlayer pet) {
// Put pet in dogbind if not already tied
PlayerBindState bindState = PlayerBindState.getInstance(pet);
if (bindState != null && !bindState.isTiedUp()) {
ItemStack dogbind = new ItemStack(
ModItems.getBind(BindVariant.DOGBINDER)
);
bindState.equip(BodyRegionV2.ARMS, dogbind);
}
// Attach leash
master.attachLeashToPet();
// Set dogwalk mode - master leads (pulls the pet)
master.setDogwalkMode(true);
master.setMasterState(MasterState.DOGWALK);
// Dialogue
DialogueBridge.talkTo(master, pet, "petplay.start_dogwalk");
TiedUpMod.LOGGER.debug(
"[MasterRandomEventGoal] {} started dogwalk with {}",
master.getNpcName(),
pet.getName().getString()
);
// Event is instant - dogwalk continues until manually ended or timeout
this.currentEvent = null; // Don't continue in this goal
}
/**
* Trigger human chair event - Master uses pet as furniture.
* Delegates to MasterHumanChairGoal for the actual behavior.
*/
private void triggerHumanChair(ServerPlayer pet) {
// Don't trigger if pet is already tied up (dogbind would conflict)
PlayerBindState bindState = PlayerBindState.getInstance(pet);
if (bindState != null && bindState.isTiedUp()) {
// Fall back to random bind instead
currentEvent = RandomEvent.RANDOM_BIND;
triggerRandomBind(pet);
return;
}
// Set state - MasterHumanChairGoal will handle the rest
master.setMasterState(MasterState.HUMAN_CHAIR);
// Dialogue
DialogueBridge.talkTo(master, pet, "petplay.human_chair_start");
TiedUpMod.LOGGER.debug(
"[MasterRandomEventGoal] {} started human chair with {}",
master.getNpcName(),
pet.getName().getString()
);
// Event is instant - human chair continues in dedicated goal
this.currentEvent = null;
}
// ========================================
// STATIC CLEANUP METHODS
// ========================================
/** All body regions that can have temporary master event items */
private static final List<BodyRegionV2> TEMP_ITEM_CLEANUP_REGIONS =
List.of(
BodyRegionV2.ARMS,
BodyRegionV2.EYES,
BodyRegionV2.MOUTH,
BodyRegionV2.HANDS
);
/**
* Check and cleanup any expired temporary master event items on a player.
* Should be called periodically from EntityMaster.tick() or on player login.
*
* @param pet The player to check
* @param currentTime Current game time
*/
public static void cleanupExpiredTempItems(
ServerPlayer pet,
long currentTime
) {
for (BodyRegionV2 region : TEMP_ITEM_CLEANUP_REGIONS) {
ItemStack item = V2EquipmentHelper.getInRegion(pet, region);
if (item.isEmpty()) continue;
CompoundTag tag = item.getTag();
if (tag == null) continue;
if (tag.getBoolean(NBT_TEMP_MASTER_EVENT)) {
long expirationTime = tag.getLong(NBT_EXPIRATION_TIME);
if (currentTime >= expirationTime) {
// Item expired - use proper removal to trigger onUnequipped callbacks
// ARMS (bind) needs PlayerBindState.unequip(BodyRegionV2.ARMS) for speed/animation cleanup
if (region == BodyRegionV2.ARMS) {
PlayerBindState bindState = PlayerBindState.getInstance(
pet
);
if (bindState != null && bindState.isTiedUp()) {
bindState.unequip(BodyRegionV2.ARMS);
}
} else {
V2EquipmentHelper.unequipFromRegion(pet, region, true);
}
TiedUpMod.LOGGER.info(
"[MasterRandomEventGoal] Removed expired temp {} from {}",
region,
pet.getName().getString()
);
}
}
}
}
/**
* Check if an item is a temporary master event item.
*/
public static boolean isTempMasterEventItem(ItemStack stack) {
if (stack.isEmpty()) return false;
CompoundTag tag = stack.getTag();
return tag != null && tag.getBoolean(NBT_TEMP_MASTER_EVENT);
}
}

View File

@@ -0,0 +1,123 @@
package com.tiedup.remake.entities.ai.master;
/**
* Enum defining the behavioral states of a Master entity.
*
* Master NPC follows a different pattern than Kidnapper:
* - FOLLOWING: Master follows their pet (player)
* - OBSERVING: Master is watching the pet (can detect struggle)
* - DISTRACTED: Master is distracted (pet can struggle safely)
* - TASK_ASSIGN: Master is assigning a task to pet
* - TASK_WATCH: Master is watching pet perform a task
* - INSPECT: Master is inspecting pet's inventory for contraband
* - PUNISH: Master is punishing pet for misbehavior
* - PURCHASING: Master is buying a pet from a Kidnapper
*/
public enum MasterState {
/**
* Idle state - Master has nothing specific to do.
* Default state when no other action is available.
*/
IDLE,
/**
* Purchasing state - Master is buying a captive from a Kidnapper.
* This is the initial state when Master spawns.
*/
PURCHASING,
/**
* Following state - Master follows the pet player.
* Unlike normal NPCs, the Master follows the player, not vice versa.
* Maintains 2-8 block distance.
*/
FOLLOWING,
/**
* Observing state - Master is actively watching the pet.
* Can detect struggle attempts in this state.
* Pet cannot escape while being observed.
*/
OBSERVING,
/**
* Distracted state - Master is temporarily distracted.
* Pet can safely struggle in this state.
* Lasts 30-60 seconds, occurs every 2-5 minutes.
*/
DISTRACTED,
/**
* Task assign state - Master is assigning a task to the pet.
* Transitions to TASK_WATCH after assignment.
*/
TASK_ASSIGN,
/**
* Task watch state - Master is supervising pet's task execution.
* Similar to OBSERVING but focused on task completion.
*/
TASK_WATCH,
/**
* Inspect state - Master is checking pet's inventory for contraband.
* Will confiscate lockpicks, knives, weapons, etc.
*/
INSPECT,
/**
* Punish state - Master is punishing the pet.
* Uses shock collar, paddle, or other punishment methods.
* Triggered by: struggle detected, task failed, contraband found.
*/
PUNISH,
/**
* Dogwalk state - Master is taking pet on a walk (event).
* Similar to FOLLOWING but with specific destination.
*/
DOGWALK,
/**
* Human Chair state - Master uses pet as furniture.
* Pet is forced on all fours, cannot move. Master sits on pet.
* Lasts ~2 minutes with idle dialogue.
*/
HUMAN_CHAIR;
/**
* Check if this state allows the master to detect struggles.
* OBSERVING, TASK_WATCH, and FOLLOWING can detect struggles.
*/
public boolean canDetectStruggle() {
return this == OBSERVING || this == TASK_WATCH || this == FOLLOWING;
}
/**
* Check if this state is a "watching" state where Master pays attention.
*/
public boolean isWatching() {
return this == OBSERVING || this == TASK_WATCH || this == INSPECT;
}
/**
* Check if this state allows the pet to struggle without detection.
*/
public boolean allowsSafeStruggle() {
return this == DISTRACTED || this == IDLE;
}
/**
* Check if Master can transition to punishment from this state.
*/
public boolean canTransitionToPunish() {
return this != PUNISH && this != PURCHASING;
}
/**
* Check if Master is actively engaged with pet (not idle).
*/
public boolean isEngaged() {
return this != IDLE && this != DISTRACTED;
}
}

View File

@@ -0,0 +1,435 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.util.MessageDispatcher;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
/**
* AI Goal for EntityMaster to assign tasks to their pet player.
*
* Tasks include:
* - HEEL: Stay close to master (< 3 blocks)
* - WAIT_HERE: Stay at current location (< 1 block movement)
* - FETCH_ITEM: Bring a specific item
*
* After assignment, transitions to TASK_WATCH where MasterTaskWatchGoal monitors compliance.
*/
public class MasterTaskAssignGoal extends Goal {
private final EntityMaster master;
/** Chance to assign task when following (per tick) */
private static final float TASK_CHANCE = 0.003f; // ~6% per 10 seconds
/** Minimum time between tasks (ticks) - 1 minute */
private static final int TASK_COOLDOWN = 1200;
/** Task assignment duration (ticks) */
private static final int ASSIGN_DURATION = 40;
/** Items that can be requested for FETCH_ITEM task */
private static final List<Item> FETCHABLE_ITEMS = List.of(
Items.APPLE,
Items.BREAD,
Items.COOKED_BEEF,
Items.COOKED_CHICKEN,
Items.GOLDEN_APPLE,
Items.COOKIE,
Items.DIAMOND,
Items.EMERALD,
Items.IRON_INGOT,
Items.GOLD_INGOT,
Items.BONE,
Items.FLOWER_BANNER_PATTERN,
Items.ROSE_BUSH,
Items.DANDELION,
Items.POPPY
);
/** Tasks that can be randomly assigned (excludes events like RANDOM_BIND, DOGWALK) */
private static final PetTask[] ASSIGNABLE_TASKS = {
PetTask.HEEL,
PetTask.WAIT_HERE,
PetTask.FETCH_ITEM,
PetTask.KNEEL,
PetTask.COME,
PetTask.PRESENT,
PetTask.SPEAK,
PetTask.DROP,
PetTask.FOLLOW_CLOSE,
PetTask.DEMAND,
};
/** Items the Master considers valuable enough to demand (high-value items) */
private static final Set<Item> HIGH_VALUE_ITEMS = Set.of(
Items.DIAMOND,
Items.EMERALD,
Items.GOLD_INGOT,
Items.GOLDEN_APPLE,
Items.ENCHANTED_GOLDEN_APPLE,
Items.NETHERITE_INGOT,
Items.NETHER_STAR,
Items.TOTEM_OF_UNDYING,
Items.DIAMOND_SWORD,
Items.DIAMOND_PICKAXE,
Items.DIAMOND_AXE,
Items.DIAMOND_CHESTPLATE,
Items.DIAMOND_HELMET,
Items.DIAMOND_LEGGINGS,
Items.DIAMOND_BOOTS,
Items.NETHERITE_SWORD,
Items.NETHERITE_PICKAXE,
Items.NETHERITE_AXE,
Items.NETHERITE_CHESTPLATE,
Items.NETHERITE_HELMET,
Items.NETHERITE_LEGGINGS,
Items.NETHERITE_BOOTS,
Items.TRIDENT,
Items.ELYTRA,
Items.ENDER_PEARL,
Items.BLAZE_ROD,
Items.GHAST_TEAR
);
/** Items of moderate value - fallback if no high-value items found */
private static final Set<Item> MEDIUM_VALUE_ITEMS = Set.of(
Items.IRON_INGOT,
Items.GOLD_INGOT,
Items.LAPIS_LAZULI,
Items.REDSTONE,
Items.IRON_SWORD,
Items.IRON_PICKAXE,
Items.IRON_AXE,
Items.IRON_CHESTPLATE,
Items.COOKED_BEEF,
Items.COOKED_PORKCHOP,
Items.GOLDEN_CARROT,
Items.EXPERIENCE_BOTTLE,
Items.BOOK,
Items.NAME_TAG,
Items.SADDLE,
Items.COMPASS,
Items.CLOCK,
Items.SPYGLASS
);
private int assignTimer = 0;
private long lastTaskTime = 0;
private boolean hasAssigned = false;
private PetTask selectedTask = null;
public MasterTaskAssignGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
if (!master.hasPet()) return false;
MasterState state = master.getStateManager().getCurrentState();
// Already assigning
if (state == MasterState.TASK_ASSIGN) return true;
// Don't assign if already has active task
if (master.hasActiveTask()) return false;
// Can only assign from following/observing
if (state != MasterState.FOLLOWING && state != MasterState.OBSERVING) {
return false;
}
// Check cooldown
long currentTime = master.level().getGameTime();
if (currentTime - lastTaskTime < TASK_COOLDOWN) {
return false;
}
// Random chance (modulated by engagement cadence)
float multiplier = master.getEngagementMultiplier();
return (
multiplier > 0 &&
master.getRandom().nextFloat() < TASK_CHANCE * multiplier
);
}
@Override
public boolean canContinueToUse() {
return (
master.hasPet() &&
master.getStateManager().getCurrentState() ==
MasterState.TASK_ASSIGN &&
assignTimer < ASSIGN_DURATION
);
}
@Override
public void start() {
this.assignTimer = 0;
this.hasAssigned = false;
this.selectedTask = selectRandomTask();
master.setMasterState(MasterState.TASK_ASSIGN);
master.markEngagement();
TiedUpMod.LOGGER.debug(
"[MasterTaskAssignGoal] {} assigning task: {}",
master.getNpcName(),
selectedTask
);
}
@Override
public void stop() {
lastTaskTime = master.level().getGameTime();
this.assignTimer = 0;
this.hasAssigned = false;
// Transition to task watch if task was assigned
if (master.hasPet() && master.hasActiveTask()) {
master.setMasterState(MasterState.TASK_WATCH);
} else if (master.hasPet()) {
master.setMasterState(MasterState.FOLLOWING);
}
this.selectedTask = null;
TiedUpMod.LOGGER.debug(
"[MasterTaskAssignGoal] {} task assignment complete",
master.getNpcName()
);
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
// Look at pet
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
// Assign task at start
if (!hasAssigned && selectedTask != null) {
assignTask(pet, selectedTask);
hasAssigned = true;
}
assignTimer++;
}
/**
* Select a random task to assign.
*/
private PetTask selectRandomTask() {
return ASSIGNABLE_TASKS[master
.getRandom()
.nextInt(ASSIGNABLE_TASKS.length)];
}
/**
* Assign a task to the pet.
*/
private void assignTask(ServerPlayer pet, PetTask task) {
String message;
Item requestedItem = null;
// Handle task-specific setup
switch (task) {
case HEEL, FOLLOW_CLOSE -> {
message = task.getMessageTemplate();
// Distance tracked to master, no position needed
}
case WAIT_HERE -> {
message = task.getMessageTemplate();
// Store pet's current position as the anchor point
master.setTaskStartPosition(pet.position());
}
case KNEEL -> {
message = task.getMessageTemplate();
// Store pet's current position - must not move
master.setTaskStartPosition(pet.position());
}
case PRESENT -> {
message = task.getMessageTemplate();
// Distance tracked to master
}
case COME -> {
message = task.getMessageTemplate();
// Pet must reach master quickly - no special setup
}
case SPEAK -> {
message = task.getMessageTemplate();
// Pet must right-click on master - no special setup
}
case DROP -> {
message = task.getMessageTemplate();
// Pet must empty hands - no special setup
}
case FETCH_ITEM -> {
// Select random item to fetch
requestedItem = FETCHABLE_ITEMS.get(
master.getRandom().nextInt(FETCHABLE_ITEMS.size())
);
String itemName = requestedItem.getDescription().getString();
message = String.format(task.getMessageTemplate(), itemName);
master.setRequestedItem(requestedItem);
}
case DEMAND -> {
// Scan pet's inventory for the most valuable item
requestedItem = findMostValuableItem(pet);
if (requestedItem == null) {
// Pet has nothing worth taking - fallback to FETCH_ITEM
TiedUpMod.LOGGER.debug(
"[MasterTaskAssignGoal] {} found nothing to demand, falling back to FETCH",
master.getNpcName()
);
task = PetTask.FETCH_ITEM;
requestedItem = FETCHABLE_ITEMS.get(
master.getRandom().nextInt(FETCHABLE_ITEMS.size())
);
String fallbackName = requestedItem
.getDescription()
.getString();
message = String.format(
PetTask.FETCH_ITEM.getMessageTemplate(),
fallbackName
);
master.setRequestedItem(requestedItem);
master.setActiveTask(task);
} else {
String itemName = requestedItem
.getDescription()
.getString();
message = String.format(
task.getMessageTemplate(),
itemName
);
master.setRequestedItem(requestedItem);
}
}
default -> {
message = task.getMessageTemplate();
}
}
// Set the active task on the master
master.setActiveTask(task);
// FIX: Use MessageDispatcher for consistency with earplug system
MessageDispatcher.sendChat(
pet,
Component.literal(
master.getNpcName() + ": \"" + message + "\""
).withStyle(Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR))
);
// Also use dialogue system for more variation
String dialogueId = switch (task) {
case HEEL, FOLLOW_CLOSE -> "petplay.task_heel";
case WAIT_HERE -> "petplay.task_wait";
case FETCH_ITEM -> "petplay.task_fetch";
case KNEEL -> "petplay.task_kneel";
case COME -> "petplay.task_come";
case PRESENT -> "petplay.task_present";
case SPEAK -> "petplay.task_speak";
case DROP -> "petplay.task_drop";
case DEMAND -> "petplay.task_demand";
default -> null;
};
if (dialogueId != null) {
DialogueBridge.talkTo(master, pet, dialogueId);
}
TiedUpMod.LOGGER.debug(
"[MasterTaskAssignGoal] {} assigned {} task to {}{}",
master.getNpcName(),
task,
pet.getName().getString(),
requestedItem != null
? " (item: " + requestedItem.getDescription().getString() + ")"
: ""
);
}
/**
* Force assign a specific task (used by external triggers like random events).
*/
public void forceAssignTask(PetTask task) {
this.selectedTask = task;
this.hasAssigned = false;
master.setMasterState(MasterState.TASK_ASSIGN);
}
// ========================================
// DEMAND TASK - INVENTORY SCANNING
// ========================================
/**
* Scan pet's inventory and find the most valuable item to demand.
* Prioritizes: enchanted items > high-value items > medium-value items.
*
* @param pet The pet player to scan
* @return The most valuable item found, or null if inventory is empty/worthless
*/
@javax.annotation.Nullable
private Item findMostValuableItem(ServerPlayer pet) {
List<ItemStack> highValue = new ArrayList<>();
List<ItemStack> mediumValue = new ArrayList<>();
List<ItemStack> enchantedItems = new ArrayList<>();
for (int i = 0; i < pet.getInventory().getContainerSize(); i++) {
ItemStack stack = pet.getInventory().getItem(i);
if (stack.isEmpty()) continue;
Item item = stack.getItem();
// Enchanted items are always high priority (even enchanted books)
if (
stack.isEnchanted() ||
EnchantmentHelper.getEnchantments(stack).size() > 0
) {
enchantedItems.add(stack);
continue;
}
if (HIGH_VALUE_ITEMS.contains(item)) {
highValue.add(stack);
} else if (MEDIUM_VALUE_ITEMS.contains(item)) {
mediumValue.add(stack);
}
}
// Pick from the highest tier available
if (!enchantedItems.isEmpty()) {
return enchantedItems
.get(master.getRandom().nextInt(enchantedItems.size()))
.getItem();
}
if (!highValue.isEmpty()) {
return highValue
.get(master.getRandom().nextInt(highValue.size()))
.getItem();
}
if (!mediumValue.isEmpty()) {
return mediumValue
.get(master.getRandom().nextInt(mediumValue.size()))
.getItem();
}
return null; // Nothing worth demanding
}
}

View File

@@ -0,0 +1,489 @@
package com.tiedup.remake.entities.ai.master;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.items.ItemChokeCollar;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.util.MessageDispatcher;
import java.util.EnumSet;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
/**
* AI Goal for EntityMaster to watch and enforce active tasks.
*
* Monitors task compliance and triggers choke collar punishment on violation:
* - HEEL: Player must stay within 3 blocks of Master
* - WAIT_HERE: Player must not move more than 1 block from start position
* - FETCH_ITEM: Player must give item within time limit (handled separately via interaction)
*/
public class MasterTaskWatchGoal extends Goal {
private final EntityMaster master;
/** Ticks between compliance checks */
private static final int CHECK_INTERVAL = 10;
/** Choke duration on violation (ticks) - 2 seconds */
private static final int CHOKE_DURATION = 40;
/** Grace period before starting to check (ticks) - 3 seconds */
private static final int GRACE_PERIOD = 60;
/** Warning messages when task is violated */
private static final String[] HEEL_VIOLATION_MESSAGES = {
"You're too far! Heel!",
"Stay close, pet!",
"*tugs at your collar*",
};
private static final String[] WAIT_VIOLATION_MESSAGES = {
"I said don't move!",
"Stay still!",
"*activates your collar*",
};
private static final String[] KNEEL_VIOLATION_MESSAGES = {
"I said kneel!",
"Get back down!",
"On your knees, pet!",
};
private static final String[] COME_VIOLATION_MESSAGES = {
"Too slow!",
"I said come HERE!",
"You're taking too long!",
};
private static final String[] PRESENT_VIOLATION_MESSAGES = {
"Stand still!",
"Don't move away from me!",
"Present yourself properly!",
};
private static final String[] DROP_VIOLATION_MESSAGES = {
"Drop it!",
"Empty your hands. Now.",
"I said drop what you're holding!",
};
private static final String[] FOLLOW_CLOSE_VIOLATION_MESSAGES = {
"Closer!",
"Stay right beside me!",
"*yanks your collar*",
};
private int checkTimer = 0;
private int taskTimer = 0;
private int chokeTimer = 0;
private ItemStack activeChokeCollar = ItemStack.EMPTY;
public MasterTaskWatchGoal(EntityMaster master) {
this.master = master;
this.setFlags(EnumSet.of(Goal.Flag.LOOK));
}
@Override
public boolean canUse() {
// Only active in TASK_WATCH state with an active task
return (
master.getStateManager().getCurrentState() ==
MasterState.TASK_WATCH &&
master.hasPet() &&
master.hasActiveTask()
);
}
@Override
public boolean canContinueToUse() {
if (!master.hasPet()) return false;
ServerPlayer pet = master.getPetPlayer();
if (pet == null || !pet.isAlive()) return false;
// Continue while in TASK_WATCH state
if (
master.getStateManager().getCurrentState() != MasterState.TASK_WATCH
) {
return false;
}
// Check if task duration expired
PetTask task = master.getCurrentTask();
if (
task != null &&
task.getDurationTicks() > 0 &&
taskTimer >= task.getDurationTicks()
) {
return false;
}
return master.hasActiveTask();
}
@Override
public void start() {
this.checkTimer = 0;
this.taskTimer = 0;
this.chokeTimer = 0;
this.activeChokeCollar = ItemStack.EMPTY;
TiedUpMod.LOGGER.debug(
"[MasterTaskWatchGoal] {} started watching task: {}",
master.getNpcName(),
master.getCurrentTask()
);
}
@Override
public void stop() {
// Ensure choke is deactivated
deactivateChoke();
// Check if task completed successfully
PetTask task = master.getCurrentTask();
// Determine success based on task type
boolean success = false;
ServerPlayer pet = master.getPetPlayer();
if (
task != null &&
task.getDurationTicks() > 0 &&
taskTimer >= task.getDurationTicks()
) {
// Duration expired - check end-state success for specific tasks
switch (task) {
case FETCH_ITEM, DEMAND -> {
// Reaching timeout = failure (success handled via handleFetchItemGive/mobInteract)
success = false;
}
case SPEAK -> {
// Reaching timeout = failure (success handled via mobInteract)
success = false;
}
case COME -> {
// Success if pet is within range at end
success =
pet != null &&
master.distanceTo(pet) <= task.getMaxDistance();
}
case DROP -> {
// Success if hands are empty at end
success =
pet != null &&
pet.getMainHandItem().isEmpty() &&
pet.getOffhandItem().isEmpty();
}
default -> {
// Duration-based tasks: completing duration IS success
success = true;
}
}
}
if (success) {
if (pet != null) {
DialogueBridge.talkTo(master, pet, "petplay.task_complete");
TiedUpMod.LOGGER.debug(
"[MasterTaskWatchGoal] {} completed task {} successfully",
pet.getName().getString(),
task
);
}
} else if (
task != null &&
task.getDurationTicks() > 0 &&
taskTimer >= task.getDurationTicks()
) {
// Task timed out without success
if (pet != null) {
DialogueBridge.talkTo(master, pet, "petplay.disappointed");
// Trigger punishment for failure
master.setMasterState(MasterState.PUNISH);
master.clearActiveTask();
this.checkTimer = 0;
this.taskTimer = 0;
this.chokeTimer = 0;
this.activeChokeCollar = ItemStack.EMPTY;
return; // Don't fall through to FOLLOWING
}
}
// Clear task and return to following
master.clearActiveTask();
if (master.hasPet()) {
master.setMasterState(MasterState.FOLLOWING);
}
this.checkTimer = 0;
this.taskTimer = 0;
this.chokeTimer = 0;
this.activeChokeCollar = ItemStack.EMPTY;
TiedUpMod.LOGGER.debug(
"[MasterTaskWatchGoal] {} stopped watching task (success={})",
master.getNpcName(),
success
);
}
@Override
public void tick() {
ServerPlayer pet = master.getPetPlayer();
if (pet == null) return;
PetTask task = master.getCurrentTask();
if (task == null) return;
// Always look at pet
master.getLookControl().setLookAt(pet, 30.0F, 30.0F);
// Increment task timer
taskTimer++;
// Handle choke cooldown
if (chokeTimer > 0) {
chokeTimer--;
if (chokeTimer <= 0) {
deactivateChoke();
}
}
// Show progress every 5 seconds via action bar
if (
task.getDurationTicks() > 0 &&
taskTimer % 100 == 0 &&
taskTimer >= GRACE_PERIOD
) {
int remainingSec = (task.getDurationTicks() - taskTimer) / 20;
if (remainingSec > 0) {
MessageDispatcher.sendActionBar(
pet,
Component.literal(
task.name() + " - " + remainingSec + "s remaining"
).withStyle(Style.EMPTY.withColor(0xFFAA00))
);
}
}
// Skip compliance checks during grace period
if (taskTimer < GRACE_PERIOD) {
return;
}
// Check compliance periodically
checkTimer++;
if (checkTimer >= CHECK_INTERVAL) {
checkTimer = 0;
checkTaskCompliance(pet, task);
}
}
/**
* Check if pet is complying with the current task.
*/
private void checkTaskCompliance(ServerPlayer pet, PetTask task) {
boolean violation = false;
String[] violationMessages = null;
switch (task) {
case HEEL -> {
double dist = master.distanceTo(pet);
if (dist > task.getMaxDistance()) {
violation = true;
violationMessages = HEEL_VIOLATION_MESSAGES;
}
}
case WAIT_HERE -> {
Vec3 startPos = master.getTaskStartPosition();
if (startPos != null) {
double dist = pet.position().distanceTo(startPos);
if (dist > task.getMaxDistance()) {
violation = true;
violationMessages = WAIT_VIOLATION_MESSAGES;
}
}
}
case KNEEL -> {
// Must be crouching AND near start position
if (!pet.isCrouching()) {
violation = true;
violationMessages = KNEEL_VIOLATION_MESSAGES;
} else {
Vec3 startPos = master.getTaskStartPosition();
if (startPos != null) {
double dist = pet.position().distanceTo(startPos);
if (dist > task.getMaxDistance()) {
violation = true;
violationMessages = KNEEL_VIOLATION_MESSAGES;
}
}
}
}
case COME -> {
// Grace period: only check after half the duration has passed
double dist = master.distanceTo(pet);
if (
taskTimer > task.getDurationTicks() / 2 &&
dist > task.getMaxDistance()
) {
violation = true;
violationMessages = COME_VIOLATION_MESSAGES;
}
}
case PRESENT -> {
// Must stay near master
double dist = master.distanceTo(pet);
if (dist > task.getMaxDistance()) {
violation = true;
violationMessages = PRESENT_VIOLATION_MESSAGES;
}
}
case SPEAK -> {
// No continuous check - just late warning at 75% time
if (
taskTimer >= (int) (task.getDurationTicks() * 0.75) &&
taskTimer % 60 == 0
) {
MessageDispatcher.sendChat(
pet,
Component.literal(
master.getNpcName() + ": \"I'm waiting...\""
).withStyle(
Style.EMPTY.withColor(
EntityMaster.MASTER_NAME_COLOR
)
)
);
}
}
case DEMAND -> {
// No continuous check - just late warning at 60% time (more impatient)
if (
taskTimer >= (int) (task.getDurationTicks() * 0.6) &&
taskTimer % 60 == 0
) {
String[] demandWarnings = {
"I said give it to me.",
"Don't make me ask again.",
"Hand it over. Now.",
"*extends hand impatiently*",
};
String warning = demandWarnings[master
.getRandom()
.nextInt(demandWarnings.length)];
MessageDispatcher.sendChat(
pet,
Component.literal(
master.getNpcName() + ": \"" + warning + "\""
).withStyle(
Style.EMPTY.withColor(
EntityMaster.MASTER_NAME_COLOR
)
)
);
}
}
case DROP -> {
if (
!pet.getMainHandItem().isEmpty() ||
!pet.getOffhandItem().isEmpty()
) {
violation = true;
violationMessages = DROP_VIOLATION_MESSAGES;
}
}
case FOLLOW_CLOSE -> {
double dist = master.distanceTo(pet);
if (dist > task.getMaxDistance()) {
violation = true;
violationMessages = FOLLOW_CLOSE_VIOLATION_MESSAGES;
}
}
default -> {
// Other tasks don't have continuous checks
}
}
if (violation && violationMessages != null) {
triggerPunishment(pet, violationMessages);
}
}
/**
* Trigger choke collar punishment for task violation.
*/
private void triggerPunishment(ServerPlayer pet, String[] messages) {
// Only punish if not already choking
if (chokeTimer > 0) return;
// FIX: Use MessageDispatcher for consistency with earplug system
String message = messages[master.getRandom().nextInt(messages.length)];
MessageDispatcher.sendChat(
pet,
Component.literal(
master.getNpcName() + ": \"" + message + "\""
).withStyle(Style.EMPTY.withColor(EntityMaster.MASTER_NAME_COLOR))
);
// Activate choke collar
PlayerBindState bindState = PlayerBindState.getInstance(pet);
if (bindState != null && bindState.hasCollar()) {
ItemStack collar = bindState.getEquipment(BodyRegionV2.NECK);
if (collar.getItem() instanceof ItemChokeCollar chokeCollar) {
chokeCollar.setChoking(collar, true);
this.activeChokeCollar = collar;
this.chokeTimer = CHOKE_DURATION;
// Play choking sound
pet
.level()
.playSound(
null,
pet.getX(),
pet.getY(),
pet.getZ(),
SoundEvents.PLAYER_HURT,
SoundSource.HOSTILE,
0.8f,
0.5f + master.getRandom().nextFloat() * 0.2f
);
TiedUpMod.LOGGER.debug(
"[MasterTaskWatchGoal] {} triggered choke punishment on {}",
master.getNpcName(),
pet.getName().getString()
);
}
}
}
/**
* Deactivate the choke collar if active.
*/
private void deactivateChoke() {
if (
!activeChokeCollar.isEmpty() &&
activeChokeCollar.getItem() instanceof ItemChokeCollar chokeCollar
) {
chokeCollar.setChoking(activeChokeCollar, false);
TiedUpMod.LOGGER.debug(
"[MasterTaskWatchGoal] {} deactivated choke",
master.getNpcName()
);
}
this.activeChokeCollar = ItemStack.EMPTY;
this.chokeTimer = 0;
}
}

View File

@@ -0,0 +1,173 @@
package com.tiedup.remake.entities.ai.master;
/**
* Enum defining the types of tasks a Master can assign to their pet.
*/
public enum PetTask {
/**
* Heel - Pet must stay within 3 blocks of Master.
* Punishment: Choke if distance > 3 blocks
*/
HEEL("Heel, pet. Stay close to me. [Stay within 3 blocks]", 1200, 3.0),
/**
* Wait Here - Pet cannot move from current position.
* Punishment: Choke if moves more than 2 blocks
*/
WAIT_HERE("Stay here. Don't move. [Stay within 2 blocks]", 800, 2.0),
/**
* Fetch Item - Pet must give Master a specific item.
* Punishment: Choke if item not given within time limit
*/
FETCH_ITEM("Bring me %s. [Right-click on me with the item]", 2400, 0.0),
/**
* Kneel - Pet must crouch and not move.
* Punishment if pet stands up or moves.
*/
KNEEL("Kneel. [Hold shift, don't move]", 600, 1.5),
/**
* Come - Pet must reach Master quickly.
* Speed test - must be within range before time runs out.
*/
COME("Come here! Now! [Reach me within 10 seconds]", 200, 2.0),
/**
* Present - Pet must stand near Master without moving.
* Punishment if pet moves away.
*/
PRESENT(
"Present yourself. Stand before me. [Stand still near me]",
400,
2.0
),
/**
* Speak - Pet must right-click on Master.
* Interaction-based task.
*/
SPEAK("Speak, pet. [Right-click on me]", 300, 0.0),
/**
* Drop - Pet must empty both hands.
* Punishment if hands are not empty.
*/
DROP("Drop what you're holding. [Empty both hands]", 400, 0.0),
/**
* Follow Close - Like HEEL but tighter distance, longer duration.
* Punishment if pet strays more than 1.5 blocks.
*/
FOLLOW_CLOSE(
"Stay right beside me. Don't stray. [Stay within 1.5 blocks]",
1600,
1.5
),
/**
* Demand - Master demands a valuable item from pet's inventory.
* Scans inventory and picks the most precious item the pet has.
* Interaction-based completion (same as FETCH_ITEM).
*/
DEMAND("Give me that %s. Now. [Right-click on me with the item]", 600, 0.0),
/**
* Random Bind - Master puts a random bind/accessory on pet temporarily.
* This is not really a "task" but an event.
*/
RANDOM_BIND("Hold still...", 0, 0.0),
/**
* Dogwalk - Master initiates a walk with the pet.
* Not a task per se, but a special event.
*/
DOGWALK("Time for a walk.", 0, 0.0);
private final String messageTemplate;
private final int durationTicks;
private final double maxDistance;
PetTask(String messageTemplate, int durationTicks, double maxDistance) {
this.messageTemplate = messageTemplate;
this.durationTicks = durationTicks;
this.maxDistance = maxDistance;
}
/**
* Get the message template for this task.
* May contain %s for item names.
*/
public String getMessageTemplate() {
return messageTemplate;
}
/**
* Get the duration of this task in ticks.
*/
public int getDurationTicks() {
return durationTicks;
}
/**
* Get the maximum allowed distance for distance-based tasks.
* For HEEL: max distance from Master.
* For WAIT_HERE: max distance from starting position.
*/
public double getMaxDistance() {
return maxDistance;
}
/**
* Check if this task requires distance monitoring.
*/
public boolean requiresDistanceCheck() {
return (
this == HEEL ||
this == WAIT_HERE ||
this == FOLLOW_CLOSE ||
this == KNEEL ||
this == PRESENT
);
}
/**
* Check if this is a real task (vs an event like RANDOM_BIND or DOGWALK).
*/
public boolean isRealTask() {
return (
this == HEEL ||
this == WAIT_HERE ||
this == FETCH_ITEM ||
this == KNEEL ||
this == COME ||
this == PRESENT ||
this == SPEAK ||
this == DROP ||
this == FOLLOW_CLOSE ||
this == DEMAND
);
}
/**
* Check if this task requires the pet to be crouching.
*/
public boolean requiresCrouching() {
return this == KNEEL;
}
/**
* Check if this task requires an interaction to complete.
*/
public boolean requiresInteraction() {
return this == SPEAK || this == DEMAND;
}
/**
* Check if this task requires empty hands.
*/
public boolean requiresEmptyHands() {
return this == DROP;
}
}

View File

@@ -0,0 +1,49 @@
package com.tiedup.remake.entities.ai.master;
/**
* Enum defining the types of punishments a Master can inflict on their pet.
*/
public enum PunishmentType {
/** Activate choke collar - requires pet to have a choke collar */
CHOKE_COLLAR(80, "punishment.choke"),
/** Apply temporary blindfold - requires blindfold slot empty */
BLINDFOLD(2400, "punishment.blindfold"),
/** Apply temporary gag - requires gag slot empty */
GAG(2400, "punishment.gag"),
/** Apply temporary mittens - requires mittens slot empty */
MITTENS(2400, "punishment.mittens"),
/** Apply armbinder - requires pet to not be bound */
TIGHTEN_RESTRAINTS(0, "punishment.tighten"),
/** Ignore pet interactions for a duration */
COLD_SHOULDER(1200, "punishment.cold_shoulder"),
/** Yank pet toward master with brief leash */
LEASH_TUG(40, "punishment.leash_tug");
private final int durationTicks;
private final String dialogueId;
PunishmentType(int durationTicks, String dialogueId) {
this.durationTicks = durationTicks;
this.dialogueId = dialogueId;
}
/**
* Get the duration of this punishment in ticks.
*/
public int getDurationTicks() {
return durationTicks;
}
/**
* Get the dialogue ID for this punishment type.
*/
public String getDialogueId() {
return dialogueId;
}
}