Repository: CleverRaven/Cataclysm-DDA (2026-04-09T08:47:31Z)

Issue:
NPCs won't stop moving when assigned to an expanded base
### Describe the bug

If assigned to a mansion camp that has been expanded to at least one additional tile, NPCs will rapidly walk between the two tiles, seemingly between two 'sittable' surfaces like a couch in one tile and a bench in another.

I haven't tested other base types, but I suspect I'd see the same behavior as long as there are two or more tiles with spaces for them to sit.

### Attach save file

[Mansion check - standard-trimmed.tar.gz](https://github.com/user-attachments/files/26591481/Mansion.check.-.standard-trimmed.tar.gz)

### Steps to reproduce

1) Start a faction camp at a mansion
2) Assign an NPC to the camp
3) Observe normal "seeks a surface to sit on" behavior
4) Go to bulletin board and expand the base
5) Wait 3 hours
6) Pick a tile to expand to
7) Observe the NPC assigned returns to their normal position
8) Move the NPC from their position. IE: swap positions, or unassign and then re-assign to the camp.
9) Watch them go back and forth between the two tiles endlessly.

### Expected behavior

NPCs without jobs stay in one place while idle. Possibly moving to go to sleep somewhere comfortable.

### Screenshots

_No response_

### Versions and configuration

- OS: Windows
    - OS Version: 10.0.19045.6466 (22H2)
- Game Version: cdda-experimental-2026-04-07-2033-dirty [64-bit]
- Graphics Version: Tiles
- Game Language: System language []
- Mods loaded: [
    Dark Days Ahead [dda],
    Disable NPC Needs [no_npc_food],
    Portal Storms Ignore NPCs [personal_portal_storms]
]


### Additional context

Provided save is at Step 5 of instructions for ease of testing.


Discussion:
Can not reproduce exactly what you describe, but found one oscillation. Will test deeper and try to fix it.
Yeah right the expanded tiles are not considered "base", so they constantly oscillate between "go to camp" and "rest"
/confirmed



Patch:
diff --git a/src/character_oracle.cpp b/src/character_oracle.cpp
index 744b56d2623c0..7e6e10b08df52 100644
--- a/src/character_oracle.cpp
+++ b/src/character_oracle.cpp
@@ -6,6 +6,7 @@
 #include <optional>
 #include <vector>
 
+#include "basecamp.h"
 #include "behavior.h"
 #include "bodypart.h"
 #include "calendar.h"
@@ -17,6 +18,7 @@
 #include "mapdata.h"
 #include "npc.h"
 #include "npc_class.h"
+#include "overmapbuffer.h"
 #include "point.h"
 #include "ret_val.h"
 #include "stomach.h"
@@ -30,6 +32,22 @@ static const efftype_id effect_npc_run_away( "npc_run_away" );
 static const json_character_flag json_flag_CANNOT_MOVE( "CANNOT_MOVE" );
 static const trait_id trait_IGNORE_SOUND( "IGNORE_SOUND" );
 
+// Whether the NPC is on any tile belonging to their assigned camp,
+// including expansions.  Falls back to base-OMT match when the camp
+// object is gone (abandoned) but assigned_camp is still set.
+static bool npc_within_camp( const npc &n )
+{
+    if( !n.assigned_camp ) {
+        return false;
+    }
+    std::optional<basecamp *> bcp = overmap_buffer.find_camp( n.assigned_camp->xy() );
+    if( !bcp || !*bcp ) {
+        // Camp object gone -- fall back to base-OMT check.
+        return n.pos_abs_omt() == *n.assigned_camp;
+    }
+    return ( *bcp )->point_within_camp( n.pos_abs_omt() );
+}
+
 namespace behavior
 {
 
@@ -436,7 +454,7 @@ status_t character_oracle_t::has_camp_job( std::string_view ) const
     if( !n || !n->assigned_camp || n->mission != NPC_MISSION_CAMP_RESIDENT ) {
         return status_t::failure;
     }
-    if( n->pos_abs_omt() != *n->assigned_camp ) {
+    if( !npc_within_camp( *n ) ) {
         return status_t::failure;
     }
     if( !n->has_job() ) {
@@ -454,8 +472,7 @@ status_t character_oracle_t::is_away_from_camp( std::string_view ) const
     if( !n || !n->assigned_camp || n->mission != NPC_MISSION_CAMP_RESIDENT ) {
         return status_t::failure;
     }
-    return n->pos_abs_omt() != *n->assigned_camp
-           ? status_t::running : status_t::failure;
+    return npc_within_camp( *n ) ? status_t::failure : status_t::running;
 }
 
 status_t character_oracle_t::is_camp_idle( std::string_view ) const
@@ -467,7 +484,7 @@ status_t character_oracle_t::is_camp_idle( std::string_view ) const
     if( n->get_attitude() == NPCATT_ACTIVITY ) {
         return status_t::failure;
     }
-    if( n->pos_abs_omt() != *n->assigned_camp ) {
+    if( !npc_within_camp( *n ) ) {
         return status_t::failure;
     }
     return status_t::running;
@@ -477,7 +494,7 @@ float character_oracle_t::camp_work_urgency( std::string_view ) const
 {
     const npc *n = dynamic_cast<const npc *>( subject );
     if( !n || !n->assigned_camp || n->mission != NPC_MISSION_CAMP_RESIDENT
-        || n->pos_abs_omt() != *n->assigned_camp || !n->has_job() ) {
+        || !npc_within_camp( *n ) || !n->has_job() ) {
         return 0.0f;
     }
     return 0.4f;
@@ -487,7 +504,7 @@ float character_oracle_t::return_to_camp_urgency( std::string_view ) const
 {
     const npc *n = dynamic_cast<const npc *>( subject );
     if( !n || !n->assigned_camp || n->mission != NPC_MISSION_CAMP_RESIDENT
-        || n->pos_abs_omt() == *n->assigned_camp ) {
+        || npc_within_camp( *n ) ) {
         return 0.0f;
     }
     // Below follow max (0.6), above duty (0.45).
@@ -499,7 +516,7 @@ float character_oracle_t::free_time_urgency( std::string_view ) const
     const npc *n = dynamic_cast<const npc *>( subject );
     if( !n || !n->assigned_camp || n->mission != NPC_MISSION_CAMP_RESIDENT
         || n->get_attitude() == NPCATT_ACTIVITY
-        || n->pos_abs_omt() != *n->assigned_camp ) {
+        || !npc_within_camp( *n ) ) {
         return 0.0f;
     }
     return 0.35f;
diff --git a/src/npctalk_funcs.cpp b/src/npctalk_funcs.cpp
index 7c4c2db4825f6..c7ba89952f6b9 100644
--- a/src/npctalk_funcs.cpp
+++ b/src/npctalk_funcs.cpp
@@ -480,7 +480,16 @@ void talk_function::return_to_camp_duties( npc &p )
     p.clear_ai_guard_pos();
     p.clear_committed_goal();
     p.chatbin.first_topic = "TALK_FRIEND_CAMP_RESIDENT";
-    if( p.assigned_camp && p.pos_abs_omt() != *p.assigned_camp ) {
+    // Check whether the NPC is already within the camp footprint
+    // (including expansion tiles), not just the base OMT.
+    bool at_camp = false;
+    if( p.assigned_camp ) {
+        std::optional<basecamp *> bcp = overmap_buffer.find_camp( p.assigned_camp->xy() );
+        at_camp = ( bcp && *bcp )
+                  ? ( *bcp )->point_within_camp( p.pos_abs_omt() )
+                  : p.pos_abs_omt() == *p.assigned_camp;
+    }
+    if( p.assigned_camp && !at_camp ) {
         p.goal = *p.assigned_camp;
         tripoint_abs_omt surface = p.pos_abs_omt();
         surface.z() = 0;


Test patch:
diff --git a/tests/behavior_test.cpp b/tests/behavior_test.cpp
index 02fe426062384..c543c8ff32ddb 100644
--- a/tests/behavior_test.cpp
+++ b/tests/behavior_test.cpp
@@ -8,6 +8,7 @@
 #include <unordered_map>
 #include <vector>
 
+#include "basecamp.h"
 #include "behavior.h"
 #include "behavior_oracle.h"
 #include "behavior_strategy.h"
@@ -40,6 +41,7 @@
 #include "npc.h"
 #include "npc_class.h"
 #include "options_helpers.h"
+#include "overmapbuffer.h"
 #include "player_helpers.h"
 #include "pocket_type.h"
 #include "point.h"
@@ -1701,7 +1703,12 @@ TEST_CASE( "bt_priority_matrix", "[npc][behavior]" )
     }
     SECTION( "camp resident idle: free_time" ) {
         guy.set_mission( NPC_MISSION_CAMP_RESIDENT );
-        guy.assigned_camp = project_to<coords::omt>( guy.pos_abs() );
+        const tripoint_abs_omt comt = project_to<coords::omt>( guy.pos_abs() );
+        here.add_camp( comt, "faction_camp" );
+        std::optional<basecamp *> cbcp = overmap_buffer.find_camp( comt.xy() );
+        REQUIRE( cbcp );
+        ( *cbcp )->define_camp( comt, "faction_base_bare_bones_NPC_camp_0", false );
+        guy.assigned_camp = comt;
         guy.guard_pos = std::nullopt;
         guy.clear_ai_guard_pos();
         CHECK( bt_goal( guy ) == "free_time" );
@@ -1751,7 +1758,15 @@ TEST_CASE( "bt_camp_resident_goals", "[npc][behavior]" )
     clear_character( guy, true );
     guy.set_fac( faction_your_followers );
     guy.set_mission( NPC_MISSION_CAMP_RESIDENT );
-    guy.assigned_camp = project_to<coords::omt>( guy.pos_abs() );
+
+    // Create a real camp so the BT predicates can resolve it.
+    const tripoint_abs_omt camp_omt = project_to<coords::omt>( guy.pos_abs() );
+    here.add_camp( camp_omt, "faction_camp" );
+    std::optional<basecamp *> bcp = overmap_buffer.find_camp( camp_omt.xy() );
+    REQUIRE( bcp );
+    ( *bcp )->define_camp( camp_omt, "faction_base_bare_bones_NPC_camp_0", false );
+
+    guy.assigned_camp = camp_omt;
     guy.guard_pos = std::nullopt;
     guy.clear_ai_guard_pos();
 
@@ -1776,6 +1791,63 @@ TEST_CASE( "bt_camp_resident_goals", "[npc][behavior]" )
     }
 }
 
+TEST_CASE( "bt_camp_resident_expansion_tile", "[npc][behavior]" )
+{
+    clear_map_without_vision();
+    clear_avatar();
+    map &here = get_map();
+
+    // Place camp at mid-map so we control OMT alignment.
+    const tripoint_bub_ms mid{ MAPSIZE_X / 2, MAPSIZE_Y / 2, 0 };
+    const tripoint_abs_omt camp_omt = project_to<coords::omt>( here.get_abs( mid ) );
+
+    here.add_camp( camp_omt, "faction_camp" );
+    std::optional<basecamp *> bcp = overmap_buffer.find_camp( camp_omt.xy() );
+    REQUIRE( bcp );
+    basecamp *test_camp = *bcp;
+    test_camp->define_camp( camp_omt, "faction_base_bare_bones_NPC_camp_0", false );
+
+    // Add an expansion one OMT to the east.
+    const tripoint_abs_omt expansion_omt = camp_omt + tripoint::east;
+    test_camp->add_expansion( "faction_base_farm_0", expansion_omt,
+                              point_rel_omt{ 1, 0 } );
+    REQUIRE( test_camp->point_within_camp( expansion_omt ) );
+
+    // Spawn NPC anywhere, then teleport to the expansion tile.
+    npc &guy = spawn_npc( mid.xy(), "test_talker" );
+    clear_character( guy, true );
+    const tripoint_abs_ms expansion_center =
+        project_to<coords::ms>( expansion_omt ) + tripoint{ SEEX, SEEY, 0 };
+    const tripoint_bub_ms expansion_local = here.get_bub( expansion_center );
+    REQUIRE( here.inbounds( expansion_local ) );
+    guy.setpos( here, expansion_local );
+    guy.set_fac( faction_your_followers );
+    guy.set_mission( NPC_MISSION_CAMP_RESIDENT );
+    guy.assigned_camp = camp_omt;
+    guy.guard_pos = std::nullopt;
+    guy.clear_ai_guard_pos();
+
+    REQUIRE( guy.pos_abs_omt() == expansion_omt );
+    REQUIRE( guy.pos_abs_omt() != camp_omt );
+
+    SECTION( "on expansion tile: free_time, not return_to_camp" ) {
+        CHECK( bt_goal( guy ) == "free_time" );
+    }
+    SECTION( "on expansion tile: camp predicates treat as at-camp" ) {
+        behavior::character_oracle_t oracle( &guy );
+        CHECK( oracle.is_camp_idle( "" ) == behavior::status_t::running );
+        CHECK( oracle.is_away_from_camp( "" ) == behavior::status_t::failure );
+    }
+    SECTION( "on expansion tile with job: camp_work fires" ) {
+        guy.job.set_all_priorities( 1 );
+        REQUIRE( guy.has_job() );
+        guy.last_job_scan = calendar::turn - 11_minutes;
+        behavior::character_oracle_t oracle( &guy );
+        CHECK( oracle.has_camp_job( "" ) == behavior::status_t::running );
+        CHECK( oracle.camp_work_urgency( "" ) > 0.0f );
+    }
+}
+
 TEST_CASE( "bt_goal_category_mapping_camp", "[npc][behavior]" )
 {
     CHECK( bt_goal_to_category( "camp_work" ) == decision_category::camp_work );