From de6e9aa5cdf99789f6a548dac5238f204c50fd75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:08:30 +0000 Subject: [PATCH 1/2] Initial plan From e808816f045f0c3368918dd06633ba035cb338fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:28:20 +0000 Subject: [PATCH 2/2] feat: implement foundational SmartBrainLib architecture for HumanoidNPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModMemoryTypes: BASE_LOCATION (GlobalPos), MACRO_OBJECTIVE (String), NEEDED_MATERIALS (List) via DeferredRegister - InventoryStateSensor: checks main-hand/offhand durability + inventory, writes NEEDED_MATERIALS every 10 ticks - ContextualToolSwapBehavior: maps objective keywords to tool classes (wood→Axe, stone→Pickaxe, combat→Sword, farm→Hoe, dig→Shovel), swaps best-match from carried inventory into main hand - HumanoidNPC: PathfinderMob + SmartBrainOwner; CORE/IDLE/WORK brain activities; sensors polled before brain tick in customServerAiStep - MillEntities: register HUMANOID_NPC entity type - Millenaire2: wire MEMORY_MODULE_TYPES register + HumanoidNPC attributes - HumanoidNpcTests: 12 @GameTest methods covering all components Co-authored-by: DizzyMii <156777136+DizzyMii@users.noreply.github.com> Agent-Logs-Url: https://github.com/DizzyMii/Millenaire_port/sessions/a8ac6081-94b5-40c7-a63b-631d58cb5a8b --- .../org/dizzymii/millenaire2/Millenaire2.java | 7 + .../millenaire2/entity/HumanoidNPC.java | 331 ++++++++++++++++ .../millenaire2/entity/MillEntities.java | 6 + .../entity/brain/ModMemoryTypes.java | 83 +++++ .../behaviour/ContextualToolSwapBehavior.java | 188 ++++++++++ .../brain/sensor/InventoryStateSensor.java | 138 +++++++ .../gametest/HumanoidNpcTests.java | 352 ++++++++++++++++++ 7 files changed, 1105 insertions(+) create mode 100644 src/main/java/org/dizzymii/millenaire2/entity/HumanoidNPC.java create mode 100644 src/main/java/org/dizzymii/millenaire2/entity/brain/ModMemoryTypes.java create mode 100644 src/main/java/org/dizzymii/millenaire2/entity/brain/behaviour/ContextualToolSwapBehavior.java create mode 100644 src/main/java/org/dizzymii/millenaire2/entity/brain/sensor/InventoryStateSensor.java create mode 100644 src/main/java/org/dizzymii/millenaire2/gametest/HumanoidNpcTests.java diff --git a/src/main/java/org/dizzymii/millenaire2/Millenaire2.java b/src/main/java/org/dizzymii/millenaire2/Millenaire2.java index 2ef1198c..ae6fbcbf 100644 --- a/src/main/java/org/dizzymii/millenaire2/Millenaire2.java +++ b/src/main/java/org/dizzymii/millenaire2/Millenaire2.java @@ -25,8 +25,10 @@ import org.dizzymii.millenaire2.block.MillBlocks; import org.dizzymii.millenaire2.client.screen.FirePitScreen; import org.dizzymii.millenaire2.data.ContentDeployer; +import org.dizzymii.millenaire2.entity.HumanoidNPC; import org.dizzymii.millenaire2.entity.MillEntities; import org.dizzymii.millenaire2.entity.MillVillager; +import org.dizzymii.millenaire2.entity.brain.ModMemoryTypes; import org.dizzymii.millenaire2.item.MillItems; import org.dizzymii.millenaire2.menu.MillMenuTypes; import org.dizzymii.millenaire2.network.MillNetworking; @@ -49,6 +51,8 @@ public class Millenaire2 { public static final DeferredRegister CREATIVE_MODE_TABS = DeferredRegister.create(Registries.CREATIVE_MODE_TAB, MODID); public static final DeferredRegister> ENTITY_TYPES = DeferredRegister.create(Registries.ENTITY_TYPE, MODID); public static final DeferredRegister> BLOCK_ENTITY_TYPES = DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MODID); + public static final DeferredRegister> MEMORY_MODULE_TYPES = + ModMemoryTypes.MEMORY_MODULE_TYPES; // --- Creative Tab --- public static final DeferredHolder MILL_TAB = CREATIVE_MODE_TABS.register("millenaire_tab", @@ -72,6 +76,7 @@ public Millenaire2(IEventBus modEventBus, ModContainer modContainer) { CREATIVE_MODE_TABS.register(modEventBus); ENTITY_TYPES.register(modEventBus); BLOCK_ENTITY_TYPES.register(modEventBus); + MEMORY_MODULE_TYPES.register(modEventBus); MillMenuTypes.MENU_TYPES.register(modEventBus); // Force class loading of registration holders @@ -79,6 +84,7 @@ public Millenaire2(IEventBus modEventBus, ModContainer modContainer) { MillItems.init(); MillEntities.init(); MillMenuTypes.init(); + ModMemoryTypes.init(); NeoForge.EVENT_BUS.register(this); @@ -100,6 +106,7 @@ private void registerEntityAttributes(EntityAttributeCreationEvent event) { event.put(MillEntities.TARGETED_BLAZE.get(), net.minecraft.world.entity.monster.Blaze.createAttributes().build()); event.put(MillEntities.TARGETED_WITHER_SKELETON.get(), net.minecraft.world.entity.monster.WitherSkeleton.createAttributes().build()); event.put(MillEntities.TARGETED_GHAST.get(), net.minecraft.world.entity.monster.Ghast.createAttributes().build()); + event.put(MillEntities.HUMANOID_NPC.get(), HumanoidNPC.createAttributes().build()); } @SubscribeEvent diff --git a/src/main/java/org/dizzymii/millenaire2/entity/HumanoidNPC.java b/src/main/java/org/dizzymii/millenaire2/entity/HumanoidNPC.java new file mode 100644 index 00000000..6397c24e --- /dev/null +++ b/src/main/java/org/dizzymii/millenaire2/entity/HumanoidNPC.java @@ -0,0 +1,331 @@ +package org.dizzymii.millenaire2.entity; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Dynamic; +import net.minecraft.core.GlobalPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.entity.ai.Brain; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.behavior.BehaviorControl; +import net.minecraft.world.entity.schedule.Activity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.dizzymii.millenaire2.entity.brain.ModMemoryTypes; +import org.dizzymii.millenaire2.entity.brain.behaviour.ContextualToolSwapBehavior; +import org.dizzymii.millenaire2.entity.brain.sensor.InventoryStateSensor; +import org.dizzymii.millenaire2.entity.brain.smartbrain.BrainActivityGroup; +import org.dizzymii.millenaire2.entity.brain.smartbrain.ExtendedSensor; +import org.dizzymii.millenaire2.entity.brain.smartbrain.SmartBrainOwner; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * A state-driven, "human-like" autonomous NPC built on the SmartBrainLib (SBL) + * architecture. + * + *

Unlike standard reactive mobs, {@code HumanoidNPC} is driven by custom brain + * memories, sensors, and contextual behaviours: + *

    + *
  • {@link ModMemoryTypes#BASE_LOCATION} — home position anchor for + * patrol and return-home behaviours. + *
  • {@link ModMemoryTypes#MACRO_OBJECTIVE} — high-level state such as + * {@code "gather_wood"} or {@code "defend"}. + *
  • {@link ModMemoryTypes#NEEDED_MATERIALS} — materials the NPC must acquire, + * refreshed each scan cycle by {@link InventoryStateSensor}. + *
+ * + *

Brain activities: + *

    + *
  1. CORE — runs every tick (look-at and walk stubs; full integration + * pending vanilla {@code LookAtTargetSink} / {@code MoveToTargetSink}). + *
  2. WORK — "acquisition" phase; {@link ContextualToolSwapBehavior} + * ensures the NPC holds the correct tool before any work action fires. + *
  3. IDLE — fallback when no higher-priority activity is active. + *
+ * + *

Actual pathfinding and block-breaking logic are intentionally out of scope + * for this pass; only the memory, sensor, and tool-swapping architecture is + * implemented here. + */ +public class HumanoidNPC extends PathfinderMob implements SmartBrainOwner { + + // ========== Attribute defaults ========== + + private static final double DEFAULT_HEALTH = 20.0; + private static final double DEFAULT_MOVE_SPEED = 0.5; + private static final double DEFAULT_ATTACK_DAMAGE = 3.0; + private static final double DEFAULT_FOLLOW_RANGE = 32.0; + + // ========== Instance state ========== + + /** + * Logical carried inventory — items the NPC uses for tasks. + * Tools are selected from this list by {@link ContextualToolSwapBehavior}. + */ + private final List carriedInventory = new ArrayList<>(); + + /** + * Sensors that are polled each server tick to populate brain memories. + * Cached at construction time; re-creating on every tick would be wasteful. + */ + private final List> sensorCache; + + // ========== Constructor ========== + + /** + * Standard {@link PathfinderMob} constructor. + * + *

The sensor cache is built immediately so sensors are ready on the first + * tick without lazy initialisation overhead. + * + * @param type the registered entity type + * @param level the world the entity is being created in + */ + public HumanoidNPC(EntityType type, Level level) { + super(type, level); + this.sensorCache = getSensors(); + } + + // ========== Attribute factory ========== + + /** + * Creates the {@link AttributeSupplier} used when the entity type is registered + * in {@link MillEntities} and + * {@link org.dizzymii.millenaire2.Millenaire2#registerEntityAttributes}. + * + *

Attributes represent a typical human-scale combatant at default difficulty. + * + * @return a builder pre-populated with health, movement speed, attack damage, + * and follow range + */ + public static AttributeSupplier.Builder createAttributes() { + return PathfinderMob.createMobAttributes() + .add(Attributes.MAX_HEALTH, DEFAULT_HEALTH) + .add(Attributes.MOVEMENT_SPEED, DEFAULT_MOVE_SPEED) + .add(Attributes.ATTACK_DAMAGE, DEFAULT_ATTACK_DAMAGE) + .add(Attributes.FOLLOW_RANGE, DEFAULT_FOLLOW_RANGE); + } + + // ========== Brain wiring ========== + + /** + * Returns the {@link Brain.Provider} that declares all memory module types + * used by this entity. + * + *

Sensor types are not registered here because our stub + * {@link ExtendedSensor} API does not wrap vanilla {@code SensorType}; sensors + * are instead polled manually in {@link #customServerAiStep}. + * + * @return a Brain provider configured with all three custom memory types + */ + @SuppressWarnings("unchecked") + @Override + protected Brain.Provider brainProvider() { + return Brain.provider( + List.of( + ModMemoryTypes.BASE_LOCATION.get(), + ModMemoryTypes.MACRO_OBJECTIVE.get(), + ModMemoryTypes.NEEDED_MATERIALS.get() + ), + List.of() // SensorTypes not used with the stub ExtendedSensor API + ); + } + + /** + * Constructs the {@link Brain} and installs all activity groups via the + * {@link SmartBrainOwner} helper methods. + * + * @param dynamic serialised brain data (may be empty on first spawn) + * @return a fully configured Brain ready for use + */ + @SuppressWarnings("unchecked") + @Override + protected Brain makeBrain(Dynamic dynamic) { + Brain brain = (Brain) brainProvider().makeBrain(dynamic); + configureBrainActivities(brain); + return brain; + } + + /** + * Returns this entity's typed {@link Brain}, casting from the raw wildcard + * returned by the super-class. + * + * @return the typed Brain + */ + @Override + @SuppressWarnings("unchecked") + public Brain getBrain() { + return (Brain) super.getBrain(); + } + + // ========== SmartBrainOwner implementation ========== + + /** + * Returns the list of custom sensors that populate brain memories each tick. + * + *

The {@link InventoryStateSensor} is the primary awareness probe; it runs + * at twice-per-second cadence and writes {@link ModMemoryTypes#NEEDED_MATERIALS}. + * + * @return sensors polled during each AI tick via {@link #customServerAiStep} + */ + @Override + public List> getSensors() { + return List.of(new InventoryStateSensor()); + } + + /** + * CORE tasks run every tick regardless of which activity is currently active. + * + *

Placeholder: vanilla {@code LookAtTargetSink} and {@code MoveToTargetSink} + * should be wired here once full SBL integration is complete. + * + * @return an empty core group (placeholder) + */ + @Override + public BrainActivityGroup getCoreTasks() { + // Placeholder — vanilla look/walk behaviours are wired here in the full + // SBL integration pass. + return BrainActivityGroup.coreTasks(); + } + + /** + * IDLE tasks execute when no higher-priority activity is currently active. + * + *

Placeholder: socialise and wander behaviours are added in a future pass. + * + * @return an empty idle group (placeholder) + */ + @Override + public BrainActivityGroup getIdleTasks() { + return BrainActivityGroup.idleTasks(); + } + + /** + * WORK / acquisition tasks execute during the NPC's active task phase. + * + *

{@link ContextualToolSwapBehavior} fires here to ensure the NPC holds + * the correct tool before any block-breaking or attacking action proceeds. + * + * @return the work/acquisition activity group containing the tool-swap behaviour + */ + @Override + public BrainActivityGroup getWorkTasks() { + return BrainActivityGroup.workTasks(new ContextualToolSwapBehavior()); + } + + // ========== Tick logic ========== + + /** + * Server-side AI step: polls all sensors and then runs the vanilla Brain tick. + * + *

Sensors are polled before the brain tick so that memories written + * by {@link InventoryStateSensor} are available to behaviours on the same tick. + */ + @Override + protected void customServerAiStep() { + super.customServerAiStep(); + if (level() instanceof ServerLevel sl) { + for (ExtendedSensor sensor : sensorCache) { + sensor.tick(sl, this); + } + level().getProfiler().push("humanoidNpcBrain"); + getBrain().tick(sl, this); + level().getProfiler().pop(); + } + } + + // ========== Inventory accessors ========== + + /** + * Returns an unmodifiable view of the NPC's carried inventory. + * + *

Used by {@link ContextualToolSwapBehavior} and {@link InventoryStateSensor} + * to inspect available tools without risking external mutation. + * + * @return read-only view of the carried item list + */ + public List getCarriedInventory() { + return Collections.unmodifiableList(carriedInventory); + } + + /** + * Adds an item to the NPC's carried inventory. + * + *

Empty stacks are silently ignored to keep the inventory clean. + * + * @param stack the item to add; must not be {@code null} + */ + public void addToCarriedInventory(ItemStack stack) { + if (!stack.isEmpty()) { + carriedInventory.add(stack); + } + } + + // ========== Home location ========== + + /** + * Stores or clears the NPC's home / base location in brain memory. + * + *

The stored {@link GlobalPos} is the anchor used by patrol and + * return-home behaviours (implemented in a future pass). + * + * @param pos the global position to write, or {@code null} to erase the memory + */ + public void setBaseLocation(@Nullable GlobalPos pos) { + if (pos != null) { + getBrain().setMemory(ModMemoryTypes.BASE_LOCATION.get(), pos); + } else { + getBrain().eraseMemory(ModMemoryTypes.BASE_LOCATION.get()); + } + } + + // ========== Private brain-wiring helpers ========== + + /** + * Iterates all non-empty {@link BrainActivityGroup}s from + * {@link SmartBrainOwner#getAllBrainActivities()} and registers each one with + * the vanilla {@link Brain} using sequential priority indices. + * + *

Uses the same priority-registration pattern as + * {@link org.dizzymii.millenaire2.entity.brain.VillagerBrainConfig}. + * + * @param brain the brain to configure + */ + private void configureBrainActivities(Brain brain) { + for (BrainActivityGroup group : getAllBrainActivities()) { + if (!group.isEmpty()) { + registerGroup(brain, group); + } + } + brain.setCoreActivities(ImmutableSet.of(Activity.CORE)); + brain.setDefaultActivity(Activity.WORK); + brain.setActiveActivityIfPossible(Activity.WORK); + } + + /** + * Registers a single {@link BrainActivityGroup} with the given {@link Brain}, + * assigning sequential integer priorities (0, 1, 2 …) to each behaviour. + * + * @param brain the target brain + * @param group the non-empty group to register + */ + private static void registerGroup(Brain brain, + BrainActivityGroup group) { + ImmutableList> behaviours = group.behaviours(); + ImmutableList.Builder>> builder = + ImmutableList.builder(); + for (int i = 0; i < behaviours.size(); i++) { + builder.add(Pair.of(i, behaviours.get(i))); + } + brain.addActivityWithConditions(group.activity(), builder.build(), Set.of()); + } +} diff --git a/src/main/java/org/dizzymii/millenaire2/entity/MillEntities.java b/src/main/java/org/dizzymii/millenaire2/entity/MillEntities.java index 1d54983c..889fa1eb 100644 --- a/src/main/java/org/dizzymii/millenaire2/entity/MillEntities.java +++ b/src/main/java/org/dizzymii/millenaire2/entity/MillEntities.java @@ -60,6 +60,12 @@ public class MillEntities { .sized(4.0F, 4.0F).clientTrackingRange(10) .build("targeted_ghast")); + public static final DeferredHolder, EntityType> HUMANOID_NPC = + Millenaire2.ENTITY_TYPES.register("humanoid_npc", + () -> EntityType.Builder.of(HumanoidNPC::new, MobCategory.CREATURE) + .sized(0.6F, 1.95F).clientTrackingRange(8).updateInterval(3) + .build("humanoid_npc")); + // ========== Block Entity Types ========== public static final DeferredHolder, BlockEntityType> FIRE_PIT_BE = diff --git a/src/main/java/org/dizzymii/millenaire2/entity/brain/ModMemoryTypes.java b/src/main/java/org/dizzymii/millenaire2/entity/brain/ModMemoryTypes.java new file mode 100644 index 00000000..d6a53ca6 --- /dev/null +++ b/src/main/java/org/dizzymii/millenaire2/entity/brain/ModMemoryTypes.java @@ -0,0 +1,83 @@ +package org.dizzymii.millenaire2.entity.brain; + +import com.mojang.serialization.Codec; +import net.minecraft.core.GlobalPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.entity.ai.memory.MemoryModuleType; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; +import org.dizzymii.millenaire2.Millenaire2; + +import java.util.List; +import java.util.Optional; + +/** + * Central registry for all custom {@link MemoryModuleType} instances used by + * the HumanoidNPC brain. + * + *

Register is held here as a public constant so it can be handed to the + * NeoForge mod event bus in {@link org.dizzymii.millenaire2.Millenaire2}. + * + *

Memory lifecycle in the SBL tick loop: + *

    + *
  1. {@link org.dizzymii.millenaire2.entity.brain.sensor.InventoryStateSensor} + * writes {@link #NEEDED_MATERIALS} every 10 ticks. + *
  2. {@link org.dizzymii.millenaire2.entity.brain.behaviour.ContextualToolSwapBehavior} + * reads {@link #MACRO_OBJECTIVE} to decide which tool to equip. + *
  3. {@link #BASE_LOCATION} is written once when the NPC spawns and is used + * by patrol / return-home behaviours. + *
+ */ +public final class ModMemoryTypes { + + /** + * DeferredRegister for all custom memory module types. + * Must be registered with the mod event bus in the mod constructor. + */ + public static final DeferredRegister> MEMORY_MODULE_TYPES = + DeferredRegister.create(Registries.MEMORY_MODULE_TYPE, Millenaire2.MODID); + + // ========== Memory registrations ========== + + /** + * Stores the NPC's home / spawn coordinates as a {@link GlobalPos} (dimension + block pos). + * Written once on spawn via + * {@link org.dizzymii.millenaire2.entity.HumanoidNPC#setBaseLocation}. + */ + public static final DeferredHolder, MemoryModuleType> + BASE_LOCATION = MEMORY_MODULE_TYPES.register("base_location", + () -> new MemoryModuleType<>(Optional.of(GlobalPos.CODEC))); + + /** + * Stores the NPC's current high-level objective as a plain string, e.g. + * {@code "gather_wood"}, {@code "mine_stone"}, or {@code "defend"}. + * Read by {@link org.dizzymii.millenaire2.entity.brain.behaviour.ContextualToolSwapBehavior} + * to select the correct tool category. + */ + public static final DeferredHolder, MemoryModuleType> + MACRO_OBJECTIVE = MEMORY_MODULE_TYPES.register("macro_objective", + () -> new MemoryModuleType<>(Optional.of(Codec.STRING))); + + /** + * Stores a dynamic list of item identifiers that the NPC is missing or needs + * to replace (e.g. {@code "main_hand_tool:damaged:item.minecraft.iron_pickaxe"}). + * Written each scan cycle by + * {@link org.dizzymii.millenaire2.entity.brain.sensor.InventoryStateSensor}. + */ + public static final DeferredHolder, MemoryModuleType>> + NEEDED_MATERIALS = MEMORY_MODULE_TYPES.register("needed_materials", + () -> new MemoryModuleType<>(Optional.of(Codec.STRING.listOf()))); + + // ========== Class-loading trigger ========== + + /** + * Called from {@link org.dizzymii.millenaire2.Millenaire2} to force class loading + * and ensure all static registration fields are initialised before the event bus + * fires. + */ + public static void init() { + // Intentionally empty — static fields are initialised on first class access. + } + + private ModMemoryTypes() {} +} diff --git a/src/main/java/org/dizzymii/millenaire2/entity/brain/behaviour/ContextualToolSwapBehavior.java b/src/main/java/org/dizzymii/millenaire2/entity/brain/behaviour/ContextualToolSwapBehavior.java new file mode 100644 index 00000000..32296f81 --- /dev/null +++ b/src/main/java/org/dizzymii/millenaire2/entity/brain/behaviour/ContextualToolSwapBehavior.java @@ -0,0 +1,188 @@ +package org.dizzymii.millenaire2.entity.brain.behaviour; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.ai.memory.MemoryModuleType; +import net.minecraft.world.entity.ai.memory.MemoryStatus; +import net.minecraft.world.item.AxeItem; +import net.minecraft.world.item.HoeItem; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.PickaxeItem; +import net.minecraft.world.item.ShovelItem; +import net.minecraft.world.item.SwordItem; +import org.dizzymii.millenaire2.entity.HumanoidNPC; +import org.dizzymii.millenaire2.entity.brain.ModMemoryTypes; +import org.dizzymii.millenaire2.entity.brain.smartbrain.ExtendedBehaviour; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; + +/** + * Behaviour that selects the best tool from the NPC's carried inventory for the + * current macro-objective and swaps it into the main hand. + * + *

This behaviour belongs to the WORK (acquisition) activity and fires before + * any block-breaking or attacking action so the NPC is always wielding the + * correct tool without manual intervention. + * + *

In the SBL tick loop: + *

    + *
  1. {@link #checkExtraStartConditions} — evaluates whether the main hand + * is already optimal for the active objective; returns {@code true} only + * when a swap is actually needed. + *
  2. {@link #start} — performs the one-shot swap and transitions back to + * {@code STOPPED} on the same tick (no keep-running logic required). + *
+ * + *

Objective → tool-category mapping: + * + * + * + * + * + * + * + *
Objective keywordTool class
wood / lumber{@link AxeItem}
stone / mine{@link PickaxeItem}
defend / attack / fight{@link SwordItem}
farm / harvest{@link HoeItem}
dig / excavat{@link ShovelItem}
+ * + *

Actual pathfinding and block-breaking logic are intentionally out of scope + * for this pass; only the memory, sensor, and tool-swapping architecture is + * implemented here. + */ +public class ContextualToolSwapBehavior extends ExtendedBehaviour { + + // ========== Entry conditions ========== + + /** + * Declares the memory preconditions that must be satisfied before this + * behaviour is even considered for start. + * + *

Requires {@link ModMemoryTypes#MACRO_OBJECTIVE} to be present so the + * behaviour knows which tool category to seek. + * + * @return map of required memory states + */ + @Override + protected Map, MemoryStatus> entryConditions() { + return Map.of(ModMemoryTypes.MACRO_OBJECTIVE.get(), MemoryStatus.VALUE_PRESENT); + } + + // ========== Start conditions ========== + + /** + * Returns {@code true} when the NPC's main hand does not already hold an + * optimal tool for the active {@link ModMemoryTypes#MACRO_OBJECTIVE}. + * + *

This prevents no-op swaps and keeps the behaviour inactive during ticks + * where the NPC is already correctly equipped. + * + * @param level the server-side level + * @param entity the {@link HumanoidNPC} being evaluated + * @return {@code true} if the main hand should be changed this tick + */ + @Override + protected boolean checkExtraStartConditions(ServerLevel level, HumanoidNPC entity) { + String objective = entity.getBrain() + .getMemory(ModMemoryTypes.MACRO_OBJECTIVE.get()) + .orElse(""); + if (objective.isEmpty()) return false; + + ItemStack currentMainHand = entity.getItemInHand(InteractionHand.MAIN_HAND); + return !isOptimalTool(currentMainHand, objective); + } + + // ========== Behaviour execution ========== + + /** + * Performs the tool swap: iterates the NPC's carried inventory, finds the + * first {@link ItemStack} appropriate for the active objective, and places a + * copy of it into the main hand. + * + *

If no matching tool is found in the inventory the main hand is left + * unchanged. This is a one-shot behaviour — it does not keep running. + * + * @param level the server-side level + * @param entity the {@link HumanoidNPC} performing the swap + */ + @Override + protected void start(ServerLevel level, HumanoidNPC entity) { + String objective = entity.getBrain() + .getMemory(ModMemoryTypes.MACRO_OBJECTIVE.get()) + .orElse(""); + if (objective.isEmpty()) return; + + List inventory = entity.getCarriedInventory(); + ItemStack bestTool = findBestToolForObjective(inventory, objective); + if (bestTool != null && !bestTool.isEmpty()) { + entity.setItemInHand(InteractionHand.MAIN_HAND, bestTool.copy()); + } + } + + // ========== Private helpers ========== + + /** + * Returns {@code true} when {@code stack} is already an appropriate tool for + * {@code objective}, avoiding a redundant swap. + * + * @param stack the item currently in the main hand + * @param objective the active macro-objective string from memory + * @return {@code true} if no swap is needed + */ + private boolean isOptimalTool(ItemStack stack, String objective) { + if (stack.isEmpty()) return false; + return switch (resolveObjectiveCategory(objective)) { + case "wood" -> stack.getItem() instanceof AxeItem; + case "stone" -> stack.getItem() instanceof PickaxeItem; + case "combat" -> stack.getItem() instanceof SwordItem; + case "farm" -> stack.getItem() instanceof HoeItem; + case "dig" -> stack.getItem() instanceof ShovelItem; + default -> false; + }; + } + + /** + * Scans the given inventory list and returns the first {@link ItemStack} that + * matches the tool category derived from {@code objective}. + * + * @param inventory the NPC's carried items (from + * {@link HumanoidNPC#getCarriedInventory()}) + * @param objective the active macro-objective string from memory + * @return the best matching tool, or {@code null} if none found + */ + @Nullable + private ItemStack findBestToolForObjective(List inventory, String objective) { + String category = resolveObjectiveCategory(objective); + for (ItemStack stack : inventory) { + if (stack.isEmpty()) continue; + boolean match = switch (category) { + case "wood" -> stack.getItem() instanceof AxeItem; + case "stone" -> stack.getItem() instanceof PickaxeItem; + case "combat" -> stack.getItem() instanceof SwordItem; + case "farm" -> stack.getItem() instanceof HoeItem; + case "dig" -> stack.getItem() instanceof ShovelItem; + default -> false; + }; + if (match) return stack; + } + return null; + } + + /** + * Maps a raw objective string to a simplified tool-category token used by the + * switch expressions in {@link #isOptimalTool} and + * {@link #findBestToolForObjective}. + * + * @param objective the {@link ModMemoryTypes#MACRO_OBJECTIVE} value from memory + * @return one of {@code "wood"}, {@code "stone"}, {@code "combat"}, + * {@code "farm"}, {@code "dig"}, or {@code "none"} + */ + private String resolveObjectiveCategory(String objective) { + if (objective.contains("wood") || objective.contains("lumber")) return "wood"; + if (objective.contains("stone") || objective.contains("mine")) return "stone"; + if (objective.contains("defend") || objective.contains("attack") + || objective.contains("fight")) return "combat"; + if (objective.contains("farm") || objective.contains("harvest")) return "farm"; + if (objective.contains("dig") || objective.contains("excavat")) return "dig"; + return "none"; + } +} diff --git a/src/main/java/org/dizzymii/millenaire2/entity/brain/sensor/InventoryStateSensor.java b/src/main/java/org/dizzymii/millenaire2/entity/brain/sensor/InventoryStateSensor.java new file mode 100644 index 00000000..28d0d040 --- /dev/null +++ b/src/main/java/org/dizzymii/millenaire2/entity/brain/sensor/InventoryStateSensor.java @@ -0,0 +1,138 @@ +package org.dizzymii.millenaire2.entity.brain.sensor; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import org.dizzymii.millenaire2.entity.HumanoidNPC; +import org.dizzymii.millenaire2.entity.brain.ModMemoryTypes; +import org.dizzymii.millenaire2.entity.brain.smartbrain.ExtendedSensor; + +import java.util.ArrayList; +import java.util.List; + +/** + * Sensor that evaluates a {@link HumanoidNPC}'s equipment and carried inventory + * to build a picture of what the NPC is missing or needs to replace. + * + *

During each scan cycle this sensor: + *

    + *
  1. Inspects the main-hand item for durability damage. + *
  2. Inspects the offhand item for durability damage. + *
  3. Scans all items in {@link HumanoidNPC#getCarriedInventory()} for damaged tools. + *
  4. Writes the complete need-list to {@link ModMemoryTypes#NEEDED_MATERIALS}. + *
+ * + *

In the SBL tick loop this sensor runs before behaviours (called from + * {@link HumanoidNPC#customServerAiStep}), so that + * {@link org.dizzymii.millenaire2.entity.brain.behaviour.ContextualToolSwapBehavior} + * always reads an up-to-date snapshot on the same tick. + * + *

Need identifiers use the format: + *

    + *
  • {@code "main_hand_tool:empty"} — main hand slot is empty. + *
  • {@code "main_hand_tool:damaged:"} — main-hand tool is critically worn. + *
  • {@code "off_hand_tool:empty"} — offhand slot is empty. + *
  • {@code "inventory:damaged:"} — a carried tool needs replacing. + *
+ */ +public class InventoryStateSensor extends ExtendedSensor { + + /** + * Durability fraction below which a tool is considered critically damaged and + * added to the {@link ModMemoryTypes#NEEDED_MATERIALS} list. + * A value of {@code 0.25f} means the tool has 25 % durability remaining + * (75 % lost), and should be replaced. + */ + private static final float DAMAGE_THRESHOLD = 0.25f; + + // ========== Scan rate ========== + + /** + * Polls twice per second (every 10 ticks) so that memory stays fresh enough + * for {@link org.dizzymii.millenaire2.entity.brain.behaviour.ContextualToolSwapBehavior} + * to react before the next action fires. + * + * @param entity the entity being sensed + * @return scan interval in game ticks + */ + @Override + public int getScanRate(HumanoidNPC entity) { + return 10; + } + + // ========== Core sense logic ========== + + /** + * Evaluates the entity's current equipment and writes the resulting need-list + * into the {@link ModMemoryTypes#NEEDED_MATERIALS} brain memory. + * + *

An empty list means the NPC is fully equipped with undamaged tools. + * A non-empty list signals to behaviours that the NPC should seek replacements. + * + * @param level the server-side level (used for potential future world queries) + * @param entity the {@link HumanoidNPC} being evaluated + */ + @Override + protected void doTick(ServerLevel level, HumanoidNPC entity) { + List needed = new ArrayList<>(); + + evaluateHandItem( + entity.getItemInHand(InteractionHand.MAIN_HAND), + "main_hand_tool", + needed); + + evaluateHandItem( + entity.getItemInHand(InteractionHand.OFF_HAND), + "off_hand_tool", + needed); + + scanInventoryForNeeds(entity, needed); + + entity.getBrain().setMemory(ModMemoryTypes.NEEDED_MATERIALS.get(), needed); + } + + // ========== Private helpers ========== + + /** + * Checks whether a hand slot is empty or whether its item has critically low + * durability, appending a need identifier to {@code needed} if so. + * + * @param stack the {@link ItemStack} currently in the hand slot + * @param slotKey a descriptive prefix used as the need identifier + * (e.g. {@code "main_hand_tool"}) + * @param needed mutable list to append need strings to + */ + private void evaluateHandItem(ItemStack stack, String slotKey, List needed) { + if (stack.isEmpty()) { + needed.add(slotKey + ":empty"); + return; + } + if (stack.isDamageableItem()) { + float durabilityFraction = + 1.0f - ((float) stack.getDamageValue() / (float) stack.getMaxDamage()); + if (durabilityFraction < DAMAGE_THRESHOLD) { + needed.add(slotKey + ":damaged:" + stack.getItem().getDescriptionId()); + } + } + } + + /** + * Iterates the NPC's logical inventory and flags any tool whose durability has + * fallen below {@link #DAMAGE_THRESHOLD}. + * + * @param entity the entity whose carried inventory is scanned + * @param needed mutable list to append need strings to + */ + private void scanInventoryForNeeds(HumanoidNPC entity, List needed) { + for (ItemStack stack : entity.getCarriedInventory()) { + if (stack.isEmpty()) continue; + if (stack.isDamageableItem()) { + float durabilityFraction = + 1.0f - ((float) stack.getDamageValue() / (float) stack.getMaxDamage()); + if (durabilityFraction < DAMAGE_THRESHOLD) { + needed.add("inventory:damaged:" + stack.getItem().getDescriptionId()); + } + } + } + } +} diff --git a/src/main/java/org/dizzymii/millenaire2/gametest/HumanoidNpcTests.java b/src/main/java/org/dizzymii/millenaire2/gametest/HumanoidNpcTests.java new file mode 100644 index 00000000..c83cb3d9 --- /dev/null +++ b/src/main/java/org/dizzymii/millenaire2/gametest/HumanoidNpcTests.java @@ -0,0 +1,352 @@ +package org.dizzymii.millenaire2.gametest; + +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.schedule.Activity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.dizzymii.millenaire2.Millenaire2; +import org.dizzymii.millenaire2.entity.HumanoidNPC; +import org.dizzymii.millenaire2.entity.MillEntities; +import org.dizzymii.millenaire2.entity.brain.ModMemoryTypes; +import org.dizzymii.millenaire2.entity.brain.behaviour.ContextualToolSwapBehavior; +import org.dizzymii.millenaire2.entity.brain.sensor.InventoryStateSensor; + +import java.util.List; +import java.util.Optional; + +/** + * GameTest suite for the HumanoidNPC SBL (SmartBrainLib) architecture. + * + *

Covers: + *

    + *
  • Entity spawns with a non-null Brain ({@link #testBrainIsConfiguredOnSpawn}) + *
  • All three custom memory types are registered and readable + * ({@link #testCustomMemoryTypesAreRegistered}) + *
  • WORK is the default active activity ({@link #testDefaultActivityIsWork}) + *
  • {@link InventoryStateSensor} writes NEEDED_MATERIALS to brain memory + * ({@link #testInventoryStateSensorWritesNeededMaterials}, + * {@link #testInventoryStateSensorReportsDamagedTool}, + * {@link #testInventoryStateSensorEmptyInventoryReportsEmptyHands}) + *
  • {@link ContextualToolSwapBehavior} swaps the correct tool for an objective + * ({@link #testToolSwapPickaxeForMineObjective}, + * {@link #testToolSwapSwordForDefendObjective}, + * {@link #testToolSwapAxeForGatherWoodObjective}, + * {@link #testToolSwapNoSwapWhenAlreadyOptimal}, + * {@link #testToolSwapNoOpWhenNoMatchingTool}) + *
  • Base-location memory helpers work correctly + * ({@link #testSetBaseLocationStoresMemory}, + * {@link #testClearBaseLocationErasesMemory}) + *
+ */ +@GameTestHolder(Millenaire2.MODID) +@PrefixGameTestTemplate(false) +public class HumanoidNpcTests { + + // ==================== Helpers ==================== + + /** Spawns a {@link HumanoidNPC} at a test-relative position. */ + private static HumanoidNPC spawnNpc(GameTestHelper helper, int rx, int ry, int rz) { + BlockPos abs = helper.absolutePos(new BlockPos(rx, ry, rz)); + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = MillEntities.HUMANOID_NPC.get().create(level); + if (npc == null) throw new IllegalStateException("Failed to create HumanoidNPC entity"); + npc.setPos(abs.getX() + 0.5, abs.getY(), abs.getZ() + 0.5); + level.addFreshEntity(npc); + return npc; + } + + /** + * Creates an {@link InventoryStateSensor} that always fires on every tick + * (scan rate = 1) so tests do not depend on the game-time being divisible + * by the production scan rate. + * + *

{@code getScanRate} returns 1; since {@code getGameTime() % 1 == 0} + * is unconditionally true, {@code doTick} is guaranteed to run on the + * first call to {@code tick()}. + */ + private static InventoryStateSensor alwaysFireSensor() { + return new InventoryStateSensor() { + @Override + public int getScanRate(HumanoidNPC entity) { + return 1; + } + }; + } + + // ==================== Brain configuration tests ==================== + + /** + * A freshly created {@link HumanoidNPC} must have a non-null Brain. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testBrainIsConfiguredOnSpawn(GameTestHelper helper) { + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + helper.assertFalse(npc.getBrain() == null, "Brain must not be null after spawn"); + helper.succeed(); + } + + /** + * All three custom memory module types must be registered and readable from + * the brain without throwing. + * + *

Accessing a registered type with no value returns an empty Optional; + * an unregistered type causes the Brain to throw. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testCustomMemoryTypesAreRegistered(GameTestHelper helper) { + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + Optional baseLoc = npc.getBrain().getMemory(ModMemoryTypes.BASE_LOCATION.get()); + Optional objective = npc.getBrain().getMemory(ModMemoryTypes.MACRO_OBJECTIVE.get()); + Optional materials = npc.getBrain().getMemory(ModMemoryTypes.NEEDED_MATERIALS.get()); + + // getMemory returns Optional.empty() for registered-but-absent memories and + // throws for unregistered ones; the non-null check proves registration succeeded. + helper.assertFalse(baseLoc == null, "BASE_LOCATION memory access must not return null"); + helper.assertFalse(objective == null, "MACRO_OBJECTIVE memory access must not return null"); + helper.assertFalse(materials == null, "NEEDED_MATERIALS memory access must not return null"); + helper.succeed(); + } + + /** + * The default active activity for a freshly spawned {@link HumanoidNPC} must + * be {@link Activity#WORK} (the "acquisition" phase as per the architecture spec). + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testDefaultActivityIsWork(GameTestHelper helper) { + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + helper.assertTrue(npc.getBrain().isActive(Activity.WORK), + "Default activity must be WORK, got: " + npc.getBrain().getActiveActivities()); + helper.succeed(); + } + + // ==================== InventoryStateSensor tests ==================== + + /** + * When the NPC has empty hands and no carried items, the sensor must write a + * non-null list (containing at least the empty-hand entries) to + * {@link ModMemoryTypes#NEEDED_MATERIALS}. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testInventoryStateSensorWritesNeededMaterials(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + alwaysFireSensor().tick(level, npc); + + Optional> memory = + npc.getBrain().getMemory(ModMemoryTypes.NEEDED_MATERIALS.get()); + helper.assertTrue(memory.isPresent(), + "NEEDED_MATERIALS memory must be present after sensor tick"); + helper.succeed(); + } + + /** + * When the NPC holds a critically damaged tool (≤ 25 % durability remaining, + * matching {@link InventoryStateSensor}'s {@code DAMAGE_THRESHOLD}), + * the sensor must include a {@code ":damaged:"} entry in the needs list. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testInventoryStateSensorReportsDamagedTool(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + // Damage the pickaxe to 95% of max (leaving only 5% durability — well below + // the 25% DAMAGE_THRESHOLD in InventoryStateSensor). + final float criticalDamageFraction = 0.95f; + ItemStack brokenPickaxe = new ItemStack(Items.IRON_PICKAXE); + int maxDamage = brokenPickaxe.getMaxDamage(); + brokenPickaxe.setDamageValue((int) (maxDamage * criticalDamageFraction)); + npc.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND, brokenPickaxe); + + alwaysFireSensor().tick(level, npc); + + List needs = npc.getBrain() + .getMemory(ModMemoryTypes.NEEDED_MATERIALS.get()) + .orElse(List.of()); + boolean hasDamagedEntry = needs.stream().anyMatch(s -> s.contains(":damaged:")); + helper.assertTrue(hasDamagedEntry, + "Sensor must report damaged main-hand tool; needs=" + needs); + helper.succeed(); + } + + /** + * When both hand slots are empty the sensor must report at least two + * {@code ":empty"} entries — one per hand. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testInventoryStateSensorEmptyInventoryReportsEmptyHands(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + alwaysFireSensor().tick(level, npc); + + List needs = npc.getBrain() + .getMemory(ModMemoryTypes.NEEDED_MATERIALS.get()) + .orElse(List.of()); + long emptyCount = needs.stream().filter(s -> s.endsWith(":empty")).count(); + helper.assertTrue(emptyCount >= 2, + "Sensor must report at least 2 empty-hand entries; needs=" + needs); + helper.succeed(); + } + + // ==================== ContextualToolSwapBehavior tests ==================== + + /** + * Given objective {@code "mine_stone"}, {@link ContextualToolSwapBehavior} + * must swap an iron pickaxe from the inventory into the main hand. + * + *

The public {@link org.dizzymii.millenaire2.entity.brain.smartbrain.ExtendedBehaviour#tryStart} + * method is used to invoke the behaviour without breaking encapsulation. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testToolSwapPickaxeForMineObjective(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + npc.getBrain().setMemory(ModMemoryTypes.MACRO_OBJECTIVE.get(), "mine_stone"); + npc.addToCarriedInventory(new ItemStack(Items.IRON_PICKAXE)); + + new ContextualToolSwapBehavior().tryStart(level, npc, level.getGameTime()); + + ItemStack mainHand = npc.getItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND); + helper.assertTrue(mainHand.getItem() instanceof net.minecraft.world.item.PickaxeItem, + "Main hand must hold a pickaxe after swap for mine_stone; got: " + + mainHand.getItem().getDescriptionId()); + helper.succeed(); + } + + /** + * Given objective {@code "defend"}, the behaviour must swap a sword into + * the main hand when the NPC's carried inventory contains one. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testToolSwapSwordForDefendObjective(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + npc.getBrain().setMemory(ModMemoryTypes.MACRO_OBJECTIVE.get(), "defend"); + npc.addToCarriedInventory(new ItemStack(Items.IRON_SWORD)); + + new ContextualToolSwapBehavior().tryStart(level, npc, level.getGameTime()); + + ItemStack mainHand = npc.getItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND); + helper.assertTrue(mainHand.getItem() instanceof net.minecraft.world.item.SwordItem, + "Main hand must hold a sword after swap for defend; got: " + + mainHand.getItem().getDescriptionId()); + helper.succeed(); + } + + /** + * Given objective {@code "gather_wood"}, the behaviour must swap an axe into + * the main hand when the NPC's carried inventory contains one. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testToolSwapAxeForGatherWoodObjective(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + npc.getBrain().setMemory(ModMemoryTypes.MACRO_OBJECTIVE.get(), "gather_wood"); + npc.addToCarriedInventory(new ItemStack(Items.IRON_AXE)); + + new ContextualToolSwapBehavior().tryStart(level, npc, level.getGameTime()); + + ItemStack mainHand = npc.getItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND); + helper.assertTrue(mainHand.getItem() instanceof net.minecraft.world.item.AxeItem, + "Main hand must hold an axe after swap for gather_wood; got: " + + mainHand.getItem().getDescriptionId()); + helper.succeed(); + } + + /** + * When the NPC's main hand already holds an optimal tool for the active + * objective, {@code tryStart} must return {@code false} — no swap occurs. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testToolSwapNoSwapWhenAlreadyOptimal(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + npc.getBrain().setMemory(ModMemoryTypes.MACRO_OBJECTIVE.get(), "mine_stone"); + // Place the pickaxe directly in the main hand — already optimal. + npc.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND, + new ItemStack(Items.IRON_PICKAXE)); + + boolean started = new ContextualToolSwapBehavior().tryStart(level, npc, level.getGameTime()); + helper.assertFalse(started, + "tryStart must return false when main hand already holds the optimal tool"); + helper.succeed(); + } + + /** + * When the NPC's carried inventory contains no tool matching the objective, + * the behaviour must leave the main hand unchanged (no crash; main hand stays empty). + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testToolSwapNoOpWhenNoMatchingTool(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + + npc.getBrain().setMemory(ModMemoryTypes.MACRO_OBJECTIVE.get(), "mine_stone"); + // Inventory has only a sword — no pickaxe available. + npc.addToCarriedInventory(new ItemStack(Items.IRON_SWORD)); + + new ContextualToolSwapBehavior().tryStart(level, npc, level.getGameTime()); + + // Main hand must remain empty since no matching tool was found. + ItemStack mainHand = npc.getItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND); + helper.assertTrue(mainHand.isEmpty(), + "Main hand must remain empty when no matching tool is in inventory; got: " + + mainHand.getItem().getDescriptionId()); + helper.succeed(); + } + + // ==================== Base-location memory tests ==================== + + /** + * {@link HumanoidNPC#setBaseLocation} must write a {@link net.minecraft.core.GlobalPos} + * to the {@link ModMemoryTypes#BASE_LOCATION} memory slot. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testSetBaseLocationStoresMemory(GameTestHelper helper) { + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + BlockPos abs = helper.absolutePos(new BlockPos(1, 1, 1)); + net.minecraft.core.GlobalPos gp = net.minecraft.core.GlobalPos.of( + helper.getLevel().dimension(), abs); + + npc.setBaseLocation(gp); + + Optional mem = + npc.getBrain().getMemory(ModMemoryTypes.BASE_LOCATION.get()); + helper.assertTrue(mem.isPresent(), + "BASE_LOCATION memory must be present after setBaseLocation"); + helper.assertTrue(mem.get().pos().equals(abs), + "BASE_LOCATION pos must match the pos that was set"); + helper.succeed(); + } + + /** + * Passing {@code null} to {@link HumanoidNPC#setBaseLocation} must erase the + * {@link ModMemoryTypes#BASE_LOCATION} memory. + */ + @GameTest(template = "empty", timeoutTicks = 40) + public static void testClearBaseLocationErasesMemory(GameTestHelper helper) { + HumanoidNPC npc = spawnNpc(helper, 1, 1, 1); + BlockPos abs = helper.absolutePos(new BlockPos(1, 1, 1)); + npc.setBaseLocation(net.minecraft.core.GlobalPos.of( + helper.getLevel().dimension(), abs)); + + npc.setBaseLocation(null); + + Optional mem = + npc.getBrain().getMemory(ModMemoryTypes.BASE_LOCATION.get()); + helper.assertFalse(mem.isPresent(), + "BASE_LOCATION memory must be absent after setBaseLocation(null)"); + helper.succeed(); + } +}