Repository: CleverRaven/Cataclysm-DDA (2026-04-07T12:58:47Z)

Issue:
Bandits decided to take a nap after aggroing on me
### Describe the bug

I got the refugee center mission from Smokes to deal with the caravan raiders. However as soon as I got in range of the bandits they became angry and then immediately fell asleep, allowing me to punch them trivially.

### Attach save file

n/a

### Steps to reproduce

1. Get Smokes' caravan quest (immediately after Back Bay)
2. Approach bandits
3. Observe them sleeping?

### Expected behavior

Bandits don't immediately go to bed after they see me

### Screenshots

<img width="1086" height="157" alt="Image" src="https://github.com/user-attachments/assets/5f6475e2-e5b5-40d2-82cc-df4a7741f0fd" />

### Versions and configuration

- OS: Windows
    - OS Version: 10.0.26200.8037 (25H2)
- Game Version: cdda-experimental-2026-04-06-0609-dirty [64-bit]
- Graphics Version: Tiles
- Game Language: English [en]
- Mods loaded: [
    Dark Days Ahead [dda],
    Disable NPC Needs [no_npc_food],
    Bionic Slots [cbm_slots],
    Portal Storms Ignore NPCs [personal_portal_storms],
    Mind Over Matter [mindovermatter],
    Bombastic Perks [bombastic_perks],
    Magiclysm [magiclysm],
    Xedra Evolved [xedra_evolved],
    DinoMod [DinoMod],
    Railroads [railroads]
]


### Additional context

_No response_


Discussion:
I have also seen this, but for me it took the form of a wild NPC falling asleep while bandits were coming for us. I tried yelling at her to wake up, but she was too sleepy. Ironically she found a nearby tent to nap in while I had to fight off the bandits and then the zombies drawn by the noise. I found her after, still asleep and completely fine. 



Patch:
diff --git a/src/npcmove.cpp b/src/npcmove.cpp
index 830e962868431..43f4c3b31fb14 100644
--- a/src/npcmove.cpp
+++ b/src/npcmove.cpp
@@ -994,8 +994,8 @@ void npc::assess_danger()
     }
     if( is_friendly( player_character ) && sees_player ) {
         ai_cache.friends.emplace_back( g->shared_from( player_character ) );
-    } else if( sees_player && is_enemy() && sees( here, player_character ) ) {
-        // Unlike allies, hostile npcs should not see invisible players
+    } else if( sees_player && guaranteed_hostile() && sees( here, player_character ) ) {
+        // Includes faction hostility, not just the attitude enum.
         ai_cache.hostile_guys.emplace_back( g->shared_from( player_character ) );
     }
 
@@ -1209,10 +1209,12 @@ void npc::assess_danger()
         // NPC will try hard not to break and run while in formation.
         // This code should eventually remove the 'player' special case and be applied to
         // whoever the NPC perceives as their closest leader.
-        float player_diff = std::max( evaluate_character( player_character, npc_ranged, is_enemy() ),
+        const bool hostile_to_player = guaranteed_hostile();
+        float player_diff = std::max( evaluate_character( player_character, npc_ranged,
+                                      hostile_to_player ),
                                       NPC_DANGER_VERY_LOW );
         int dist = rl_dist( pos_bub(), player_character.pos_bub() );
-        if( is_enemy() ) {
+        if( hostile_to_player ) {
             add_msg_debug( debugmode::DF_NPC_COMBATAI,
                            "<color_light_gray>%s identified player as an</color> <color_red>enemy</color> <color_light_gray>of threat level %1.2f</color>",
                            name, player_diff );
@@ -1587,6 +1589,8 @@ void npc::move()
         } else {
             set_attitude( NPCATT_KILL );    // Yeah, we think we could take you!
         }
+        // Rebuild cache so danger/target reflect the new attitude.
+        regen_ai_cache();
     }
 
     // Top-level decision BT: evaluate before side-effecting cascade for convergence


Test patch:
diff --git a/tests/npc_test.cpp b/tests/npc_test.cpp
index d952c5f88dd15..4bd8cab5d3cc5 100644
--- a/tests/npc_test.cpp
+++ b/tests/npc_test.cpp
@@ -1386,6 +1386,67 @@ TEST_CASE( "npc_nonally_sleeps_when_tired", "[npc][needs]" )
     }
 }
 
+// Thugs are guaranteed_hostile() by faction but start NPCATT_NULL.
+// assess_danger() must detect faction hostility so combat beats sleep.
+
+TEST_CASE( "faction_hostile_npc_detects_player_threat", "[npc][npc_ai]" )
+{
+    g->faction_manager_ptr->create_if_needed();
+    clear_map_without_vision();
+    clear_avatar();
+    set_time_to_day();
+
+    Character &player_character = get_player_character();
+    npc &bandit = spawn_npc( player_character.pos_bub().xy() + point::south, "thug" );
+    REQUIRE( rl_dist( player_character.pos_bub(), bandit.pos_bub() ) <= 1 );
+
+    // Thug is guaranteed_hostile by faction, but attitude has not flipped yet.
+    REQUIRE( bandit.guaranteed_hostile() );
+    REQUIRE_FALSE( bandit.is_enemy() );
+
+    bandit.regen_ai_cache();
+
+    // assess_danger() must count the player as a threat even before the
+    // attitude enum flips to NPCATT_KILL.
+    CHECK( bandit.get_ai_danger() > 0 );
+    CHECK( bandit.current_target() == static_cast<Creature *>( &player_character ) );
+}
+
+TEST_CASE( "faction_hostile_tired_npc_fights_not_sleeps", "[npc][npc_ai][needs]" )
+{
+    g->faction_manager_ptr->create_if_needed();
+    clear_map_without_vision();
+    clear_avatar();
+    // 30+ minutes past turn_zero so can_sleep() cooldown does not interfere.
+    calendar::turn = calendar::turn_zero + 1_hours;
+
+    Character &player_character = get_player_character();
+    npc &bandit = spawn_npc( player_character.pos_bub().xy() + point( 0, 3 ), "thug" );
+    clear_character( bandit, true );
+    bandit.set_hunger( 0 );
+    bandit.set_thirst( 0 );
+    bandit.set_stored_kcal( bandit.get_healthy_kcal() );
+    bandit.set_all_parts_temp_conv( BODYTEMP_NORM );
+    bandit.set_all_parts_temp_cur( BODYTEMP_NORM );
+    // Exhausted -- would sleep if no danger present.
+    bandit.set_sleepiness( 800 );
+    bandit.set_moves( 100 );
+
+    // Thug is guaranteed_hostile by faction, but attitude starts neutral.
+    // The move() pipeline must detect the player as a threat and choose
+    // combat over sleep.
+    REQUIRE( bandit.guaranteed_hostile() );
+    REQUIRE_FALSE( bandit.is_enemy() );
+
+    bandit.move();
+
+    // Must NOT have fallen asleep.
+    CHECK_FALSE( bandit.has_effect( effect_sleep ) );
+    CHECK_FALSE( bandit.has_effect( effect_lying_down ) );
+    // The attitude must have flipped to hostile.
+    CHECK( bandit.is_enemy() );
+}
+
 // ---------------------------------------------------------------------------
 // Helper-level tests for local resource acquisition
 // ---------------------------------------------------------------------------