areaType,
+ AbstractWorkerEntity worker) {
+ for (T area : WorkAreaIndex.instance().queryInChunks(level, claim.getClaimedChunks(), areaType)) {
+ if (area != null && area.canWorkHere(worker)) {
+ return area;
}
}
- return ItemStack.EMPTY;
- }
-
- private static ItemStack resolveSeedFromCropState(BlockState cropState) {
- if (cropState == null || cropState.isAir()) {
- return ItemStack.EMPTY;
- }
-
- Item item = cropState.getBlock().asItem();
- if (FarmerPlantingPreparation.isSupportedSeedItem(item)) {
- return new ItemStack(item);
- }
- return ItemStack.EMPTY;
+ return null;
}
@Nullable
diff --git a/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java
index 8e72e0bc..935e64b6 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java
@@ -1,6 +1,8 @@
package com.talhanation.bannermod.settlement.dispatch;
import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
import com.talhanation.bannermod.settlement.SettlementMarketState;
import com.talhanation.bannermod.settlement.SettlementResidentServiceContract;
import com.talhanation.bannermod.settlement.SettlementSellerDispatchRecord;
@@ -26,17 +28,17 @@
*
* - Resident must hold a market-profile service contract
* (actor state == LOCAL_BUILDING_SERVICE).
- * - A {@link SettlementSellerDispatchRecord} with
- * {@link SettlementSellerDispatchState#READY} and a matching
+ *
- A {@link BannerModSettlementSellerDispatchRecord} with
+ * {@link BannerModSettlementSellerDispatchState#READY} and a matching
* resident UUID must exist in the supplied market state.
* - The runtime must not already have the seller in a non-idle phase.
*
*
* FIXME(marketStateSupplier): the spec reads
- * {@code Supplier} but the shipped
+ * {@code Supplier} but the shipped
* name is an enum (READY / MARKET_CLOSED); the actual bag of seed records
- * lives on {@link SettlementMarketState}, so we take a
- * {@code Supplier} here. A later slice can
+ * lives on {@link BannerModSettlementMarketState}, so we take a
+ * {@code Supplier} here. A later slice can
* replace this with a dedicated facade if naming ambiguity bites.
*/
public final class SellerResidentGoal implements ResidentGoal {
@@ -93,7 +95,14 @@ public int computePriority(ResidentGoalContext ctx) {
if (ctx == null || !ctx.isActivePhase()) {
return 0;
}
- return this.findReadyMarketUuid(ctx) != null ? SELLER_PRIORITY : 0;
+ if (this.findReadyMarketUuid(ctx) == null) {
+ return 0;
+ }
+ int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK) + 8;
+ if (score <= 8) {
+ return 0;
+ }
+ return Math.max(SELLER_PRIORITY, score);
}
@Override
@@ -108,7 +117,8 @@ public boolean canStart(ResidentGoalContext ctx) {
if (this.runtime.isActive(residentUuid)) {
return false;
}
- return this.findReadyMarketUuid(ctx) != null;
+ SettlementResidentServiceContract contract = ctx.resident().serviceContract();
+ return contract != null && contract.actorState() == SettlementServiceActorState.LOCAL_BUILDING_SERVICE;
}
@Override
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java b/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java
index 92fb3e7f..53bb9b93 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java
@@ -1,13 +1,19 @@
package com.talhanation.bannermod.settlement.goal;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseOneRuntime;
+import com.talhanation.bannermod.society.NpcSocietyIntentRules;
import com.talhanation.bannermod.settlement.SettlementMarketState;
import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime;
import com.talhanation.bannermod.settlement.dispatch.SellerResidentGoal;
import com.talhanation.bannermod.settlement.goal.impl.DeliverResidentGoal;
+import com.talhanation.bannermod.settlement.goal.impl.DefendResidentGoal;
+import com.talhanation.bannermod.settlement.goal.impl.EatResidentGoal;
import com.talhanation.bannermod.settlement.goal.impl.FetchResidentGoal;
+import com.talhanation.bannermod.settlement.goal.impl.HideResidentGoal;
import com.talhanation.bannermod.settlement.goal.impl.IdleResidentGoal;
import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal;
-import com.talhanation.bannermod.settlement.goal.impl.SocialiseResidentGoal;
+import com.talhanation.bannermod.settlement.goal.impl.SeekSuppliesResidentGoal;
import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal;
import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime;
import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal;
@@ -28,43 +34,55 @@
* persisted schedule policy + window seed, picks the highest-priority startable
* {@link ResidentGoal}, and tracks its {@link ResidentTask} + cooldowns in
* memory. Strictly additive: this class does not itself mutate any resident or
- * worker entity — it only publishes "what this resident should do right now."
- * Down-stream slices (D/E/F) translate published tasks into real NPC behavior.
+ * worker entity; it only publishes "what this resident should do right now."
+ * Downstream runtime code translates published tasks into real NPC behavior.
*
* Dormant by default. Registration via
* {@link #withDefaultGoals()} gives the stock six-goal set; tests supply their
* own {@code List} for determinism.
*/
public final class BannerModResidentGoalScheduler {
+ private static final int FAILURE_BASE_COOLDOWN_TICKS = 80;
+ private static final int CONTEXT_INVALID_EXTRA_BACKOFF_TICKS = 40;
+ private static final int GOAL_REEVALUATION_HEARTBEAT_TICKS = 20;
private final List goals;
+ private final Map goalsById;
private final Map activeTasks = new HashMap<>();
+ private final Map lastFinishedTasks = new HashMap<>();
private final Map> cooldownExpiries = new HashMap<>();
+ private final Map recentOutcomes = new HashMap<>();
public BannerModResidentGoalScheduler(List goals) {
if (goals == null) {
throw new IllegalArgumentException("goals must not be null");
}
this.goals = List.copyOf(goals);
+ Map goalsById = new HashMap<>();
+ for (ResidentGoal goal : this.goals) {
+ if (goal != null && goal.id() != null) {
+ goalsById.put(goal.id(), goal);
+ }
+ }
+ this.goalsById = Map.copyOf(goalsById);
}
/** Default scheduler wired with the six stock stub goals. */
public static BannerModResidentGoalScheduler withDefaultGoals() {
return new BannerModResidentGoalScheduler(List.of(
+ new DefendResidentGoal(),
+ new HideResidentGoal(),
new IdleResidentGoal(),
new RestResidentGoal(),
+ new EatResidentGoal(),
new WorkResidentGoal(),
- new SocialiseResidentGoal(),
+ new SeekSuppliesResidentGoal(),
new DeliverResidentGoal(),
new FetchResidentGoal()
));
}
- /**
- * Default scheduler extended with the current Phase 25 household and seller
- * runtime seams. This keeps those slices additive and composable without
- * forcing live settlement orchestration to land in the same change.
- */
+ /** Default scheduler extended with household and seller runtime seams. */
public static BannerModResidentGoalScheduler withDefaultGoals(
BannerModHomeAssignmentRuntime homeAssignmentRuntime,
Supplier marketStateSupplier,
@@ -80,12 +98,15 @@ public static BannerModResidentGoalScheduler withDefaultGoals(
throw new IllegalArgumentException("sellerDispatchRuntime must not be null");
}
return new BannerModResidentGoalScheduler(List.of(
+ new DefendResidentGoal(),
+ new HideResidentGoal(),
new GoHomeResidentGoal(homeAssignmentRuntime),
new RestResidentGoal(),
+ new EatResidentGoal(),
new LeaveHomeResidentGoal(homeAssignmentRuntime),
new SellerResidentGoal(marketStateSupplier, sellerDispatchRuntime),
new WorkResidentGoal(),
- new SocialiseResidentGoal(),
+ new SeekSuppliesResidentGoal(),
new DeliverResidentGoal(),
new FetchResidentGoal(),
new IdleResidentGoal()
@@ -104,7 +125,22 @@ public void tick(ResidentGoalContext ctx) {
UUID residentId = ctx.residentId();
ResidentTask active = this.activeTasks.get(residentId);
if (active != null && !active.isDone()) {
- active.advance();
+ GoalSelection alternative = this.shouldEvaluateAlternativeGoal(ctx, active)
+ ? this.selectBestGoal(ctx, active.goalId())
+ : null;
+ if (this.shouldPreemptActiveTask(ctx, active, alternative)) {
+ active.syncElapsed(ctx.gameTime());
+ if (active.finishTimedOutIfExpired()) {
+ this.onTaskFinished(residentId, active);
+ this.startSelection(ctx, alternative);
+ return;
+ }
+ active.finish(ResidentStopReason.PRE_EMPTED);
+ this.onTaskFinished(residentId, active);
+ this.startSelection(ctx, alternative);
+ return;
+ }
+ active.advance(ctx.gameTime());
if (active.isDone()) {
this.onTaskFinished(residentId, active);
}
@@ -116,9 +152,14 @@ public void tick(ResidentGoalContext ctx) {
this.startNextGoal(ctx);
}
- /** Current task for a resident, or empty if nothing scheduled. */
+ /** Current active task, or the most recent finished task if nothing is active. */
public Optional currentTask(UUID residentId) {
- return Optional.ofNullable(this.activeTasks.get(residentId));
+ ResidentTask active = this.activeTasks.get(residentId);
+ return Optional.ofNullable(active != null ? active : this.lastFinishedTasks.get(residentId));
+ }
+
+ public Optional lastOutcome(UUID residentId) {
+ return Optional.ofNullable(this.recentOutcomes.get(residentId));
}
/**
@@ -140,7 +181,9 @@ public void forceStop(UUID residentId, ResidentStopReason reason) {
/** Drop all in-memory state. Intended for test isolation and reloads. */
public void reset() {
this.activeTasks.clear();
+ this.lastFinishedTasks.clear();
this.cooldownExpiries.clear();
+ this.recentOutcomes.clear();
}
/** Read-only view of the registered goals, in registration order. */
@@ -153,9 +196,29 @@ public List goals() {
// ------------------------------------------------------------------
private void startNextGoal(ResidentGoalContext ctx) {
+ this.startSelection(ctx, this.selectBestGoal(ctx, null));
+ }
+
+ private void startSelection(ResidentGoalContext ctx, @Nullable GoalSelection selection) {
+ if (selection == null) {
+ this.activeTasks.remove(ctx.residentId());
+ return;
+ }
+ ResidentTask task = selection.goal.start(ctx);
+ if (task == null) {
+ this.activeTasks.remove(ctx.residentId());
+ return;
+ }
+ this.activeTasks.put(ctx.residentId(), task);
+ }
+
+ private @Nullable GoalSelection selectBestGoal(ResidentGoalContext ctx, @Nullable ResourceLocation excludedGoalId) {
ResidentGoal best = null;
int bestPriority = 0;
for (ResidentGoal goal : this.goals) {
+ if (excludedGoalId != null && excludedGoalId.equals(goal.id())) {
+ continue;
+ }
if (this.isOnCooldown(ctx.residentId(), goal.id(), ctx.gameTime())) {
continue;
}
@@ -166,45 +229,103 @@ private void startNextGoal(ResidentGoalContext ctx) {
if (priority <= 0) {
continue;
}
- if (priority > bestPriority
- || (priority == bestPriority && best != null && idOrderBefore(goal.id(), best.id()))) {
+ if (best == null
+ || priority > bestPriority
+ || (priority == bestPriority && idOrderBefore(goal.id(), best.id()))) {
best = goal;
bestPriority = priority;
}
}
- if (best == null) {
- this.activeTasks.remove(ctx.residentId());
- return;
+ return best == null ? null : new GoalSelection(best);
+ }
+
+ private boolean shouldPreemptActiveTask(ResidentGoalContext ctx,
+ ResidentTask activeTask,
+ @Nullable GoalSelection alternative) {
+ if (ctx == null || activeTask == null || activeTask.goalId() == null || alternative == null) {
+ return false;
}
- ResidentTask task = best.start(ctx);
- if (task == null) {
- this.activeTasks.remove(ctx.residentId());
- return;
+ ResidentGoal activeGoal = this.findGoal(activeTask.goalId());
+ if (activeGoal == null) {
+ return true;
}
- this.activeTasks.put(ctx.residentId(), task);
+ int currentPriority = activeGoal.computePriority(ctx);
+ if (currentPriority <= 0 || !activeGoal.canStart(ctx)) {
+ return true;
+ }
+ ResourceLocation currentGoalId = activeTask.goalId();
+ ResourceLocation nextGoalId = alternative.goal.id();
+ if (currentGoalId.equals(nextGoalId)) {
+ return false;
+ }
+ if (IdleResidentGoal.ID.equals(currentGoalId)) {
+ return true;
+ }
+ if (GoHomeResidentGoal.ID.equals(currentGoalId)
+ && RestResidentGoal.ID.equals(nextGoalId)
+ && ctx.isReadyToSettleAtHome()) {
+ return true;
+ }
+ if (isDangerOverride(nextGoalId)) {
+ return !isDangerOverride(currentGoalId);
+ }
+ return isNightHomeOverride(nextGoalId)
+ && !isNightHomeOverride(currentGoalId)
+ && (ctx.isRestPhase() || ctx.fatigueNeed() >= 85 || ctx.safetyNeed() >= 70);
+ }
+
+ private boolean shouldEvaluateAlternativeGoal(ResidentGoalContext ctx, ResidentTask activeTask) {
+ if (ctx == null || activeTask == null || activeTask.goalId() == null) {
+ return false;
+ }
+ if (IdleResidentGoal.ID.equals(activeTask.goalId())) {
+ return true;
+ }
+ if (ctx.safetyNeed() >= 35) {
+ return true;
+ }
+ if (ctx.isRestPhase() && !isNightHomeOverride(activeTask.goalId())) {
+ return true;
+ }
+ long activeAge = Math.max(0L, ctx.gameTime() - activeTask.startGameTime());
+ return activeAge <= 0L || activeAge % GOAL_REEVALUATION_HEARTBEAT_TICKS == 0L;
}
private void onTaskFinished(UUID residentId, ResidentTask task) {
+ if (residentId == null || task == null || task.stopReason() == null) {
+ return;
+ }
ResidentGoal goal = this.findGoal(task.goalId());
+ long finishedAt = task.startGameTime() + Math.max(0, task.elapsedTicks());
long expiresAt = 0L;
if (goal != null && goal.cooldownTicks() > 0 && task.stopReason() == ResidentStopReason.COMPLETED) {
- expiresAt = task.startGameTime() + task.elapsedTicks() + goal.cooldownTicks();
+ expiresAt = finishedAt + goal.cooldownTicks();
+ }
+ if (isFailure(task.stopReason())) {
+ expiresAt = Math.max(expiresAt, finishedAt + failureBackoffTicks(task.stopReason()));
}
if (expiresAt > 0L) {
this.cooldownExpiries
.computeIfAbsent(residentId, k -> new HashMap<>())
.put(task.goalId(), expiresAt);
}
+ this.activeTasks.remove(residentId);
+ this.lastFinishedTasks.put(residentId, task);
+ this.recentOutcomes.put(residentId, new ResidentTaskOutcome(task.goalId(), task.stopReason(), finishedAt));
+ }
+
+ private static boolean isFailure(@Nullable ResidentStopReason reason) {
+ return reason == ResidentStopReason.TIMED_OUT || reason == ResidentStopReason.CONTEXT_INVALID;
+ }
+
+ private static int failureBackoffTicks(@Nullable ResidentStopReason reason) {
+ return FAILURE_BASE_COOLDOWN_TICKS
+ + (reason == ResidentStopReason.CONTEXT_INVALID ? CONTEXT_INVALID_EXTRA_BACKOFF_TICKS : 0);
}
@Nullable
private ResidentGoal findGoal(ResourceLocation id) {
- for (ResidentGoal goal : this.goals) {
- if (goal.id().equals(id)) {
- return goal;
- }
- }
- return null;
+ return id == null ? null : this.goalsById.get(id);
}
private boolean isOnCooldown(UUID residentId, ResourceLocation goalId, long gameTime) {
@@ -227,6 +348,17 @@ private static boolean idOrderBefore(ResourceLocation a, ResourceLocation b) {
return a.toString().compareTo(b.toString()) < 0;
}
+ private static boolean isDangerOverride(ResourceLocation goalId) {
+ return HideResidentGoal.ID.equals(goalId) || DefendResidentGoal.ID.equals(goalId);
+ }
+
+ private static boolean isNightHomeOverride(ResourceLocation goalId) {
+ return GoHomeResidentGoal.ID.equals(goalId) || RestResidentGoal.ID.equals(goalId);
+ }
+
+ private record GoalSelection(ResidentGoal goal) {
+ }
+
// ------------------------------------------------------------------
// Test hooks (package-private; production code must not rely on these)
// ------------------------------------------------------------------
@@ -238,4 +370,8 @@ Map activeTasksForTests() {
Map> cooldownsForTests() {
return Collections.unmodifiableMap(this.cooldownExpiries);
}
+
+ Map finishedTasksForTests() {
+ return Collections.unmodifiableMap(this.lastFinishedTasks);
+ }
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java
index 9c48e78b..f00fb0a2 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java
@@ -1,9 +1,20 @@
package com.talhanation.bannermod.settlement.goal;
+import com.talhanation.bannermod.settlement.SettlementBuildingRecord;
+import com.talhanation.bannermod.society.NpcHouseholdHousingState;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyDecisionSnapshot;
+import com.talhanation.bannermod.society.NpcSocietyPhaseOneRuntime;
+import com.talhanation.bannermod.society.NpcSocietyProfile;
+import com.talhanation.bannermod.society.NpcLifeStage;
import com.talhanation.bannermod.settlement.SettlementResidentRecord;
+import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState;
import com.talhanation.bannermod.settlement.SettlementResidentSchedulePolicy;
import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed;
+import com.talhanation.bannermod.settlement.SettlementResidentRole;
import com.talhanation.bannermod.settlement.SettlementSnapshot;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.phys.Vec3;
import javax.annotation.Nullable;
import java.util.UUID;
@@ -11,9 +22,61 @@
public record ResidentGoalContext(
SettlementResidentRecord resident,
@Nullable SettlementSnapshot settlement,
- long gameTime
+ long gameTime,
+ long worldDayTime,
+ @Nullable NpcSocietyProfile societyProfile,
+ @Nullable Vec3 currentPosition
) {
+ private static final double HOME_SETTLE_DISTANCE_SQR = 36.0D;
+ private static final double LEAVE_HOME_FAN_OUT_DISTANCE_SQR = 16.0D;
+
+ public ResidentGoalContext(SettlementResidentRecord resident,
+ @Nullable SettlementSnapshot settlement,
+ long gameTime) {
+ this(resident, settlement, gameTime, gameTime, null, null);
+ }
+
+ public ResidentGoalContext(SettlementResidentRecord resident,
+ @Nullable SettlementSnapshot settlement,
+ long gameTime,
+ @Nullable NpcSocietyProfile societyProfile) {
+ this(resident, settlement, gameTime, gameTime, societyProfile, null);
+ }
+
+ public ResidentGoalContext(SettlementResidentRecord resident,
+ @Nullable SettlementSnapshot settlement,
+ long gameTime,
+ long worldDayTime,
+ @Nullable NpcSocietyProfile societyProfile) {
+ this(resident, settlement, gameTime, worldDayTime, societyProfile, null);
+ }
+
+ public ResidentGoalContext(SettlementResidentRecord resident,
+ @Nullable SettlementSnapshot settlement,
+ long gameTime,
+ long worldDayTime,
+ @Nullable NpcSocietyProfile societyProfile,
+ int householdSize,
+ NpcHouseholdHousingState householdHousingState,
+ boolean hasSpouse,
+ int childCount) {
+ this(resident, settlement, gameTime, worldDayTime, societyProfile, null);
+ }
+
+ public ResidentGoalContext(SettlementResidentRecord resident,
+ @Nullable SettlementSnapshot settlement,
+ long gameTime,
+ long worldDayTime,
+ @Nullable NpcSocietyProfile societyProfile,
+ int householdSize,
+ NpcHouseholdHousingState householdHousingState,
+ boolean hasSpouse,
+ int childCount,
+ @Nullable Vec3 currentPosition) {
+ this(resident, settlement, gameTime, worldDayTime, societyProfile, currentPosition);
+ }
+
public UUID residentId() {
return this.resident.residentUuid();
}
@@ -28,7 +91,7 @@ public SettlementResidentScheduleWindowSeed window() {
/** Minecraft day-of-time (0..23999) derived from absolute game time. */
public int dayTime() {
- long time = this.gameTime % 24000L;
+ long time = this.worldDayTime % 24000L;
if (time < 0L) {
time += 24000L;
}
@@ -48,4 +111,196 @@ public boolean isRestPhase() {
SettlementResidentScheduleWindowSeed w = this.window();
return t >= w.restStartTick() || t < w.activeStartTick();
}
+
+ /** True during the gap between labor/civic work and the rest window. */
+ public boolean isLeisurePhase() {
+ int t = this.dayTime();
+ SettlementResidentScheduleWindowSeed w = this.window();
+ return t >= w.activeEndTick() && t < w.restStartTick();
+ }
+
+ public boolean isDayRoutinePhase() {
+ return this.isActivePhase() || this.isLeisurePhase();
+ }
+
+ public int ticksSinceActiveStart() {
+ int t = this.dayTime();
+ int activeStart = this.window().activeStartTick();
+ return t < activeStart ? -1 : t - activeStart;
+ }
+
+ public int ticksUntilRestStart() {
+ if (this.isRestPhase()) {
+ return 0;
+ }
+ int t = this.dayTime();
+ int restStart = this.window().restStartTick();
+ return t >= restStart ? -1 : restStart - t;
+ }
+
+ public boolean isEarlyActiveWindow(int windowTicks) {
+ int sinceStart = this.ticksSinceActiveStart();
+ return this.isActivePhase() && windowTicks > 0 && sinceStart >= 0 && sinceStart < windowTicks;
+ }
+
+ public boolean isLateDayWindow(int windowTicks) {
+ int untilRest = this.ticksUntilRestStart();
+ return windowTicks > 0 && untilRest > 0 && untilRest <= windowTicks;
+ }
+
+ public NpcIntent currentPublishedIntent() {
+ return this.societyProfile == null || this.societyProfile.currentIntent() == null
+ ? NpcIntent.UNSPECIFIED
+ : this.societyProfile.currentIntent();
+ }
+
+ public NpcIntent lastPublishedIntent() {
+ if (this.societyProfile == null || this.societyProfile.decisionSnapshot() == null) {
+ return NpcIntent.UNSPECIFIED;
+ }
+ return NpcIntent.fromName(this.societyProfile.decisionSnapshot().lastIntentTag());
+ }
+
+ public long currentIntentAgeTicks() {
+ if (this.societyProfile == null || this.societyProfile.decisionSnapshot() == null) {
+ return 0L;
+ }
+ long started = this.societyProfile.decisionSnapshot().currentIntentStartedGameTime();
+ if (started <= 0L) {
+ return 0L;
+ }
+ return Math.max(0L, this.gameTime - started);
+ }
+
+ public @Nullable String previousBlockedGoalId() {
+ NpcSocietyDecisionSnapshot snapshot = this.societyProfile == null ? null : this.societyProfile.decisionSnapshot();
+ if (snapshot == null || snapshot.blockedGoalId() == null || snapshot.blockedGoalId().isBlank()) {
+ return null;
+ }
+ return snapshot.blockedGoalId();
+ }
+
+ public String previousBlockedReasonTag() {
+ NpcSocietyDecisionSnapshot snapshot = this.societyProfile == null ? null : this.societyProfile.decisionSnapshot();
+ return snapshot == null ? "NONE" : snapshot.blockedReasonTag();
+ }
+
+ public NpcIntent previousBlockedIntent() {
+ String goalId = this.previousBlockedGoalId();
+ if (goalId == null) {
+ return NpcIntent.UNSPECIFIED;
+ }
+ return NpcSocietyPhaseOneRuntime.intentForGoal(ResourceLocation.tryParse(goalId));
+ }
+
+ public boolean isReadyToSettleAtHome() {
+ if (!this.isRestPhase()
+ || this.currentPublishedIntent() != NpcIntent.GO_HOME
+ || this.currentIntentAgeTicks() < 80L) {
+ return false;
+ }
+ Vec3 homeCenter = this.homeBuildingCenter();
+ if (this.currentPosition == null || homeCenter == null) {
+ return true;
+ }
+ return this.currentPosition.distanceToSqr(homeCenter) <= HOME_SETTLE_DISTANCE_SQR;
+ }
+
+ public boolean isReadyToFanOutFromLeaveHome() {
+ if (!this.isActivePhase()
+ || this.currentPublishedIntent() != NpcIntent.LEAVE_HOME
+ || this.currentIntentAgeTicks() < 50L) {
+ return false;
+ }
+ Vec3 homeCenter = this.homeBuildingCenter();
+ if (this.currentPosition == null || homeCenter == null) {
+ return true;
+ }
+ return this.currentPosition.distanceToSqr(homeCenter) >= LEAVE_HOME_FAN_OUT_DISTANCE_SQR;
+ }
+
+ public boolean recentlyCameFromHome() {
+ return this.currentPublishedIntent() == NpcIntent.LEAVE_HOME
+ || this.lastPublishedIntent() == NpcIntent.LEAVE_HOME;
+ }
+
+ public boolean hasHome() {
+ return this.societyProfile != null && this.societyProfile.homeBuildingUuid() != null;
+ }
+
+ private @Nullable Vec3 homeBuildingCenter() {
+ if (this.settlement == null || this.societyProfile == null || this.societyProfile.homeBuildingUuid() == null) {
+ return null;
+ }
+ for (SettlementBuildingRecord building : this.settlement.buildings()) {
+ if (building != null && this.societyProfile.homeBuildingUuid().equals(building.buildingUuid()) && building.originPos() != null) {
+ return Vec3.atCenterOf(building.originPos());
+ }
+ }
+ return null;
+ }
+
+ public boolean hasWorkAssignment() {
+ SettlementResidentAssignmentState state = this.resident.assignmentState();
+ return this.resident.effectiveWorkBuildingUuid() != null
+ && (state == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING
+ || state == SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING);
+ }
+
+ public int hungerNeed() {
+ return this.societyProfile == null ? 0 : this.societyProfile.hungerNeed();
+ }
+
+ public int fatigueNeed() {
+ return this.societyProfile == null ? 0 : this.societyProfile.fatigueNeed();
+ }
+
+ public int safetyNeed() {
+ return this.societyProfile == null ? 0 : this.societyProfile.safetyNeed();
+ }
+
+ public boolean canDefend() {
+ return this.resident.role() == SettlementResidentRole.GOVERNOR_RECRUIT;
+ }
+
+ public boolean isAdolescent() {
+ return this.societyProfile != null && this.societyProfile.lifeStage() == NpcLifeStage.ADOLESCENT;
+ }
+
+ public boolean hasMarketFoodAccess() {
+ return this.settlement != null && this.settlement.marketState().openMarketCount() > 0;
+ }
+
+ public boolean hasSupplyAccess() {
+ if (this.hasMarketFoodAccess()) {
+ return true;
+ }
+ if (this.settlement == null) {
+ return false;
+ }
+ if (this.settlement.stockpileSummary().storageBuildingCount() > 0) {
+ return true;
+ }
+ for (SettlementBuildingRecord building : this.settlement.buildings()) {
+ if (building != null && building.stockpileBuilding()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean hasOnlyStockpileFoodAccess() {
+ return this.hasSupplyAccess() && !this.hasMarketFoodAccess();
+ }
+
+ public boolean shouldEscalateMealRecoveryToSupplies() {
+ if (!this.hasSupplyAccess()) {
+ return false;
+ }
+ if (this.hungerNeed() < 70) {
+ return false;
+ }
+ return !this.hasHome() && !this.hasMarketFoodAccess();
+ }
+
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java
index ba4e82c1..7714955e 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java
@@ -13,6 +13,7 @@ public final class ResidentTask {
private final ResourceLocation goalId;
private final long startGameTime;
private final int maxTicks;
+ private long lastAdvancedGameTime;
private int elapsedTicks;
private boolean done;
@Nullable
@@ -28,6 +29,7 @@ public ResidentTask(ResourceLocation goalId, long startGameTime, int maxTicks) {
this.goalId = goalId;
this.startGameTime = startGameTime;
this.maxTicks = maxTicks;
+ this.lastAdvancedGameTime = startGameTime;
this.elapsedTicks = 0;
this.done = false;
this.stopReason = null;
@@ -58,16 +60,32 @@ public ResidentStopReason stopReason() {
return this.stopReason;
}
- /** Advance one tick. If max reached, finish with TIMED_OUT. */
- void advance() {
+ void syncElapsed(long gameTime) {
if (this.done) {
return;
}
- this.elapsedTicks++;
- if (this.maxTicks > 0 && this.elapsedTicks >= this.maxTicks) {
- this.done = true;
- this.stopReason = ResidentStopReason.TIMED_OUT;
+ long normalizedGameTime = Math.max(this.lastAdvancedGameTime, gameTime);
+ long delta = Math.max(0L, normalizedGameTime - this.lastAdvancedGameTime);
+ this.lastAdvancedGameTime = normalizedGameTime;
+ this.elapsedTicks = (int) Math.min(Integer.MAX_VALUE, (long) this.elapsedTicks + delta);
+ }
+
+ boolean finishTimedOutIfExpired() {
+ if (this.done || this.maxTicks <= 0 || this.elapsedTicks < this.maxTicks) {
+ return false;
+ }
+ this.done = true;
+ this.stopReason = ResidentStopReason.TIMED_OUT;
+ return true;
+ }
+
+ /** Advance by elapsed game time since the last scheduler heartbeat. */
+ void advance(long gameTime) {
+ this.syncElapsed(gameTime);
+ if (this.done) {
+ return;
}
+ this.finishTimedOutIfExpired();
}
/** Mark finished with the given reason. Idempotent: first call wins. */
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTaskOutcome.java b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTaskOutcome.java
new file mode 100644
index 00000000..693b4d00
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTaskOutcome.java
@@ -0,0 +1,23 @@
+package com.talhanation.bannermod.settlement.goal;
+
+import net.minecraft.resources.ResourceLocation;
+
+public record ResidentTaskOutcome(
+ ResourceLocation goalId,
+ ResidentStopReason stopReason,
+ long finishedGameTime
+) {
+ public ResidentTaskOutcome {
+ if (goalId == null) {
+ throw new IllegalArgumentException("goalId must not be null");
+ }
+ if (stopReason == null) {
+ throw new IllegalArgumentException("stopReason must not be null");
+ }
+ }
+
+ public boolean isFailure() {
+ return this.stopReason == ResidentStopReason.TIMED_OUT
+ || this.stopReason == ResidentStopReason.CONTEXT_INVALID;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DefendResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DefendResidentGoal.java
new file mode 100644
index 00000000..800b4c84
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DefendResidentGoal.java
@@ -0,0 +1,41 @@
+package com.talhanation.bannermod.settlement.goal.impl;
+
+import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
+import com.talhanation.bannermod.settlement.goal.ResidentGoal;
+import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
+import com.talhanation.bannermod.settlement.goal.ResidentTask;
+import net.minecraft.resources.ResourceLocation;
+
+public final class DefendResidentGoal implements ResidentGoal {
+ public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/defend");
+
+ private static final int DEFEND_DURATION_TICKS = 100;
+ private static final int DEFEND_COOLDOWN_TICKS = 80;
+
+ @Override
+ public ResourceLocation id() {
+ return ID;
+ }
+
+ @Override
+ public int computePriority(ResidentGoalContext ctx) {
+ return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.DEFEND);
+ }
+
+ @Override
+ public boolean canStart(ResidentGoalContext ctx) {
+ return ctx.canDefend() && this.computePriority(ctx) > 0;
+ }
+
+ @Override
+ public ResidentTask start(ResidentGoalContext ctx) {
+ return new ResidentTask(ID, ctx.gameTime(), DEFEND_DURATION_TICKS);
+ }
+
+ @Override
+ public int cooldownTicks() {
+ return DEFEND_COOLDOWN_TICKS;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java
index d06de770..b750131a 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java
@@ -1,6 +1,8 @@
package com.talhanation.bannermod.settlement.goal.impl;
import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState;
import com.talhanation.bannermod.settlement.goal.ResidentGoal;
import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
@@ -9,8 +11,7 @@
/**
* Placeholder for the "deliver goods from workplace to stockpile/market" goal.
- * Wired only for residents with a bound workplace; concrete target resolution
- * lands in slice 25-next-C (project bridge) + 25-next-D (seller dispatch).
+ * Wired only for residents with a bound workplace.
*/
public final class DeliverResidentGoal implements ResidentGoal {
@@ -26,7 +27,14 @@ public ResourceLocation id() {
@Override
public int computePriority(ResidentGoalContext ctx) {
- return ctx.isActivePhase() ? DELIVER_PRIORITY : 0;
+ if (!ctx.isActivePhase()) {
+ return 0;
+ }
+ int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK) - 2;
+ if (score <= 0) {
+ return 0;
+ }
+ return Math.max(DELIVER_PRIORITY, score);
}
@Override
@@ -34,7 +42,7 @@ public boolean canStart(ResidentGoalContext ctx) {
if (!ctx.isActivePhase()) {
return false;
}
- if (ctx.resident().boundWorkAreaUuid() == null) {
+ if (ctx.resident().effectiveWorkBuildingUuid() == null) {
return false;
}
return ctx.resident().assignmentState() == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING;
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/EatResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/EatResidentGoal.java
new file mode 100644
index 00000000..bfe7b8be
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/EatResidentGoal.java
@@ -0,0 +1,41 @@
+package com.talhanation.bannermod.settlement.goal.impl;
+
+import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
+import com.talhanation.bannermod.settlement.goal.ResidentGoal;
+import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
+import com.talhanation.bannermod.settlement.goal.ResidentTask;
+import net.minecraft.resources.ResourceLocation;
+
+public final class EatResidentGoal implements ResidentGoal {
+ public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/eat");
+
+ private static final int EAT_DURATION_TICKS = 200;
+ private static final int EAT_COOLDOWN_TICKS = 200;
+
+ @Override
+ public ResourceLocation id() {
+ return ID;
+ }
+
+ @Override
+ public int computePriority(ResidentGoalContext ctx) {
+ return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.EAT);
+ }
+
+ @Override
+ public boolean canStart(ResidentGoalContext ctx) {
+ return ctx.hasHome() || ctx.hasMarketFoodAccess();
+ }
+
+ @Override
+ public ResidentTask start(ResidentGoalContext ctx) {
+ return new ResidentTask(ID, ctx.gameTime(), EAT_DURATION_TICKS);
+ }
+
+ @Override
+ public int cooldownTicks() {
+ return EAT_COOLDOWN_TICKS;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java
index 2e442b22..598ab6b7 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java
@@ -1,6 +1,8 @@
package com.talhanation.bannermod.settlement.goal.impl;
import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState;
import com.talhanation.bannermod.settlement.goal.ResidentGoal;
import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
@@ -27,7 +29,14 @@ public ResourceLocation id() {
@Override
public int computePriority(ResidentGoalContext ctx) {
- return ctx.isActivePhase() ? FETCH_PRIORITY : 0;
+ if (!ctx.isActivePhase()) {
+ return 0;
+ }
+ int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK) - 3;
+ if (score <= 0) {
+ return 0;
+ }
+ return Math.max(FETCH_PRIORITY, score);
}
@Override
@@ -35,7 +44,7 @@ public boolean canStart(ResidentGoalContext ctx) {
if (!ctx.isActivePhase()) {
return false;
}
- if (ctx.resident().boundWorkAreaUuid() == null) {
+ if (ctx.resident().effectiveWorkBuildingUuid() == null) {
return false;
}
return ctx.resident().assignmentState() == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING;
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/HideResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/HideResidentGoal.java
new file mode 100644
index 00000000..1279dac5
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/HideResidentGoal.java
@@ -0,0 +1,41 @@
+package com.talhanation.bannermod.settlement.goal.impl;
+
+import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
+import com.talhanation.bannermod.settlement.goal.ResidentGoal;
+import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
+import com.talhanation.bannermod.settlement.goal.ResidentTask;
+import net.minecraft.resources.ResourceLocation;
+
+public final class HideResidentGoal implements ResidentGoal {
+ public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/hide");
+
+ private static final int HIDE_DURATION_TICKS = 200;
+ private static final int HIDE_COOLDOWN_TICKS = 120;
+
+ @Override
+ public ResourceLocation id() {
+ return ID;
+ }
+
+ @Override
+ public int computePriority(ResidentGoalContext ctx) {
+ return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.HIDE);
+ }
+
+ @Override
+ public boolean canStart(ResidentGoalContext ctx) {
+ return true;
+ }
+
+ @Override
+ public ResidentTask start(ResidentGoalContext ctx) {
+ return new ResidentTask(ID, ctx.gameTime(), HIDE_DURATION_TICKS);
+ }
+
+ @Override
+ public int cooldownTicks() {
+ return HIDE_COOLDOWN_TICKS;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java
index ef7b5785..b0b18631 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java
@@ -6,13 +6,13 @@
import com.talhanation.bannermod.bootstrap.BannerModMain;
import net.minecraft.resources.ResourceLocation;
-/** Lowest-priority fallback. Always startable; runs for 40 ticks. */
+/** Lowest-priority fallback. Always startable; runs on a coarse heartbeat. */
public final class IdleResidentGoal implements ResidentGoal {
public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/idle");
private static final int IDLE_PRIORITY = 1;
- private static final int IDLE_DURATION_TICKS = 40;
+ private static final int IDLE_DURATION_TICKS = 160;
@Override
public ResourceLocation id() {
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java
index 6af08554..e3367bf6 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java
@@ -1,6 +1,8 @@
package com.talhanation.bannermod.settlement.goal.impl;
import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
import com.talhanation.bannermod.settlement.goal.ResidentGoal;
import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
import com.talhanation.bannermod.settlement.goal.ResidentTask;
@@ -12,7 +14,7 @@ public final class RestResidentGoal implements ResidentGoal {
public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/rest");
private static final int REST_PRIORITY = 90;
- private static final int REST_DURATION_TICKS = 200;
+ private static final int REST_DURATION_TICKS = 400;
private static final int REST_COOLDOWN_TICKS = 600;
@Override
@@ -22,12 +24,16 @@ public ResourceLocation id() {
@Override
public int computePriority(ResidentGoalContext ctx) {
- return ctx.isRestPhase() ? REST_PRIORITY : 0;
+ int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.REST);
+ if (ctx.isReadyToSettleAtHome()) {
+ score = Math.max(score, REST_PRIORITY + 18);
+ }
+ return ctx.isRestPhase() ? Math.max(REST_PRIORITY - 4, score) : score;
}
@Override
public boolean canStart(ResidentGoalContext ctx) {
- return ctx.isRestPhase();
+ return ctx.isRestPhase() || (ctx.hasHome() && ctx.fatigueNeed() >= 85);
}
@Override
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SeekSuppliesResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SeekSuppliesResidentGoal.java
new file mode 100644
index 00000000..308eb071
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SeekSuppliesResidentGoal.java
@@ -0,0 +1,41 @@
+package com.talhanation.bannermod.settlement.goal.impl;
+
+import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
+import com.talhanation.bannermod.settlement.goal.ResidentGoal;
+import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
+import com.talhanation.bannermod.settlement.goal.ResidentTask;
+import net.minecraft.resources.ResourceLocation;
+
+public final class SeekSuppliesResidentGoal implements ResidentGoal {
+ public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/seek_supplies");
+
+ private static final int SEEK_SUPPLIES_DURATION_TICKS = 240;
+ private static final int SEEK_SUPPLIES_COOLDOWN_TICKS = 200;
+
+ @Override
+ public ResourceLocation id() {
+ return ID;
+ }
+
+ @Override
+ public int computePriority(ResidentGoalContext ctx) {
+ return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.SEEK_SUPPLIES);
+ }
+
+ @Override
+ public boolean canStart(ResidentGoalContext ctx) {
+ return ctx.hasSupplyAccess();
+ }
+
+ @Override
+ public ResidentTask start(ResidentGoalContext ctx) {
+ return new ResidentTask(ID, ctx.gameTime(), SEEK_SUPPLIES_DURATION_TICKS);
+ }
+
+ @Override
+ public int cooldownTicks() {
+ return SEEK_SUPPLIES_COOLDOWN_TICKS;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SocialiseResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SocialiseResidentGoal.java
deleted file mode 100644
index a29a7c3c..00000000
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SocialiseResidentGoal.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.talhanation.bannermod.settlement.goal.impl;
-
-import com.talhanation.bannermod.bootstrap.BannerModMain;
-import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed;
-import com.talhanation.bannermod.settlement.goal.ResidentGoal;
-import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
-import com.talhanation.bannermod.settlement.goal.ResidentTask;
-import net.minecraft.resources.ResourceLocation;
-
-/** Mid-priority leisure goal active during civic-day windows. */
-public final class SocialiseResidentGoal implements ResidentGoal {
-
- public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/socialise");
-
- private static final int SOCIALISE_PRIORITY = 30;
- private static final int SOCIALISE_DURATION_TICKS = 120;
- private static final int SOCIALISE_COOLDOWN_TICKS = 400;
-
- @Override
- public ResourceLocation id() {
- return ID;
- }
-
- @Override
- public int computePriority(ResidentGoalContext ctx) {
- if (!ctx.isActivePhase()) {
- return 0;
- }
- return ctx.window() == SettlementResidentScheduleWindowSeed.CIVIC_DAY
- || ctx.window() == SettlementResidentScheduleWindowSeed.DAYLIGHT_FLEX
- ? SOCIALISE_PRIORITY
- : 0;
- }
-
- @Override
- public boolean canStart(ResidentGoalContext ctx) {
- return this.computePriority(ctx) > 0;
- }
-
- @Override
- public ResidentTask start(ResidentGoalContext ctx) {
- return new ResidentTask(ID, ctx.gameTime(), SOCIALISE_DURATION_TICKS);
- }
-
- @Override
- public int cooldownTicks() {
- return SOCIALISE_COOLDOWN_TICKS;
- }
-}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java
index 0b59cf02..9e4e9f68 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java
@@ -1,7 +1,8 @@
package com.talhanation.bannermod.settlement.goal.impl;
import com.talhanation.bannermod.bootstrap.BannerModMain;
-import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
import com.talhanation.bannermod.settlement.SettlementResidentRole;
import com.talhanation.bannermod.settlement.goal.ResidentGoal;
import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
@@ -9,16 +10,15 @@
import net.minecraft.resources.ResourceLocation;
/**
- * Core day-labor goal for residents that have a workplace assignment. Sits
- * above {@link SocialiseResidentGoal} in priority during active hours so
- * bound workers prefer their job over chatter.
+ * Core day-labor goal for residents that have a workplace assignment.
+ * Bound workers should prefer useful labor during active hours.
*/
public final class WorkResidentGoal implements ResidentGoal {
public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/work");
private static final int WORK_PRIORITY = 60;
- private static final int WORK_DURATION_TICKS = 400;
+ private static final int WORK_DURATION_TICKS = 600;
@Override
public ResourceLocation id() {
@@ -27,7 +27,14 @@ public ResourceLocation id() {
@Override
public int computePriority(ResidentGoalContext ctx) {
- return ctx.isActivePhase() ? WORK_PRIORITY : 0;
+ if (!ctx.isActivePhase()) {
+ return 0;
+ }
+ int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK);
+ if (score <= 0) {
+ return 0;
+ }
+ return Math.max(WORK_PRIORITY - 10, score);
}
@Override
@@ -35,12 +42,13 @@ public boolean canStart(ResidentGoalContext ctx) {
if (!ctx.isActivePhase()) {
return false;
}
+ if (ctx.fatigueNeed() >= 90) {
+ return false;
+ }
if (ctx.resident().role() == SettlementResidentRole.GOVERNOR_RECRUIT) {
return false;
}
- SettlementResidentAssignmentState state = ctx.resident().assignmentState();
- return state == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING
- || state == SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING;
+ return ctx.hasWorkAssignment();
}
@Override
diff --git a/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java b/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java
index 1d1582a5..050da893 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java
@@ -3,6 +3,7 @@
import com.talhanation.bannermod.settlement.SettlementBuildingCategory;
import com.talhanation.bannermod.settlement.SettlementBuildingProfileSeed;
import net.minecraft.nbt.CompoundTag;
+import net.minecraft.resources.ResourceLocation;
import javax.annotation.Nullable;
import java.util.UUID;
@@ -16,6 +17,7 @@ public record PendingProject(
UUID projectId,
ProjectKind kind,
@Nullable UUID targetBuildingUuid,
+ @Nullable ResourceLocation prefabId,
SettlementBuildingCategory buildingCategory,
SettlementBuildingProfileSeed profileSeed,
int priorityScore,
@@ -46,6 +48,18 @@ public record PendingProject(
}
}
+ public PendingProject(UUID projectId,
+ ProjectKind kind,
+ @Nullable UUID targetBuildingUuid,
+ SettlementBuildingCategory buildingCategory,
+ SettlementBuildingProfileSeed profileSeed,
+ int priorityScore,
+ long proposedAtGameTime,
+ int estimatedTickCost,
+ ProjectBlocker blockerReason) {
+ this(projectId, kind, targetBuildingUuid, null, buildingCategory, profileSeed, priorityScore, proposedAtGameTime, estimatedTickCost, blockerReason);
+ }
+
public CompoundTag toTag() {
CompoundTag tag = new CompoundTag();
tag.putUUID("Id", projectId);
@@ -53,6 +67,9 @@ public CompoundTag toTag() {
if (targetBuildingUuid != null) {
tag.putUUID("Target", targetBuildingUuid);
}
+ if (prefabId != null) {
+ tag.putString("PrefabId", prefabId.toString());
+ }
tag.putString("Category", buildingCategory.name());
tag.putString("Profile", profileSeed.name());
tag.putInt("Priority", priorityScore);
@@ -68,6 +85,7 @@ public static PendingProject fromTag(CompoundTag tag) {
tag.getUUID("Id"),
kindFromTagName(tag.getString("Kind")),
target,
+ tag.contains("PrefabId") ? ResourceLocation.tryParse(tag.getString("PrefabId")) : null,
SettlementBuildingCategory.fromTagName(tag.getString("Category")),
SettlementBuildingProfileSeed.fromTagName(tag.getString("Profile")),
tag.getInt("Priority"),
diff --git a/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java b/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java
index 9fa076de..09b54911 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java
@@ -321,6 +321,7 @@ PendingProject toPendingProject(SettlementGrowthContext ctx) {
projectId,
ProjectKind.NEW_BUILDING,
null,
+ null,
category,
this.profile,
this.clampedScore(),
diff --git a/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java
index 64d2a16f..f9d0d83d 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java
@@ -1,6 +1,8 @@
package com.talhanation.bannermod.settlement.household;
import com.talhanation.bannermod.bootstrap.BannerModMain;
+import com.talhanation.bannermod.society.NpcIntent;
+import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer;
import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed;
import com.talhanation.bannermod.settlement.goal.ResidentGoal;
import com.talhanation.bannermod.settlement.goal.ResidentGoalContext;
@@ -20,7 +22,7 @@ public final class GoHomeResidentGoal implements ResidentGoal {
public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/go_home");
private static final int GO_HOME_PRIORITY = 95;
- private static final int GO_HOME_DURATION_TICKS = 120;
+ private static final int GO_HOME_DURATION_TICKS = 200;
private static final int GO_HOME_COOLDOWN_TICKS = 400;
/** Tick-window before the rest phase begins where the goal also arms. */
@@ -48,7 +50,16 @@ public int computePriority(ResidentGoalContext ctx) {
if (this.runtime.homeFor(ctx.residentId()).isEmpty()) {
return 0;
}
- return GO_HOME_PRIORITY;
+ int goHomeBias = GO_HOME_PRIORITY;
+ if (ctx.isRestPhase()) {
+ goHomeBias += 35;
+ } else if (ctx.fatigueNeed() >= 80) {
+ goHomeBias += 20;
+ }
+ if (ctx.isReadyToSettleAtHome()) {
+ goHomeBias -= 42;
+ }
+ return Math.max(goHomeBias, NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.GO_HOME));
}
@Override
@@ -70,6 +81,9 @@ public int cooldownTicks() {
}
private static boolean isRestOrApproachingRest(ResidentGoalContext ctx) {
+ if (ctx.hasHome() && ctx.fatigueNeed() >= 80) {
+ return true;
+ }
if (ctx.isRestPhase()) {
return true;
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java
index 3f8e3f84..b9d8a97c 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java
@@ -21,7 +21,7 @@ public final class LeaveHomeResidentGoal implements ResidentGoal {
public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/leave_home");
private static final int LEAVE_HOME_PRIORITY = 80;
- private static final int LEAVE_HOME_DURATION_TICKS = 60;
+ private static final int LEAVE_HOME_DURATION_TICKS = 120;
private static final int LEAVE_HOME_COOLDOWN_TICKS = 400;
/** Window after active-phase start where this goal may still fire. */
@@ -49,7 +49,13 @@ public int computePriority(ResidentGoalContext ctx) {
if (this.runtime.homeFor(ctx.residentId()).isEmpty()) {
return 0;
}
- return LEAVE_HOME_PRIORITY;
+ int priority = LEAVE_HOME_PRIORITY;
+ if (ctx.isReadyToFanOutFromLeaveHome()) {
+ priority -= 52;
+ } else if (ctx.currentPublishedIntent() == com.talhanation.bannermod.society.NpcIntent.LEAVE_HOME) {
+ priority += 8;
+ }
+ return priority;
}
@Override
@@ -57,6 +63,9 @@ public boolean canStart(ResidentGoalContext ctx) {
if (this.runtime.homeFor(ctx.residentId()).isEmpty()) {
return false;
}
+ if (ctx.fatigueNeed() >= 85) {
+ return false;
+ }
return isEarlyActive(ctx);
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java b/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java
index 4159e112..25e69379 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java
@@ -23,6 +23,8 @@
import com.talhanation.bannermod.settlement.prefab.impl.MinePrefab;
import com.talhanation.bannermod.settlement.building.BuildingType;
import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord;
+import com.talhanation.bannermod.society.NpcLifeStage;
+import com.talhanation.bannermod.society.NpcSocietyAccess;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
@@ -286,6 +288,10 @@ public static void assignCitizenToNearestVacancy(ServerLevel level, CitizenEntit
if (level == null || citizen == null || !citizen.isAlive() || citizen.isRemoved()) {
return;
}
+ NpcLifeStage lifeStage = NpcSocietyAccess.ensureResident(level, citizen.getUUID(), level.getGameTime()).lifeStage();
+ if (lifeStage != NpcLifeStage.ADULT && lifeStage != NpcLifeStage.ELDER) {
+ return;
+ }
long pausedUntil = citizen.getPersistentData().getLong(TAG_ASSIGNMENT_PAUSE_UNTIL);
if (pausedUntil > level.getGameTime()) {
return;
diff --git a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java
index 1cc40eac..24fcff45 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java
@@ -101,7 +101,7 @@ public Optional tickClaim(
&& scheduler.peek(claimUuid)
.filter(project -> project.kind() == ProjectKind.NEW_BUILDING)
.isPresent()
- && SettlementProjectWorldExecution.ensureExecutableTarget(
+ && BannerModSettlementProjectWorldExecution.ensureExecutableTarget(
ignoredLevel,
claimUuid,
scheduler.peek(claimUuid).orElse(null))) {
diff --git a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java
index 56498607..e6f3e642 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java
@@ -13,6 +13,8 @@
import com.talhanation.bannermod.settlement.prefab.impl.LumberCampPrefab;
import com.talhanation.bannermod.settlement.prefab.impl.MarketStallPrefab;
import com.talhanation.bannermod.settlement.prefab.impl.StoragePrefab;
+import com.talhanation.bannermod.society.NpcHousingRequestAccess;
+import com.talhanation.bannermod.society.NpcHousingRequestRecord;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceLocation;
@@ -23,9 +25,9 @@
import java.util.List;
import java.util.UUID;
-final class SettlementProjectWorldExecution {
+final class BannerModSettlementProjectWorldExecution {
- private SettlementProjectWorldExecution() {
+ private BannerModSettlementProjectWorldExecution() {
}
static boolean ensureExecutableTarget(ServerLevel level, UUID claimUuid, PendingProject project) {
@@ -44,13 +46,35 @@ static boolean ensureExecutableTarget(ServerLevel level, UUID claimUuid, Pending
if (buildAreas.stream().anyMatch(buildArea -> buildArea != null && buildArea.isAlive() && !buildArea.isDone())) {
return false;
}
- return BuildingPlacementService.placeForClaim(
+ NpcHousingRequestRecord housingRequest = NpcHousingRequestAccess.requestForProject(level, project.projectId());
+ BuildingPlacementService.Result result = BuildingPlacementService.placeForClaim(
level,
claim,
- prefabIdFor(project.profileSeed()),
- choosePlacementPos(level, claim, buildAreas.size()),
+ prefabIdFor(project),
+ housingRequest != null && housingRequest.reservedPlotPos() != null
+ ? housingRequest.reservedPlotPos()
+ : choosePlacementPos(level, claim, buildAreas.size()),
Direction.SOUTH
- ) == BuildingPlacementService.Result.PLACED;
+ );
+ if (result != BuildingPlacementService.Result.PLACED) {
+ return false;
+ }
+ if (project != null && project.prefabId() != null) {
+ BuildArea placedArea = BannerModBuildAreaProjectBridge.collectBuildAreas(level, claim).stream()
+ .filter(buildArea -> buildArea != null && buildArea.isAlive() && !buildArea.isDone())
+ .max(java.util.Comparator.comparingInt(net.minecraft.world.entity.Entity::getId))
+ .orElse(null);
+ if (placedArea != null) {
+ if (housingRequest != null) {
+ NpcHousingRequestAccess.bindBuildArea(level, housingRequest.householdId(), placedArea.getUUID(), level.getGameTime());
+ }
+ // First autonomous livelihood slice: once the ruler approves the request,
+ // material bootstrap is granted immediately so the new workplace can start
+ // supporting the settlement instead of deadlocking on missing resources.
+ placedArea.setStartBuild(true);
+ }
+ }
+ return true;
}
private static RecruitsClaim resolveClaim(UUID claimUuid) {
@@ -62,7 +86,11 @@ private static RecruitsClaim resolveClaim(UUID claimUuid) {
return null;
}
- private static ResourceLocation prefabIdFor(SettlementBuildingProfileSeed profileSeed) {
+ private static ResourceLocation prefabIdFor(PendingProject project) {
+ if (project != null && project.prefabId() != null) {
+ return project.prefabId();
+ }
+ SettlementBuildingProfileSeed profileSeed = project == null ? null : project.profileSeed();
return switch (profileSeed == null ? SettlementBuildingProfileSeed.GENERAL : profileSeed) {
case FOOD_PRODUCTION -> FarmPrefab.ID;
case MATERIAL_PRODUCTION -> LumberCampPrefab.ID;
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java
index d9e157cb..1ca2e51c 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java
@@ -3,6 +3,8 @@
import com.talhanation.bannermod.settlement.SettlementBuildingRecord;
import com.talhanation.bannermod.settlement.SettlementSnapshot;
import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.phys.AABB;
import javax.annotation.Nullable;
import java.util.Objects;
@@ -28,4 +30,23 @@ public record SettlementWorkOrderPublishContext(
Objects.requireNonNull(building, "building");
Objects.requireNonNull(snapshot, "snapshot");
}
+
+ public @Nullable T resolveBuildingEntity(Class entityClass) {
+ if (entityClass == null || this.level == null) {
+ return null;
+ }
+ Entity direct = this.level.getEntity(this.building.buildingUuid());
+ if (entityClass.isInstance(direct) && direct != null && direct.isAlive()) {
+ return entityClass.cast(direct);
+ }
+ if (this.building.originPos() == null) {
+ return null;
+ }
+ AABB searchBox = new AABB(this.building.originPos()).inflate(1.5D);
+ return this.level.getEntitiesOfClass(entityClass, searchBox,
+ entity -> entity != null && entity.isAlive() && this.building.originPos().equals(entity.blockPosition()))
+ .stream()
+ .findFirst()
+ .orElse(null);
+ }
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java
index 294530d8..b1f6b60b 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java
@@ -34,8 +34,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) {
if (level == null) {
return;
}
- Entity entity = level.getEntity(ctx.building().buildingUuid());
- if (!(entity instanceof AnimalPenArea pen) || !pen.isAlive()) {
+ AnimalPenArea pen = ctx.resolveBuildingEntity(AnimalPenArea.class);
+ if (pen == null) {
return;
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java
index 495f6074..a8d4d505 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java
@@ -38,8 +38,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) {
if (level == null) {
return;
}
- Entity entity = level.getEntity(ctx.building().buildingUuid());
- if (!(entity instanceof BuildArea buildArea) || !buildArea.isAlive()) {
+ BuildArea buildArea = ctx.resolveBuildingEntity(BuildArea.class);
+ if (buildArea == null) {
return;
}
if (!buildArea.hasPendingBuildWork()) {
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java
index 1d44318c..7f52bcee 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java
@@ -36,8 +36,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) {
if (level == null) {
return;
}
- Entity entity = level.getEntity(ctx.building().buildingUuid());
- if (!(entity instanceof CropArea cropArea) || !cropArea.isAlive()) {
+ CropArea cropArea = ctx.resolveBuildingEntity(CropArea.class);
+ if (cropArea == null) {
return;
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java
index 55593703..e5c5605c 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java
@@ -25,8 +25,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) {
if (level == null) {
return;
}
- Entity entity = level.getEntity(ctx.building().buildingUuid());
- if (!(entity instanceof FishingArea fishingArea) || !fishingArea.isAlive()) {
+ FishingArea fishingArea = ctx.resolveBuildingEntity(FishingArea.class);
+ if (fishingArea == null) {
return;
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java
index ba73e759..10b3c852 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java
@@ -37,8 +37,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) {
if (level == null) {
return;
}
- Entity entity = level.getEntity(ctx.building().buildingUuid());
- if (!(entity instanceof LumberArea lumberArea) || !lumberArea.isAlive()) {
+ LumberArea lumberArea = ctx.resolveBuildingEntity(LumberArea.class);
+ if (lumberArea == null) {
return;
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java
index 387c9825..562e48d7 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java
@@ -32,8 +32,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) {
if (level == null) {
return;
}
- Entity entity = level.getEntity(ctx.building().buildingUuid());
- if (!(entity instanceof MiningArea miningArea) || !miningArea.isAlive()) {
+ MiningArea miningArea = ctx.resolveBuildingEntity(MiningArea.class);
+ if (miningArea == null) {
return;
}
diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java
index d1f24f6b..b8d36c52 100644
--- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java
+++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java
@@ -44,8 +44,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) {
if (level == null) {
return;
}
- Entity sourceEntity = level.getEntity(ctx.building().buildingUuid());
- if (!(sourceEntity instanceof StorageArea source) || !source.isAlive()) {
+ StorageArea source = ctx.resolveBuildingEntity(StorageArea.class);
+ if (source == null) {
return;
}
BannerModLogisticsAuthoringState authoring = source.getLogisticsRouteAuthoringState();
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcAnchorType.java b/src/main/java/com/talhanation/bannermod/society/NpcAnchorType.java
new file mode 100644
index 00000000..e10136d9
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcAnchorType.java
@@ -0,0 +1,21 @@
+package com.talhanation.bannermod.society;
+
+public enum NpcAnchorType {
+ NONE,
+ HOME,
+ WORKPLACE,
+ MARKET,
+ BARRACKS,
+ STREET;
+
+ public static NpcAnchorType fromName(String name) {
+ if (name == null || name.isBlank()) {
+ return NONE;
+ }
+ try {
+ return NpcAnchorType.valueOf(name);
+ } catch (IllegalArgumentException ignored) {
+ return NONE;
+ }
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcDailyPhase.java b/src/main/java/com/talhanation/bannermod/society/NpcDailyPhase.java
new file mode 100644
index 00000000..368b8f72
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcDailyPhase.java
@@ -0,0 +1,20 @@
+package com.talhanation.bannermod.society;
+
+public enum NpcDailyPhase {
+ UNSPECIFIED,
+ ACTIVE,
+ DEPARTING_HOME,
+ RETURNING_HOME,
+ REST;
+
+ public static NpcDailyPhase fromName(String name) {
+ if (name == null || name.isBlank()) {
+ return UNSPECIFIED;
+ }
+ try {
+ return NpcDailyPhase.valueOf(name);
+ } catch (IllegalArgumentException ignored) {
+ return UNSPECIFIED;
+ }
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyAccess.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyAccess.java
new file mode 100644
index 00000000..6d386b22
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyAccess.java
@@ -0,0 +1,279 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.Entity;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+public final class NpcFamilyAccess {
+ private NpcFamilyAccess() {
+ }
+
+ public static void reconcileFamilyForResident(ServerLevel level, UUID residentUuid, long gameTime) {
+ if (level == null || residentUuid == null) {
+ return;
+ }
+ NpcHouseholdRecord household = NpcHouseholdAccess.householdForResident(level, residentUuid).orElse(null);
+ if (household == null) {
+ return;
+ }
+ reconcileHousehold(level, household, gameTime);
+ }
+
+ public static void reconcileHousehold(ServerLevel level, UUID householdId, long gameTime) {
+ if (level == null || householdId == null) {
+ return;
+ }
+ NpcHouseholdRecord household = NpcHouseholdAccess.householdFor(level, householdId).orElse(null);
+ if (household == null) {
+ return;
+ }
+ reconcileHousehold(level, household, gameTime);
+ }
+
+ public static void moveResident(ServerLevel level, UUID fromResidentUuid, UUID toResidentUuid, long gameTime) {
+ if (level == null) {
+ return;
+ }
+ NpcFamilySavedData.get(level).runtime().moveResident(fromResidentUuid, toResidentUuid, gameTime);
+ }
+
+ public static NpcFamilyTreeSnapshot familyTreeSnapshot(ServerLevel level, UUID residentUuid, long gameTime) {
+ if (level == null || residentUuid == null) {
+ return NpcFamilyTreeSnapshot.empty();
+ }
+ NpcSocietyProfile selfProfile = NpcSocietyAccess.ensureResident(level, residentUuid, gameTime);
+ NpcFamilyRecord family = NpcFamilySavedData.get(level).runtime().familyFor(residentUuid).orElse(null);
+ NpcFamilyMemberSnapshot self = memberSnapshot(level, residentUuid, selfProfile, "self", gameTime);
+ if (family == null) {
+ return new NpcFamilyTreeSnapshot(self, null, null, null, List.of());
+ }
+ NpcFamilyMemberSnapshot spouse = family.spouseUuid() == null ? null : memberSnapshot(level, family.spouseUuid(), null, "spouse", gameTime);
+ NpcFamilyMemberSnapshot mother = family.motherUuid() == null ? null : memberSnapshot(level, family.motherUuid(), null, "mother", gameTime);
+ NpcFamilyMemberSnapshot father = family.fatherUuid() == null ? null : memberSnapshot(level, family.fatherUuid(), null, "father", gameTime);
+ List children = new ArrayList<>();
+ for (UUID childUuid : family.childUuids()) {
+ if (childUuid != null) {
+ children.add(memberSnapshot(level, childUuid, null, "child", gameTime));
+ }
+ }
+ return new NpcFamilyTreeSnapshot(self, spouse, mother, father, List.copyOf(children));
+ }
+
+ private static void reconcileHousehold(ServerLevel level, NpcHouseholdRecord household, long gameTime) {
+ NpcFamilyRuntime runtime = NpcFamilySavedData.get(level).runtime();
+ List members = new ArrayList<>();
+ Map existingByResident = new LinkedHashMap<>();
+ for (UUID memberResidentUuid : household.memberResidentUuids()) {
+ if (memberResidentUuid == null) {
+ continue;
+ }
+ members.add(NpcSocietyAccess.ensureResident(level, memberResidentUuid, gameTime));
+ runtime.familyFor(memberResidentUuid).ifPresent(record -> existingByResident.put(memberResidentUuid, record));
+ }
+ members.sort(memberOrder());
+ Set validResidentIds = new LinkedHashSet<>();
+ for (NpcSocietyProfile member : members) {
+ if (member.residentUuid() != null) {
+ validResidentIds.add(member.residentUuid());
+ }
+ }
+
+ UUID headResidentUuid = chooseHead(household, members, validResidentIds);
+ SpousePair spousePair = chooseSpousePair(members, existingByResident, validResidentIds);
+ UUID defaultMotherUuid = spousePair == null ? null : spousePair.femaleAdult();
+ UUID defaultFatherUuid = spousePair == null ? null : spousePair.maleAdult();
+
+ Map motherByChild = new LinkedHashMap<>();
+ Map fatherByChild = new LinkedHashMap<>();
+ for (NpcSocietyProfile member : members) {
+ UUID residentUuid = member.residentUuid();
+ if (residentUuid == null || !isMinor(member)) {
+ continue;
+ }
+ NpcFamilyRecord existing = existingByResident.get(residentUuid);
+ UUID motherUuid = existing != null && validResidentIds.contains(existing.motherUuid()) ? existing.motherUuid() : defaultMotherUuid;
+ UUID fatherUuid = existing != null && validResidentIds.contains(existing.fatherUuid()) ? existing.fatherUuid() : defaultFatherUuid;
+ motherByChild.put(residentUuid, motherUuid);
+ fatherByChild.put(residentUuid, fatherUuid);
+ }
+
+ Map> childrenByParent = new LinkedHashMap<>();
+ for (NpcSocietyProfile member : members) {
+ if (member.residentUuid() != null) {
+ childrenByParent.put(member.residentUuid(), new ArrayList<>());
+ }
+ }
+ for (Map.Entry entry : motherByChild.entrySet()) {
+ if (entry.getValue() != null && childrenByParent.containsKey(entry.getValue())) {
+ childrenByParent.get(entry.getValue()).add(entry.getKey());
+ }
+ }
+ for (Map.Entry entry : fatherByChild.entrySet()) {
+ if (entry.getValue() != null && childrenByParent.containsKey(entry.getValue())
+ && !childrenByParent.get(entry.getValue()).contains(entry.getKey())) {
+ childrenByParent.get(entry.getValue()).add(entry.getKey());
+ }
+ }
+
+ List records = new ArrayList<>();
+ for (NpcSocietyProfile member : members) {
+ UUID residentUuid = member.residentUuid();
+ if (residentUuid == null) {
+ continue;
+ }
+ NpcFamilyRecord existing = existingByResident.get(residentUuid);
+ UUID spouseUuid = resolveSpouse(residentUuid, spousePair, validResidentIds, existing);
+ UUID motherUuid = isMinor(member) ? motherByChild.get(residentUuid) : null;
+ UUID fatherUuid = isMinor(member) ? fatherByChild.get(residentUuid) : null;
+ List ownChildren = childrenByParent.getOrDefault(residentUuid, List.of());
+ records.add(NpcFamilyRecord.create(
+ residentUuid,
+ household.householdId(),
+ spouseUuid,
+ motherUuid,
+ fatherUuid,
+ ownChildren,
+ gameTime
+ ));
+ }
+
+ NpcHouseholdAccess.updateHead(level, household.householdId(), headResidentUuid, gameTime);
+ runtime.replaceHousehold(household.householdId(), records);
+ }
+
+ private static @Nullable UUID chooseHead(NpcHouseholdRecord household,
+ List members,
+ Set validResidentIds) {
+ if (household.headResidentUuid() != null && validResidentIds.contains(household.headResidentUuid())) {
+ return household.headResidentUuid();
+ }
+ for (NpcSocietyProfile member : members) {
+ if (isAdult(member) && member.residentUuid() != null) {
+ return member.residentUuid();
+ }
+ }
+ return members.isEmpty() ? null : members.getFirst().residentUuid();
+ }
+
+ private static @Nullable SpousePair chooseSpousePair(List members,
+ Map existingByResident,
+ Set validResidentIds) {
+ for (NpcSocietyProfile member : members) {
+ UUID residentUuid = member.residentUuid();
+ if (residentUuid == null || !isAdult(member)) {
+ continue;
+ }
+ NpcFamilyRecord existing = existingByResident.get(residentUuid);
+ if (existing == null || existing.spouseUuid() == null || !validResidentIds.contains(existing.spouseUuid())) {
+ continue;
+ }
+ NpcFamilyRecord spouseRecord = existingByResident.get(existing.spouseUuid());
+ if (spouseRecord == null || !residentUuid.equals(spouseRecord.spouseUuid())) {
+ continue;
+ }
+ NpcSocietyProfile spouseProfile = profileById(members, existing.spouseUuid());
+ if (spouseProfile != null && isAdult(spouseProfile)) {
+ return SpousePair.of(member, spouseProfile);
+ }
+ }
+
+ NpcSocietyProfile female = null;
+ NpcSocietyProfile male = null;
+ for (NpcSocietyProfile member : members) {
+ if (!isAdult(member) || member.residentUuid() == null) {
+ continue;
+ }
+ if (female == null && member.sex() == NpcSex.FEMALE) {
+ female = member;
+ } else if (male == null && member.sex() == NpcSex.MALE) {
+ male = member;
+ }
+ }
+ if (female != null && male != null && !female.residentUuid().equals(male.residentUuid())) {
+ return SpousePair.of(female, male);
+ }
+ return null;
+ }
+
+ private static @Nullable UUID resolveSpouse(UUID residentUuid,
+ @Nullable SpousePair spousePair,
+ Set validResidentIds,
+ @Nullable NpcFamilyRecord existing) {
+ if (spousePair != null) {
+ if (residentUuid.equals(spousePair.first())) {
+ return spousePair.second();
+ }
+ if (residentUuid.equals(spousePair.second())) {
+ return spousePair.first();
+ }
+ }
+ if (existing != null && validResidentIds.contains(existing.spouseUuid())) {
+ return existing.spouseUuid();
+ }
+ return null;
+ }
+
+ private static boolean isAdult(NpcSocietyProfile profile) {
+ return profile.lifeStage() == NpcLifeStage.ADULT || profile.lifeStage() == NpcLifeStage.ELDER;
+ }
+
+ private static boolean isMinor(NpcSocietyProfile profile) {
+ return profile.lifeStage() == NpcLifeStage.CHILD || profile.lifeStage() == NpcLifeStage.ADOLESCENT;
+ }
+
+ private static Comparator memberOrder() {
+ return Comparator
+ .comparingInt((NpcSocietyProfile profile) -> isAdult(profile) ? 0 : 1)
+ .thenComparing(profile -> profile.residentUuid(), Comparator.nullsLast(Comparator.naturalOrder()));
+ }
+
+ private static @Nullable NpcSocietyProfile profileById(List members, UUID residentUuid) {
+ for (NpcSocietyProfile member : members) {
+ if (residentUuid != null && residentUuid.equals(member.residentUuid())) {
+ return member;
+ }
+ }
+ return null;
+ }
+
+ private static NpcFamilyMemberSnapshot memberSnapshot(ServerLevel level,
+ UUID residentUuid,
+ @Nullable NpcSocietyProfile profile,
+ String relationTag,
+ long gameTime) {
+ NpcSocietyProfile resolvedProfile = profile == null
+ ? NpcSocietyAccess.ensureResident(level, residentUuid, gameTime)
+ : profile;
+ Entity entity = level.getEntity(residentUuid);
+ String displayName = entity == null ? shortId(residentUuid) : entity.getName().getString();
+ return new NpcFamilyMemberSnapshot(
+ residentUuid,
+ entity == null ? -1 : entity.getId(),
+ displayName,
+ resolvedProfile.lifeStage().name(),
+ resolvedProfile.sex().name(),
+ relationTag
+ );
+ }
+
+ private static String shortId(UUID residentUuid) {
+ return residentUuid == null ? "-" : residentUuid.toString().substring(0, 8);
+ }
+
+ private record SpousePair(UUID first, UUID second, UUID femaleAdult, UUID maleAdult) {
+ private static SpousePair of(NpcSocietyProfile left, NpcSocietyProfile right) {
+ UUID femaleAdult = left.sex() == NpcSex.FEMALE ? left.residentUuid() : right.sex() == NpcSex.FEMALE ? right.residentUuid() : null;
+ UUID maleAdult = left.sex() == NpcSex.MALE ? left.residentUuid() : right.sex() == NpcSex.MALE ? right.residentUuid() : null;
+ return new SpousePair(left.residentUuid(), right.residentUuid(), femaleAdult, maleAdult);
+ }
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyMemberSnapshot.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyMemberSnapshot.java
new file mode 100644
index 00000000..cf4df2cc
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyMemberSnapshot.java
@@ -0,0 +1,46 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.util.UUID;
+
+public record NpcFamilyMemberSnapshot(
+ UUID residentUuid,
+ int entityId,
+ String displayName,
+ String lifeStageTag,
+ String sexTag,
+ String relationTag
+) {
+ public void toBytes(FriendlyByteBuf buf) {
+ buf.writeUUID(this.residentUuid);
+ buf.writeVarInt(this.entityId);
+ buf.writeUtf(this.displayName == null ? "" : this.displayName);
+ buf.writeUtf(this.lifeStageTag == null ? NpcLifeStage.UNSPECIFIED.name() : this.lifeStageTag);
+ buf.writeUtf(this.sexTag == null ? NpcSex.UNSPECIFIED.name() : this.sexTag);
+ buf.writeUtf(this.relationTag == null ? "self" : this.relationTag);
+ }
+
+ public static NpcFamilyMemberSnapshot fromBytes(FriendlyByteBuf buf) {
+ return new NpcFamilyMemberSnapshot(
+ buf.readUUID(),
+ buf.readVarInt(),
+ buf.readUtf(),
+ buf.readUtf(),
+ buf.readUtf(),
+ buf.readUtf()
+ );
+ }
+
+ public String lifeStageTranslationKey() {
+ return "gui.bannermod.society.life_stage." + this.lifeStageTag.toLowerCase(java.util.Locale.ROOT);
+ }
+
+ public String sexTranslationKey() {
+ return "gui.bannermod.society.sex." + this.sexTag.toLowerCase(java.util.Locale.ROOT);
+ }
+
+ public String relationTranslationKey() {
+ return "gui.bannermod.society.family_relation." + this.relationTag.toLowerCase(java.util.Locale.ROOT);
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyRecord.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRecord.java
new file mode 100644
index 00000000..302b9ec8
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRecord.java
@@ -0,0 +1,168 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+public record NpcFamilyRecord(
+ UUID residentUuid,
+ UUID householdId,
+ @Nullable UUID spouseUuid,
+ @Nullable UUID motherUuid,
+ @Nullable UUID fatherUuid,
+ List childUuids,
+ long version,
+ long lastUpdatedGameTime
+) {
+ public NpcFamilyRecord {
+ if (residentUuid == null) {
+ throw new IllegalArgumentException("residentUuid must not be null");
+ }
+ if (householdId == null) {
+ throw new IllegalArgumentException("householdId must not be null");
+ }
+ childUuids = sanitizeChildren(childUuids);
+ version = Math.max(1L, version);
+ }
+
+ public static NpcFamilyRecord create(UUID residentUuid,
+ UUID householdId,
+ @Nullable UUID spouseUuid,
+ @Nullable UUID motherUuid,
+ @Nullable UUID fatherUuid,
+ @Nullable Collection childUuids,
+ long gameTime) {
+ return new NpcFamilyRecord(residentUuid, householdId, spouseUuid, motherUuid, fatherUuid, copyChildren(childUuids), 1L, gameTime);
+ }
+
+ public NpcFamilyRecord moveResident(UUID toResidentUuid, long gameTime) {
+ if (toResidentUuid == null || toResidentUuid.equals(this.residentUuid)) {
+ return this;
+ }
+ return new NpcFamilyRecord(
+ toResidentUuid,
+ this.householdId,
+ this.spouseUuid,
+ this.motherUuid,
+ this.fatherUuid,
+ this.childUuids,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public NpcFamilyRecord replaceReference(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) {
+ if (fromResidentUuid == null || toResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) {
+ return this;
+ }
+ boolean changed = false;
+ UUID spouse = this.spouseUuid;
+ UUID mother = this.motherUuid;
+ UUID father = this.fatherUuid;
+ if (fromResidentUuid.equals(spouse)) {
+ spouse = toResidentUuid;
+ changed = true;
+ }
+ if (fromResidentUuid.equals(mother)) {
+ mother = toResidentUuid;
+ changed = true;
+ }
+ if (fromResidentUuid.equals(father)) {
+ father = toResidentUuid;
+ changed = true;
+ }
+ List children = new ArrayList<>(this.childUuids);
+ for (int i = 0; i < children.size(); i++) {
+ if (fromResidentUuid.equals(children.get(i))) {
+ children.set(i, toResidentUuid);
+ changed = true;
+ }
+ }
+ if (!changed) {
+ return this;
+ }
+ return new NpcFamilyRecord(
+ this.residentUuid,
+ this.householdId,
+ spouse,
+ mother,
+ father,
+ children,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public CompoundTag toTag() {
+ CompoundTag tag = new CompoundTag();
+ tag.putUUID("ResidentUuid", this.residentUuid);
+ tag.putUUID("HouseholdId", this.householdId);
+ if (this.spouseUuid != null) {
+ tag.putUUID("SpouseUuid", this.spouseUuid);
+ }
+ if (this.motherUuid != null) {
+ tag.putUUID("MotherUuid", this.motherUuid);
+ }
+ if (this.fatherUuid != null) {
+ tag.putUUID("FatherUuid", this.fatherUuid);
+ }
+ ListTag children = new ListTag();
+ for (UUID childUuid : this.childUuids) {
+ if (childUuid == null) {
+ continue;
+ }
+ CompoundTag childTag = new CompoundTag();
+ childTag.putUUID("ChildUuid", childUuid);
+ children.add(childTag);
+ }
+ tag.put("ChildUuids", children);
+ tag.putLong("Version", this.version);
+ tag.putLong("LastUpdatedGameTime", this.lastUpdatedGameTime);
+ return tag;
+ }
+
+ public static NpcFamilyRecord fromTag(CompoundTag tag) {
+ List children = new ArrayList<>();
+ for (Tag entry : tag.getList("ChildUuids", Tag.TAG_COMPOUND)) {
+ CompoundTag childTag = (CompoundTag) entry;
+ if (childTag.contains("ChildUuid")) {
+ children.add(childTag.getUUID("ChildUuid"));
+ }
+ }
+ return new NpcFamilyRecord(
+ tag.getUUID("ResidentUuid"),
+ tag.contains("HouseholdId") ? tag.getUUID("HouseholdId") : tag.getUUID("ResidentUuid"),
+ tag.contains("SpouseUuid") ? tag.getUUID("SpouseUuid") : null,
+ tag.contains("MotherUuid") ? tag.getUUID("MotherUuid") : null,
+ tag.contains("FatherUuid") ? tag.getUUID("FatherUuid") : null,
+ children,
+ Math.max(1L, tag.getLong("Version")),
+ tag.getLong("LastUpdatedGameTime")
+ );
+ }
+
+ private static List copyChildren(@Nullable Collection children) {
+ return children == null ? List.of() : new ArrayList<>(children);
+ }
+
+ private static List sanitizeChildren(@Nullable Collection children) {
+ if (children == null || children.isEmpty()) {
+ return List.of();
+ }
+ Set ordered = new LinkedHashSet<>();
+ for (UUID child : children) {
+ if (child != null) {
+ ordered.add(child);
+ }
+ }
+ return List.copyOf(ordered);
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRuntime.java
new file mode 100644
index 00000000..2b3a868a
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRuntime.java
@@ -0,0 +1,126 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+public final class NpcFamilyRuntime {
+ private final Map familyByResident = new LinkedHashMap<>();
+ private Runnable dirtyListener = () -> {
+ };
+
+ public void setDirtyListener(Runnable dirtyListener) {
+ this.dirtyListener = dirtyListener == null ? () -> {
+ } : dirtyListener;
+ }
+
+ public Optional familyFor(UUID residentUuid) {
+ if (residentUuid == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(this.familyByResident.get(residentUuid));
+ }
+
+ public void replaceHousehold(UUID householdId, Collection records) {
+ boolean changed = false;
+ if (householdId == null) {
+ return;
+ }
+ List toRemove = new ArrayList<>();
+ for (NpcFamilyRecord record : this.familyByResident.values()) {
+ if (record != null && householdId.equals(record.householdId())) {
+ toRemove.add(record.residentUuid());
+ }
+ }
+ for (UUID residentUuid : toRemove) {
+ if (this.familyByResident.remove(residentUuid) != null) {
+ changed = true;
+ }
+ }
+ if (records != null) {
+ for (NpcFamilyRecord record : records) {
+ if (record != null) {
+ NpcFamilyRecord previous = this.familyByResident.put(record.residentUuid(), record);
+ if (!record.equals(previous)) {
+ changed = true;
+ }
+ }
+ }
+ }
+ if (changed) {
+ markDirty();
+ }
+ }
+
+ public void moveResident(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) {
+ if (fromResidentUuid == null || toResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) {
+ return;
+ }
+ boolean changed = false;
+ NpcFamilyRecord ownRecord = this.familyByResident.remove(fromResidentUuid);
+ if (ownRecord != null) {
+ this.familyByResident.put(toResidentUuid, ownRecord.moveResident(toResidentUuid, gameTime));
+ changed = true;
+ }
+ List updated = new ArrayList<>();
+ for (NpcFamilyRecord record : this.familyByResident.values()) {
+ updated.add(record == null ? null : record.replaceReference(fromResidentUuid, toResidentUuid, gameTime));
+ }
+ if (!updated.isEmpty()) {
+ this.familyByResident.clear();
+ for (NpcFamilyRecord record : updated) {
+ if (record != null) {
+ this.familyByResident.put(record.residentUuid(), record);
+ }
+ }
+ changed = true;
+ }
+ if (changed) {
+ markDirty();
+ }
+ }
+
+ public CompoundTag toTag() {
+ CompoundTag tag = new CompoundTag();
+ ListTag families = new ListTag();
+ for (NpcFamilyRecord record : this.familyByResident.values()) {
+ families.add(record.toTag());
+ }
+ tag.put("Families", families);
+ return tag;
+ }
+
+ public static NpcFamilyRuntime fromTag(CompoundTag tag) {
+ NpcFamilyRuntime runtime = new NpcFamilyRuntime();
+ List records = new ArrayList<>();
+ for (Tag entry : tag.getList("Families", Tag.TAG_COMPOUND)) {
+ records.add(NpcFamilyRecord.fromTag((CompoundTag) entry));
+ }
+ runtime.restoreSnapshot(records);
+ return runtime;
+ }
+
+ public void restoreSnapshot(@Nullable Collection records) {
+ this.familyByResident.clear();
+ if (records != null) {
+ for (NpcFamilyRecord record : records) {
+ if (record != null) {
+ this.familyByResident.put(record.residentUuid(), record);
+ }
+ }
+ }
+ }
+
+ private void markDirty() {
+ this.dirtyListener.run();
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilySavedData.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilySavedData.java
new file mode 100644
index 00000000..743715d9
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilySavedData.java
@@ -0,0 +1,42 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.saveddata.SavedData;
+
+public class NpcFamilySavedData extends SavedData {
+ private static final String FILE_ID = "bannermodNpcFamily";
+ private static final SavedData.Factory FACTORY =
+ new SavedData.Factory<>(NpcFamilySavedData::new, NpcFamilySavedData::load);
+
+ private final NpcFamilyRuntime runtime;
+
+ public NpcFamilySavedData() {
+ this(new NpcFamilyRuntime());
+ }
+
+ private NpcFamilySavedData(NpcFamilyRuntime runtime) {
+ this.runtime = runtime;
+ this.runtime.setDirtyListener(this::setDirty);
+ }
+
+ public static NpcFamilySavedData get(ServerLevel level) {
+ return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID);
+ }
+
+ public static NpcFamilySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
+ return new NpcFamilySavedData(NpcFamilyRuntime.fromTag(tag));
+ }
+
+ @Override
+ public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
+ CompoundTag runtimeTag = this.runtime.toTag();
+ tag.put("Families", runtimeTag.getList("Families", 10));
+ return tag;
+ }
+
+ public NpcFamilyRuntime runtime() {
+ return this.runtime;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyTreeSnapshot.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyTreeSnapshot.java
new file mode 100644
index 00000000..5702f714
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyTreeSnapshot.java
@@ -0,0 +1,60 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+
+public record NpcFamilyTreeSnapshot(
+ NpcFamilyMemberSnapshot self,
+ @Nullable NpcFamilyMemberSnapshot spouse,
+ @Nullable NpcFamilyMemberSnapshot mother,
+ @Nullable NpcFamilyMemberSnapshot father,
+ List children
+) {
+ public static NpcFamilyTreeSnapshot empty() {
+ return new NpcFamilyTreeSnapshot(
+ new NpcFamilyMemberSnapshot(new java.util.UUID(0L, 0L), -1, "-", NpcLifeStage.UNSPECIFIED.name(), NpcSex.UNSPECIFIED.name(), "self"),
+ null,
+ null,
+ null,
+ List.of()
+ );
+ }
+
+ public void toBytes(FriendlyByteBuf buf) {
+ (this.self == null ? empty().self() : this.self).toBytes(buf);
+ buf.writeBoolean(this.spouse != null);
+ if (this.spouse != null) {
+ this.spouse.toBytes(buf);
+ }
+ buf.writeBoolean(this.mother != null);
+ if (this.mother != null) {
+ this.mother.toBytes(buf);
+ }
+ buf.writeBoolean(this.father != null);
+ if (this.father != null) {
+ this.father.toBytes(buf);
+ }
+ buf.writeVarInt(this.children == null ? 0 : this.children.size());
+ if (this.children != null) {
+ for (NpcFamilyMemberSnapshot child : this.children) {
+ child.toBytes(buf);
+ }
+ }
+ }
+
+ public static NpcFamilyTreeSnapshot fromBytes(FriendlyByteBuf buf) {
+ NpcFamilyMemberSnapshot self = NpcFamilyMemberSnapshot.fromBytes(buf);
+ NpcFamilyMemberSnapshot spouse = buf.readBoolean() ? NpcFamilyMemberSnapshot.fromBytes(buf) : null;
+ NpcFamilyMemberSnapshot mother = buf.readBoolean() ? NpcFamilyMemberSnapshot.fromBytes(buf) : null;
+ NpcFamilyMemberSnapshot father = buf.readBoolean() ? NpcFamilyMemberSnapshot.fromBytes(buf) : null;
+ int childCount = buf.readVarInt();
+ List children = new ArrayList<>();
+ for (int i = 0; i < childCount; i++) {
+ children.add(NpcFamilyMemberSnapshot.fromBytes(buf));
+ }
+ return new NpcFamilyTreeSnapshot(self, spouse, mother, father, List.copyOf(children));
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdAccess.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdAccess.java
new file mode 100644
index 00000000..be1bcdd1
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdAccess.java
@@ -0,0 +1,59 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.server.level.ServerLevel;
+
+import javax.annotation.Nullable;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.UUID;
+
+public final class NpcHouseholdAccess {
+ private NpcHouseholdAccess() {
+ }
+
+ public static @Nullable UUID reconcileResidentHome(ServerLevel level,
+ UUID residentUuid,
+ @Nullable UUID homeBuildingUuid,
+ int residentCapacity,
+ long gameTime) {
+ return NpcHouseholdSavedData.get(level).runtime().reconcileResidentHome(residentUuid, homeBuildingUuid, residentCapacity, gameTime);
+ }
+
+ public static void clearResident(ServerLevel level, UUID residentUuid, long gameTime) {
+ NpcHouseholdSavedData.get(level).runtime().clearResident(residentUuid, gameTime);
+ }
+
+ public static void moveResident(ServerLevel level,
+ UUID fromResidentUuid,
+ UUID toResidentUuid,
+ long gameTime) {
+ NpcHouseholdSavedData.get(level).runtime().moveResident(fromResidentUuid, toResidentUuid, gameTime);
+ }
+
+ public static void updateHead(ServerLevel level,
+ UUID householdId,
+ @Nullable UUID headResidentUuid,
+ long gameTime) {
+ NpcHouseholdSavedData.get(level).runtime().updateHead(householdId, headResidentUuid, gameTime);
+ }
+
+ public static void seedHousehold(ServerLevel level,
+ UUID householdId,
+ @Nullable UUID headResidentUuid,
+ Collection members,
+ long gameTime) {
+ NpcHouseholdSavedData.get(level).runtime().seedHousehold(householdId, headResidentUuid, members, gameTime);
+ }
+
+ public static Optional householdForResident(ServerLevel level, UUID residentUuid) {
+ return NpcHouseholdSavedData.get(level).runtime().householdForResident(residentUuid);
+ }
+
+ public static Optional householdFor(ServerLevel level, UUID householdId) {
+ return NpcHouseholdSavedData.get(level).runtime().householdFor(householdId);
+ }
+
+ public static Optional householdForHome(ServerLevel level, UUID homeBuildingUuid) {
+ return NpcHouseholdSavedData.get(level).runtime().householdForHome(homeBuildingUuid);
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdHousingState.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdHousingState.java
new file mode 100644
index 00000000..9d5c1f13
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdHousingState.java
@@ -0,0 +1,18 @@
+package com.talhanation.bannermod.society;
+
+public enum NpcHouseholdHousingState {
+ NORMAL,
+ HOMELESS,
+ OVERCROWDED;
+
+ public static NpcHouseholdHousingState fromName(String name) {
+ if (name == null || name.isBlank()) {
+ return HOMELESS;
+ }
+ try {
+ return NpcHouseholdHousingState.valueOf(name);
+ } catch (IllegalArgumentException ignored) {
+ return HOMELESS;
+ }
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRecord.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRecord.java
new file mode 100644
index 00000000..3869b1b2
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRecord.java
@@ -0,0 +1,248 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+public record NpcHouseholdRecord(
+ UUID householdId,
+ @Nullable UUID homeBuildingUuid,
+ @Nullable UUID headResidentUuid,
+ List memberResidentUuids,
+ int residentCapacity,
+ NpcHouseholdHousingState housingState,
+ long version,
+ long lastUpdatedGameTime
+) {
+ public NpcHouseholdRecord {
+ if (householdId == null) {
+ throw new IllegalArgumentException("householdId must not be null");
+ }
+ memberResidentUuids = sanitizeMembers(memberResidentUuids);
+ residentCapacity = Math.max(0, residentCapacity);
+ housingState = housingState == null ? NpcHouseholdHousingState.HOMELESS : housingState;
+ version = Math.max(1L, version);
+ }
+
+ public static NpcHouseholdRecord create(UUID householdId,
+ @Nullable UUID homeBuildingUuid,
+ @Nullable UUID headResidentUuid,
+ @Nullable Collection memberResidentUuids,
+ int residentCapacity,
+ NpcHouseholdHousingState housingState,
+ long gameTime) {
+ return new NpcHouseholdRecord(
+ householdId,
+ homeBuildingUuid,
+ headResidentUuid,
+ copyMembers(memberResidentUuids),
+ residentCapacity,
+ housingState,
+ 1L,
+ gameTime
+ );
+ }
+
+ public boolean hasMember(UUID residentUuid) {
+ return residentUuid != null && this.memberResidentUuids.contains(residentUuid);
+ }
+
+ public boolean isEmpty() {
+ return this.memberResidentUuids.isEmpty();
+ }
+
+ public NpcHouseholdRecord withHome(@Nullable UUID homeBuildingUuid, long gameTime) {
+ if (sameNullableUuid(this.homeBuildingUuid, homeBuildingUuid)) {
+ return this;
+ }
+ return new NpcHouseholdRecord(
+ this.householdId,
+ homeBuildingUuid,
+ this.headResidentUuid,
+ this.memberResidentUuids,
+ this.residentCapacity,
+ this.housingState,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public NpcHouseholdRecord withHousing(@Nullable UUID homeBuildingUuid,
+ int residentCapacity,
+ NpcHouseholdHousingState housingState,
+ long gameTime) {
+ int normalizedCapacity = Math.max(0, residentCapacity);
+ NpcHouseholdHousingState normalizedState = housingState == null ? NpcHouseholdHousingState.HOMELESS : housingState;
+ if (sameNullableUuid(this.homeBuildingUuid, homeBuildingUuid)
+ && this.residentCapacity == normalizedCapacity
+ && this.housingState == normalizedState) {
+ return this;
+ }
+ return new NpcHouseholdRecord(
+ this.householdId,
+ homeBuildingUuid,
+ this.headResidentUuid,
+ this.memberResidentUuids,
+ normalizedCapacity,
+ normalizedState,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public NpcHouseholdRecord withHead(@Nullable UUID headResidentUuid, long gameTime) {
+ if (sameNullableUuid(this.headResidentUuid, headResidentUuid)) {
+ return this;
+ }
+ return new NpcHouseholdRecord(
+ this.householdId,
+ this.homeBuildingUuid,
+ headResidentUuid,
+ this.memberResidentUuids,
+ this.residentCapacity,
+ this.housingState,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public NpcHouseholdRecord addMember(UUID residentUuid, long gameTime) {
+ if (residentUuid == null || this.memberResidentUuids.contains(residentUuid)) {
+ return this;
+ }
+ List updatedMembers = new ArrayList<>(this.memberResidentUuids);
+ updatedMembers.add(residentUuid);
+ return new NpcHouseholdRecord(
+ this.householdId,
+ this.homeBuildingUuid,
+ this.headResidentUuid,
+ updatedMembers,
+ this.residentCapacity,
+ this.housingState,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public NpcHouseholdRecord removeMember(UUID residentUuid, long gameTime) {
+ if (residentUuid == null || !this.memberResidentUuids.contains(residentUuid)) {
+ return this;
+ }
+ List updatedMembers = new ArrayList<>(this.memberResidentUuids);
+ updatedMembers.remove(residentUuid);
+ return new NpcHouseholdRecord(
+ this.householdId,
+ this.homeBuildingUuid,
+ residentUuid.equals(this.headResidentUuid) ? null : this.headResidentUuid,
+ updatedMembers,
+ this.residentCapacity,
+ this.housingState,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public NpcHouseholdRecord moveMember(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) {
+ if (fromResidentUuid == null || toResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) {
+ return this;
+ }
+ if (!this.memberResidentUuids.contains(fromResidentUuid)) {
+ return this;
+ }
+ List updatedMembers = new ArrayList<>(this.memberResidentUuids);
+ updatedMembers.remove(fromResidentUuid);
+ if (!updatedMembers.contains(toResidentUuid)) {
+ updatedMembers.add(toResidentUuid);
+ }
+ return new NpcHouseholdRecord(
+ this.householdId,
+ this.homeBuildingUuid,
+ fromResidentUuid.equals(this.headResidentUuid) ? toResidentUuid : this.headResidentUuid,
+ updatedMembers,
+ this.residentCapacity,
+ this.housingState,
+ this.version + 1L,
+ gameTime
+ );
+ }
+
+ public CompoundTag toTag() {
+ CompoundTag tag = new CompoundTag();
+ tag.putUUID("HouseholdId", this.householdId);
+ if (this.homeBuildingUuid != null) {
+ tag.putUUID("HomeBuildingUuid", this.homeBuildingUuid);
+ }
+ if (this.headResidentUuid != null) {
+ tag.putUUID("HeadResidentUuid", this.headResidentUuid);
+ }
+ ListTag members = new ListTag();
+ for (UUID member : this.memberResidentUuids) {
+ if (member == null) {
+ continue;
+ }
+ CompoundTag memberTag = new CompoundTag();
+ memberTag.putUUID("ResidentUuid", member);
+ members.add(memberTag);
+ }
+ tag.put("Members", members);
+ tag.putInt("ResidentCapacity", this.residentCapacity);
+ tag.putString("HousingState", this.housingState.name());
+ tag.putLong("Version", this.version);
+ tag.putLong("LastUpdatedGameTime", this.lastUpdatedGameTime);
+ return tag;
+ }
+
+ public static NpcHouseholdRecord fromTag(CompoundTag tag) {
+ List members = new ArrayList<>();
+ for (Tag entry : tag.getList("Members", Tag.TAG_COMPOUND)) {
+ CompoundTag memberTag = (CompoundTag) entry;
+ if (memberTag.contains("ResidentUuid")) {
+ members.add(memberTag.getUUID("ResidentUuid"));
+ }
+ }
+ return new NpcHouseholdRecord(
+ tag.getUUID("HouseholdId"),
+ tag.contains("HomeBuildingUuid") ? tag.getUUID("HomeBuildingUuid") : null,
+ tag.contains("HeadResidentUuid") ? tag.getUUID("HeadResidentUuid") : null,
+ members,
+ tag.contains("ResidentCapacity") ? tag.getInt("ResidentCapacity") : 0,
+ tag.contains("HousingState")
+ ? NpcHouseholdHousingState.fromName(tag.getString("HousingState"))
+ : (tag.contains("HomeBuildingUuid") ? NpcHouseholdHousingState.NORMAL : NpcHouseholdHousingState.HOMELESS),
+ Math.max(1L, tag.getLong("Version")),
+ tag.getLong("LastUpdatedGameTime")
+ );
+ }
+
+ private static boolean sameNullableUuid(@Nullable UUID left, @Nullable UUID right) {
+ if (left == null || right == null) {
+ return left == null && right == null;
+ }
+ return left.equals(right);
+ }
+
+ private static List copyMembers(@Nullable Collection members) {
+ return members == null ? List.of() : new ArrayList<>(members);
+ }
+
+ private static List sanitizeMembers(@Nullable Collection members) {
+ if (members == null || members.isEmpty()) {
+ return List.of();
+ }
+ Set ordered = new LinkedHashSet<>();
+ for (UUID member : members) {
+ if (member != null) {
+ ordered.add(member);
+ }
+ }
+ return List.copyOf(ordered);
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRuntime.java
new file mode 100644
index 00000000..00839b1f
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRuntime.java
@@ -0,0 +1,381 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+public final class NpcHouseholdRuntime {
+ private final Map householdsById = new LinkedHashMap<>();
+ private final Map householdByResident = new LinkedHashMap<>();
+ private final Map householdByHomeBuilding = new LinkedHashMap<>();
+ private Runnable dirtyListener = () -> {
+ };
+
+ public void setDirtyListener(Runnable dirtyListener) {
+ this.dirtyListener = dirtyListener == null ? () -> {
+ } : dirtyListener;
+ }
+
+ public Optional householdFor(UUID householdId) {
+ if (householdId == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(this.householdsById.get(householdId));
+ }
+
+ public Optional householdForResident(UUID residentUuid) {
+ if (residentUuid == null) {
+ return Optional.empty();
+ }
+ return householdFor(this.householdByResident.get(residentUuid));
+ }
+
+ public Optional householdForHome(UUID homeBuildingUuid) {
+ if (homeBuildingUuid == null) {
+ return Optional.empty();
+ }
+ return householdFor(this.householdByHomeBuilding.get(homeBuildingUuid));
+ }
+
+ public void updateHead(UUID householdId, @Nullable UUID headResidentUuid, long gameTime) {
+ if (householdId == null) {
+ return;
+ }
+ NpcHouseholdRecord household = this.householdsById.get(householdId);
+ if (household == null) {
+ return;
+ }
+ NpcHouseholdRecord updated = household.withHead(headResidentUuid, gameTime);
+ if (!updated.equals(household)) {
+ this.householdsById.put(householdId, updated);
+ markDirty();
+ }
+ }
+
+ public void seedHousehold(UUID householdId,
+ @Nullable UUID headResidentUuid,
+ Collection members,
+ long gameTime) {
+ if (householdId == null) {
+ throw new IllegalArgumentException("householdId must not be null");
+ }
+ List orderedMembers = new ArrayList<>();
+ if (members != null) {
+ for (UUID member : members) {
+ if (member == null) {
+ continue;
+ }
+ clearResidentInternal(member, gameTime);
+ if (!orderedMembers.contains(member)) {
+ orderedMembers.add(member);
+ }
+ }
+ }
+ NpcHouseholdRecord record = NpcHouseholdRecord.create(
+ householdId,
+ null,
+ headResidentUuid,
+ orderedMembers,
+ 0,
+ orderedMembers.isEmpty() ? NpcHouseholdHousingState.HOMELESS : NpcHouseholdHousingState.HOMELESS,
+ gameTime
+ );
+ this.householdsById.put(householdId, record);
+ for (UUID member : orderedMembers) {
+ this.householdByResident.put(member, householdId);
+ }
+ markDirty();
+ }
+
+ public @Nullable UUID reconcileResidentHome(UUID residentUuid,
+ @Nullable UUID homeBuildingUuid,
+ int residentCapacity,
+ long gameTime) {
+ if (residentUuid == null) {
+ throw new IllegalArgumentException("residentUuid must not be null");
+ }
+ UUID currentHouseholdId = this.householdByResident.get(residentUuid);
+ NpcHouseholdRecord currentHousehold = currentHouseholdId == null ? null : this.householdsById.get(currentHouseholdId);
+ if (homeBuildingUuid == null) {
+ return reconcileHomelessResident(residentUuid, currentHousehold, gameTime);
+ }
+
+ int normalizedCapacity = Math.max(1, residentCapacity);
+ boolean changed = false;
+ NpcHouseholdRecord targetHousehold = householdForHome(homeBuildingUuid).orElse(null);
+ if (targetHousehold == null) {
+ if (currentHousehold != null
+ && currentHousehold.homeBuildingUuid() == null
+ && currentHousehold.memberResidentUuids().size() == 1
+ && currentHousehold.hasMember(residentUuid)) {
+ targetHousehold = currentHousehold;
+ } else {
+ targetHousehold = NpcHouseholdRecord.create(
+ UUID.randomUUID(),
+ homeBuildingUuid,
+ null,
+ List.of(),
+ normalizedCapacity,
+ NpcHouseholdHousingState.NORMAL,
+ gameTime
+ );
+ }
+ this.householdsById.put(targetHousehold.householdId(), targetHousehold);
+ changed = true;
+ }
+
+ if (currentHouseholdId != null && !currentHouseholdId.equals(targetHousehold.householdId())) {
+ changed |= clearResidentInternal(residentUuid, gameTime);
+ }
+
+ NpcHouseholdRecord stored = this.householdsById.get(targetHousehold.householdId());
+ if (stored == null) {
+ stored = targetHousehold;
+ this.householdsById.put(stored.householdId(), stored);
+ changed = true;
+ }
+
+ NpcHouseholdRecord updated = stored.addMember(residentUuid, gameTime);
+ updated = applyHousing(updated, homeBuildingUuid, normalizedCapacity, gameTime);
+ if (!updated.equals(stored)) {
+ this.householdsById.put(updated.householdId(), updated);
+ changed = true;
+ }
+
+ UUID previousResidentHousehold = this.householdByResident.put(residentUuid, updated.householdId());
+ if (!updated.householdId().equals(previousResidentHousehold)) {
+ changed = true;
+ }
+ UUID previousHomeHousehold = this.householdByHomeBuilding.put(homeBuildingUuid, updated.householdId());
+ if (!updated.householdId().equals(previousHomeHousehold)) {
+ changed = true;
+ }
+
+ if (changed) {
+ markDirty();
+ }
+ return updated.householdId();
+ }
+
+ public void clearResident(UUID residentUuid, long gameTime) {
+ if (residentUuid == null) {
+ return;
+ }
+ if (clearResidentInternal(residentUuid, gameTime)) {
+ markDirty();
+ }
+ }
+
+ public void moveResident(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) {
+ if (toResidentUuid == null) {
+ throw new IllegalArgumentException("toResidentUuid must not be null");
+ }
+ if (fromResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) {
+ return;
+ }
+ UUID fromHouseholdId = this.householdByResident.get(fromResidentUuid);
+ if (fromHouseholdId == null) {
+ clearResident(toResidentUuid, gameTime);
+ return;
+ }
+
+ boolean changed = false;
+ UUID toHouseholdId = this.householdByResident.get(toResidentUuid);
+ if (toHouseholdId != null && !toHouseholdId.equals(fromHouseholdId)) {
+ changed |= clearResidentInternal(toResidentUuid, gameTime);
+ }
+
+ NpcHouseholdRecord household = this.householdsById.get(fromHouseholdId);
+ if (household != null) {
+ NpcHouseholdRecord updated = household.moveMember(fromResidentUuid, toResidentUuid, gameTime);
+ updated = applyHousing(updated, updated.homeBuildingUuid(), updated.residentCapacity(), gameTime);
+ if (!updated.equals(household)) {
+ this.householdsById.put(updated.householdId(), updated);
+ changed = true;
+ }
+ }
+
+ if (this.householdByResident.remove(fromResidentUuid) != null) {
+ changed = true;
+ }
+ UUID previous = this.householdByResident.put(toResidentUuid, fromHouseholdId);
+ if (!fromHouseholdId.equals(previous)) {
+ changed = true;
+ }
+
+ if (changed) {
+ markDirty();
+ }
+ }
+
+ public List snapshot() {
+ return Collections.unmodifiableList(new ArrayList<>(this.householdsById.values()));
+ }
+
+ public CompoundTag toTag() {
+ CompoundTag tag = new CompoundTag();
+ ListTag households = new ListTag();
+ for (NpcHouseholdRecord household : snapshot()) {
+ households.add(household.toTag());
+ }
+ tag.put("Households", households);
+ return tag;
+ }
+
+ public static NpcHouseholdRuntime fromTag(CompoundTag tag) {
+ NpcHouseholdRuntime runtime = new NpcHouseholdRuntime();
+ List households = new ArrayList<>();
+ for (Tag entry : tag.getList("Households", Tag.TAG_COMPOUND)) {
+ households.add(NpcHouseholdRecord.fromTag((CompoundTag) entry));
+ }
+ runtime.restoreSnapshot(households);
+ return runtime;
+ }
+
+ public void restoreSnapshot(@Nullable Collection households) {
+ List before = snapshot();
+ this.householdsById.clear();
+ this.householdByResident.clear();
+ this.householdByHomeBuilding.clear();
+ if (households != null) {
+ for (NpcHouseholdRecord household : households) {
+ if (household == null || household.householdId() == null) {
+ continue;
+ }
+ this.householdsById.put(household.householdId(), household);
+ if (household.homeBuildingUuid() != null) {
+ this.householdByHomeBuilding.put(household.homeBuildingUuid(), household.householdId());
+ }
+ for (UUID member : household.memberResidentUuids()) {
+ if (member != null) {
+ this.householdByResident.put(member, household.householdId());
+ }
+ }
+ }
+ }
+ if (!before.equals(snapshot())) {
+ markDirty();
+ }
+ }
+
+ public void reset() {
+ if (this.householdsById.isEmpty() && this.householdByResident.isEmpty() && this.householdByHomeBuilding.isEmpty()) {
+ return;
+ }
+ this.householdsById.clear();
+ this.householdByResident.clear();
+ this.householdByHomeBuilding.clear();
+ markDirty();
+ }
+
+ private UUID reconcileHomelessResident(UUID residentUuid,
+ @Nullable NpcHouseholdRecord currentHousehold,
+ long gameTime) {
+ boolean changed = false;
+ NpcHouseholdRecord household = currentHousehold;
+ if (household == null) {
+ household = NpcHouseholdRecord.create(
+ UUID.randomUUID(),
+ null,
+ residentUuid,
+ List.of(residentUuid),
+ 0,
+ NpcHouseholdHousingState.HOMELESS,
+ gameTime
+ );
+ this.householdsById.put(household.householdId(), household);
+ this.householdByResident.put(residentUuid, household.householdId());
+ markDirty();
+ return household.householdId();
+ }
+
+ if (household.homeBuildingUuid() != null) {
+ changed |= clearResidentInternal(residentUuid, gameTime);
+ household = NpcHouseholdRecord.create(
+ UUID.randomUUID(),
+ null,
+ residentUuid,
+ List.of(residentUuid),
+ 0,
+ NpcHouseholdHousingState.HOMELESS,
+ gameTime
+ );
+ this.householdsById.put(household.householdId(), household);
+ changed = true;
+ } else {
+ NpcHouseholdRecord updated = applyHousing(household.addMember(residentUuid, gameTime), null, 0, gameTime);
+ if (!updated.equals(household)) {
+ this.householdsById.put(updated.householdId(), updated);
+ household = updated;
+ changed = true;
+ }
+ }
+
+ UUID previous = this.householdByResident.put(residentUuid, household.householdId());
+ if (!household.householdId().equals(previous)) {
+ changed = true;
+ }
+ if (changed) {
+ markDirty();
+ }
+ return household.householdId();
+ }
+
+ private boolean clearResidentInternal(UUID residentUuid, long gameTime) {
+ UUID householdId = this.householdByResident.remove(residentUuid);
+ if (householdId == null) {
+ return false;
+ }
+ NpcHouseholdRecord household = this.householdsById.get(householdId);
+ if (household == null) {
+ return true;
+ }
+ NpcHouseholdRecord updated = household.removeMember(residentUuid, gameTime);
+ if (updated.isEmpty()) {
+ this.householdsById.remove(householdId);
+ if (household.homeBuildingUuid() != null && householdId.equals(this.householdByHomeBuilding.get(household.homeBuildingUuid()))) {
+ this.householdByHomeBuilding.remove(household.homeBuildingUuid());
+ }
+ } else {
+ updated = applyHousing(updated, updated.homeBuildingUuid(), updated.residentCapacity(), gameTime);
+ if (!updated.equals(household)) {
+ this.householdsById.put(householdId, updated);
+ }
+ }
+ return true;
+ }
+
+ private NpcHouseholdRecord applyHousing(NpcHouseholdRecord household,
+ @Nullable UUID homeBuildingUuid,
+ int residentCapacity,
+ long gameTime) {
+ int normalizedCapacity = homeBuildingUuid == null ? 0 : Math.max(1, residentCapacity);
+ NpcHouseholdHousingState housingState = resolveHousingState(homeBuildingUuid, normalizedCapacity, household.memberResidentUuids().size());
+ return household.withHousing(homeBuildingUuid, normalizedCapacity, housingState, gameTime);
+ }
+
+ private static NpcHouseholdHousingState resolveHousingState(@Nullable UUID homeBuildingUuid,
+ int residentCapacity,
+ int memberCount) {
+ if (homeBuildingUuid == null) {
+ return NpcHouseholdHousingState.HOMELESS;
+ }
+ return memberCount > Math.max(1, residentCapacity)
+ ? NpcHouseholdHousingState.OVERCROWDED
+ : NpcHouseholdHousingState.NORMAL;
+ }
+
+ private void markDirty() {
+ this.dirtyListener.run();
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdSavedData.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdSavedData.java
new file mode 100644
index 00000000..2e811fa7
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdSavedData.java
@@ -0,0 +1,42 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.saveddata.SavedData;
+
+public class NpcHouseholdSavedData extends SavedData {
+ private static final String FILE_ID = "bannermodNpcHouseholds";
+ private static final SavedData.Factory FACTORY =
+ new SavedData.Factory<>(NpcHouseholdSavedData::new, NpcHouseholdSavedData::load);
+
+ private final NpcHouseholdRuntime runtime;
+
+ public NpcHouseholdSavedData() {
+ this(new NpcHouseholdRuntime());
+ }
+
+ private NpcHouseholdSavedData(NpcHouseholdRuntime runtime) {
+ this.runtime = runtime;
+ this.runtime.setDirtyListener(this::setDirty);
+ }
+
+ public static NpcHouseholdSavedData get(ServerLevel level) {
+ return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID);
+ }
+
+ public static NpcHouseholdSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
+ return new NpcHouseholdSavedData(NpcHouseholdRuntime.fromTag(tag));
+ }
+
+ @Override
+ public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
+ CompoundTag runtimeTag = this.runtime.toTag();
+ tag.put("Households", runtimeTag.getList("Households", 10));
+ return tag;
+ }
+
+ public NpcHouseholdRuntime runtime() {
+ return this.runtime;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingLedgerEntry.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingLedgerEntry.java
new file mode 100644
index 00000000..c1db4080
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingLedgerEntry.java
@@ -0,0 +1,142 @@
+package com.talhanation.bannermod.society;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+
+import javax.annotation.Nullable;
+import java.util.Locale;
+import java.util.UUID;
+
+public record NpcHousingLedgerEntry(
+ UUID householdId,
+ UUID residentUuid,
+ UUID claimUuid,
+ @Nullable UUID headResidentUuid,
+ @Nullable UUID homeBuildingUuid,
+ @Nullable UUID buildAreaUuid,
+ @Nullable BlockPos reservedPlotPos,
+ String statusTag,
+ String housingStateTag,
+ String urgencyTag,
+ String reasonTag,
+ int householdSize,
+ int waitingDays,
+ int priorityScore,
+ int queueRank,
+ long requestedAtGameTime,
+ long updatedAtGameTime
+) {
+ public NpcHousingLedgerEntry {
+ if (householdId == null) {
+ throw new IllegalArgumentException("householdId must not be null");
+ }
+ if (residentUuid == null) {
+ throw new IllegalArgumentException("residentUuid must not be null");
+ }
+ if (claimUuid == null) {
+ throw new IllegalArgumentException("claimUuid must not be null");
+ }
+ statusTag = safeTag(statusTag);
+ housingStateTag = safeTag(housingStateTag);
+ urgencyTag = safeTag(urgencyTag);
+ reasonTag = safeTag(reasonTag);
+ householdSize = Math.max(0, householdSize);
+ waitingDays = Math.max(0, waitingDays);
+ priorityScore = Math.max(0, priorityScore);
+ queueRank = Math.max(0, queueRank);
+ }
+
+ public NpcHousingLedgerEntry withQueueRank(int queueRank) {
+ return new NpcHousingLedgerEntry(
+ this.householdId,
+ this.residentUuid,
+ this.claimUuid,
+ this.headResidentUuid,
+ this.homeBuildingUuid,
+ this.buildAreaUuid,
+ this.reservedPlotPos,
+ this.statusTag,
+ this.housingStateTag,
+ this.urgencyTag,
+ this.reasonTag,
+ this.householdSize,
+ this.waitingDays,
+ this.priorityScore,
+ queueRank,
+ this.requestedAtGameTime,
+ this.updatedAtGameTime
+ );
+ }
+
+ public String statusTranslationKey() {
+ return "gui.bannermod.society.housing_request." + safeTag(this.statusTag).toLowerCase(Locale.ROOT);
+ }
+
+ public String housingStateTranslationKey() {
+ return "gui.bannermod.society.household_housing." + safeTag(this.housingStateTag).toLowerCase(Locale.ROOT);
+ }
+
+ public String urgencyTranslationKey() {
+ return "gui.bannermod.housing_ledger.urgency." + safeTag(this.urgencyTag).toLowerCase(Locale.ROOT);
+ }
+
+ public String reasonTranslationKey() {
+ return "gui.bannermod.housing_ledger.reason." + safeTag(this.reasonTag).toLowerCase(Locale.ROOT);
+ }
+
+ public CompoundTag toTag() {
+ CompoundTag tag = new CompoundTag();
+ tag.putUUID("HouseholdId", this.householdId);
+ tag.putUUID("ResidentUuid", this.residentUuid);
+ tag.putUUID("ClaimUuid", this.claimUuid);
+ if (this.headResidentUuid != null) {
+ tag.putUUID("HeadResidentUuid", this.headResidentUuid);
+ }
+ if (this.homeBuildingUuid != null) {
+ tag.putUUID("HomeBuildingUuid", this.homeBuildingUuid);
+ }
+ if (this.buildAreaUuid != null) {
+ tag.putUUID("BuildAreaUuid", this.buildAreaUuid);
+ }
+ if (this.reservedPlotPos != null) {
+ tag.putLong("ReservedPlotPos", this.reservedPlotPos.asLong());
+ }
+ tag.putString("Status", this.statusTag);
+ tag.putString("HousingState", this.housingStateTag);
+ tag.putString("Urgency", this.urgencyTag);
+ tag.putString("Reason", this.reasonTag);
+ tag.putInt("HouseholdSize", this.householdSize);
+ tag.putInt("WaitingDays", this.waitingDays);
+ tag.putInt("PriorityScore", this.priorityScore);
+ tag.putInt("QueueRank", this.queueRank);
+ tag.putLong("RequestedAt", this.requestedAtGameTime);
+ tag.putLong("UpdatedAt", this.updatedAtGameTime);
+ return tag;
+ }
+
+ public static NpcHousingLedgerEntry fromTag(CompoundTag tag) {
+ return new NpcHousingLedgerEntry(
+ tag.getUUID("HouseholdId"),
+ tag.getUUID("ResidentUuid"),
+ tag.getUUID("ClaimUuid"),
+ tag.contains("HeadResidentUuid") ? tag.getUUID("HeadResidentUuid") : null,
+ tag.contains("HomeBuildingUuid") ? tag.getUUID("HomeBuildingUuid") : null,
+ tag.contains("BuildAreaUuid") ? tag.getUUID("BuildAreaUuid") : null,
+ tag.contains("ReservedPlotPos") ? BlockPos.of(tag.getLong("ReservedPlotPos")) : null,
+ tag.getString("Status"),
+ tag.getString("HousingState"),
+ tag.getString("Urgency"),
+ tag.getString("Reason"),
+ tag.getInt("HouseholdSize"),
+ tag.getInt("WaitingDays"),
+ tag.getInt("PriorityScore"),
+ tag.getInt("QueueRank"),
+ tag.getLong("RequestedAt"),
+ tag.getLong("UpdatedAt")
+ );
+ }
+
+ private static String safeTag(@Nullable String tag) {
+ return tag == null || tag.isBlank() ? "UNSPECIFIED" : tag;
+ }
+}
diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingPlotPlanner.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingPlotPlanner.java
new file mode 100644
index 00000000..1bdcef92
--- /dev/null
+++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingPlotPlanner.java
@@ -0,0 +1,254 @@
+package com.talhanation.bannermod.society;
+
+import com.talhanation.bannermod.events.ClaimEvents;
+import com.talhanation.bannermod.persistence.military.RecruitsClaim;
+import com.talhanation.bannermod.settlement.SettlementBuildingRecord;
+import com.talhanation.bannermod.settlement.SettlementSnapshot;
+import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime;
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.levelgen.Heightmap;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.UUID;
+
+public final class NpcHousingPlotPlanner {
+ private static final int RESERVED_PLOT_SPACING = 7;
+ private static final int RESERVED_HOME_MATCH_RADIUS = 12;
+ private static final int RESERVED_PLOT_CHUNK_RADIUS = 3;
+
+ private NpcHousingPlotPlanner() {
+ }
+
+ public static NpcHousingRequestRecord ensureReservedPlot(ServerLevel level,
+ SettlementSnapshot snapshot,
+ NpcHousingRequestRecord request,
+ long gameTime) {
+ if (level == null || snapshot == null || request == null || request.reservedPlotPos() != null) {
+ return request;
+ }
+ NpcHouseholdRecord household = NpcHouseholdAccess.householdFor(level, request.householdId()).orElse(null);
+ BlockPos reservedPlot = chooseReservedPlot(level, snapshot, request, household);
+ return reservedPlot == null ? request : NpcHousingRequestAccess.reservePlot(level, request.householdId(), reservedPlot, gameTime);
+ }
+
+ public static @Nullable UUID findReservedHomeBuilding(SettlementSnapshot snapshot,
+ BannerModHomeAssignmentRuntime homeRuntime,
+ NpcHousingRequestRecord request) {
+ if (snapshot == null || homeRuntime == null || request == null || request.reservedPlotPos() == null) {
+ return null;
+ }
+ UUID best = null;
+ double bestDistSqr = Double.POSITIVE_INFINITY;
+ for (SettlementBuildingRecord building : snapshot.buildings()) {
+ if (!isHousingCandidate(building)) {
+ continue;
+ }
+ if (homeRuntime.assignmentsForBuilding(building.buildingUuid()).size() >= building.residentCapacity()) {
+ continue;
+ }
+ double distSqr = building.originPos().distSqr(request.reservedPlotPos());
+ if (distSqr > RESERVED_HOME_MATCH_RADIUS * RESERVED_HOME_MATCH_RADIUS) {
+ continue;
+ }
+ if (distSqr < bestDistSqr) {
+ bestDistSqr = distSqr;
+ best = building.buildingUuid();
+ }
+ }
+ return best;
+ }
+
+ public static @Nullable HousingPlotInfo nearestPlotInfo(ServerLevel level,
+ UUID claimUuid,
+ BlockPos pos,
+ double maxDistance) {
+ if (level == null || claimUuid == null || pos == null) {
+ return null;
+ }
+ NpcHousingRequestRecord bestRequest = null;
+ double bestDistSqr = Math.max(1.0D, maxDistance * maxDistance);
+ for (NpcHousingRequestRecord request : NpcHousingRequestSavedData.get(level).runtime().requestsForClaim(claimUuid)) {
+ if (request == null || request.reservedPlotPos() == null) {
+ continue;
+ }
+ double distSqr = request.reservedPlotPos().distSqr(pos);
+ if (distSqr <= bestDistSqr) {
+ bestDistSqr = distSqr;
+ bestRequest = request;
+ }
+ }
+ if (bestRequest == null) {
+ return null;
+ }
+ NpcHouseholdRecord household = NpcHouseholdAccess.householdFor(level, bestRequest.householdId()).orElse(null);
+ return new HousingPlotInfo(bestRequest, household, bestRequest.reservedPlotPos(), bestDistSqr);
+ }
+
+ private static @Nullable BlockPos chooseReservedPlot(ServerLevel level,
+ SettlementSnapshot snapshot,
+ NpcHousingRequestRecord request,
+ @Nullable NpcHouseholdRecord household) {
+ RecruitsClaim claim = resolveClaim(snapshot.claimUuid());
+ if (claim == null) {
+ return null;
+ }
+ List candidates = candidateFortPlots(level, claim, snapshot);
+ if (candidates.isEmpty()) {
+ return null;
+ }
+ List occupied = occupiedOrigins(snapshot);
+ List reserved = reservedPlots(level, request.claimUuid(), request.householdId());
+ BlockPos reference = referencePos(level, snapshot, request, household);
+ return candidates.stream()
+ .filter(candidate -> farEnough(candidate, occupied))
+ .filter(candidate -> farEnough(candidate, reserved))
+ .min(Comparator.comparingDouble(candidate -> score(candidate, reference, occupied, reserved)))
+ .orElse(null);
+ }
+
+ private static double score(BlockPos candidate,
+ BlockPos reference,
+ List occupied,
+ List reserved) {
+ double distanceToReference = candidate.distSqr(reference);
+ double distanceToNeighborhood = nearestDistance(candidate, occupied, reserved);
+ return distanceToReference - Math.min(distanceToNeighborhood, 64.0D);
+ }
+
+ private static double nearestDistance(BlockPos candidate, List occupied, List reserved) {
+ double best = Double.POSITIVE_INFINITY;
+ for (BlockPos pos : occupied) {
+ best = Math.min(best, candidate.distSqr(pos));
+ }
+ for (BlockPos pos : reserved) {
+ best = Math.min(best, candidate.distSqr(pos));
+ }
+ return Double.isInfinite(best) ? 64.0D : best;
+ }
+
+ private static boolean farEnough(BlockPos candidate, List others) {
+ for (BlockPos other : others) {
+ if (other != null && candidate.closerThan(other, RESERVED_PLOT_SPACING)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static List occupiedOrigins(SettlementSnapshot snapshot) {
+ List occupied = new ArrayList<>();
+ if (snapshot == null) {
+ return occupied;
+ }
+ for (SettlementBuildingRecord building : snapshot.buildings()) {
+ if (isHousingCandidate(building)) {
+ occupied.add(building.originPos());
+ }
+ }
+ return occupied;
+ }
+
+ private static List reservedPlots(ServerLevel level, UUID claimUuid, UUID requestingHouseholdId) {
+ List reserved = new ArrayList<>();
+ for (NpcHousingRequestRecord request : NpcHousingRequestSavedData.get(level).runtime().requestsForClaim(claimUuid)) {
+ if (request == null || request.reservedPlotPos() == null || request.householdId().equals(requestingHouseholdId)) {
+ continue;
+ }
+ reserved.add(request.reservedPlotPos());
+ }
+ return reserved;
+ }
+
+ private static BlockPos referencePos(ServerLevel level,
+ SettlementSnapshot snapshot,
+ NpcHousingRequestRecord request,
+ @Nullable NpcHouseholdRecord household) {
+ if (household != null && household.homeBuildingUuid() != null) {
+ for (SettlementBuildingRecord building : snapshot.buildings()) {
+ if (building != null && household.homeBuildingUuid().equals(building.buildingUuid())) {
+ return building.originPos();
+ }
+ }
+ }
+ ChunkPos anchorChunk = snapshot.anchorChunk();
+ return level.getHeightmapPos(
+ Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
+ new BlockPos(anchorChunk.getMiddleBlockX(), level.getSeaLevel(), anchorChunk.getMiddleBlockZ())
+ );
+ }
+
+ private static BlockPos surfacePos(ServerLevel level, ChunkPos chunk, int localX, int localZ) {
+ return level.getHeightmapPos(
+ Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
+ new BlockPos(chunk.getMinBlockX() + localX, level.getSeaLevel(), chunk.getMinBlockZ() + localZ)
+ );
+ }
+
+ private static boolean isHousingCandidate(@Nullable SettlementBuildingRecord building) {
+ if (building == null || building.buildingUuid() == null || building.residentCapacity() <= 0) {
+ return false;
+ }
+ String buildingTypeId = building.buildingTypeId();
+ if (buildingTypeId == null || buildingTypeId.isBlank()) {
+ return false;
+ }
+ net.minecraft.resources.ResourceLocation typeId = net.minecraft.resources.ResourceLocation.tryParse(buildingTypeId);
+ String path = typeId == null ? buildingTypeId.toLowerCase(java.util.Locale.ROOT) : typeId.getPath().toLowerCase(java.util.Locale.ROOT);
+ return building != null
+ && (path.contains("house") || path.contains("zemlyanka") || path.contains("hut"));
+ }
+
+ private static @Nullable RecruitsClaim resolveClaim(UUID claimUuid) {
+ if (claimUuid == null || ClaimEvents.claimManager() == null) {
+ return null;
+ }
+ for (RecruitsClaim claim : ClaimEvents.claimManager().getAllClaims()) {
+ if (claim != null && claimUuid.equals(claim.getUUID())) {
+ return claim;
+ }
+ }
+ return null;
+ }
+
+ public record HousingPlotInfo(NpcHousingRequestRecord request,
+ @Nullable NpcHouseholdRecord household,
+ BlockPos plotPos,
+ double distanceSqr) {
+ }
+
+ private static List candidateFortPlots(ServerLevel level,
+ RecruitsClaim claim,
+ SettlementSnapshot snapshot) {
+ return candidatePlots(level, claim, snapshot == null ? claim.getCenter() : snapshot.anchorChunk());
+ }
+
+ private static List