Repository: CleverRaven/Cataclysm-DDA (2026-04-16T09:35:04Z)

Issue:
Auto sorting items not inside Unsorted zone causes camera/view to stay centered on Unsorted Zone
### Describe the bug

When trying to sort items, I had the items to be sorted on the wrong tile that is not within the Unsorted Zone. I pressed (O) to sort all items, and the view became stuck within the center where the Unsorted Zone was when I started the action. I can still move, but the view does not follow.
One way I found to “fix” this bug in-game is by hauling items into the appropriate tile within the defined Unsorted Zone and then pressing (O) again to sort the items. This fixes the view and properly sorts the items.
After further testing, I found that this also happens when sorting items that are inside the Unsorted Zone but do not have any defined zones that fit them. For example, if there is only a Spare Parts zone but no Books or Default zone, it will sort planks correctly. However, if you try to sort a book without a specific Books or Default zone, the issue occurs even if the item is inside the Unsorted Zone.

### Attach save file

[Waverly-trimmed.tar.gz](https://github.com/user-attachments/files/26713075/Waverly-trimmed.tar.gz)

### Steps to reproduce

The save file attached has already been prepared, so just follow the steps.

1. After loading into the save, press O to sort the items below the character.
2. The view should be centered onto the Unsorted Zone, but nothing happens. The character will still be on the tile and able to move, but the view will not center on the character.

"Fixing" it in-game:

1. Haul the items one step north, where the Unsorted Zone is.
2. Press O to sort the items.
3. The items should be sorted inside the refugee center within the prepared Default Zone and the camera will return to centering on the player again, even when moving.


### Expected behavior

Nothing should happen when trying to sort items when the items are inside the wrong tiles while having already set up the proper zones. There should only be an error popup or something similar.

### Screenshots

_No response_

### Versions and configuration

- OS: Android
    - OS Version: Manufacturer: ITEL; Model: itel A663LC; Release: 13; Incremental: GL-250516V1115; 
- Game Version: cdda-experimental-2026-04-13-1304 a518665-dirty [64-bit]
- Graphics Version: Tiles
- Game Language: System language []
- Mods loaded: [
    Dark Days Ahead [dda]
]

### Additional context

_No response_


Discussion:

Patch:
diff --git a/src/activity_actor.cpp b/src/activity_actor.cpp
index 38015dfaf3873..6464d66107fd2 100644
--- a/src/activity_actor.cpp
+++ b/src/activity_actor.cpp
@@ -13463,14 +13463,18 @@ void zone_sort_activity_actor::do_turn( player_activity &act, Character &you )
 
     // True completion: activity nulled AND no auto-move destination pending.
     // route_to_destination sets destination before nulling; true end does not.
-    // Use saved locals since this may be destroyed.
-    if( act.is_null() && !you.has_destination() && had_viewport ) {
+    // vp_active catches same-turn completion where had_viewport is stale.
+    avatar *const av = you.as_avatar();
+    const bool vp_active = av != nullptr && av->zone_sort_viewport.active;
+    if( act.is_null() && !you.has_destination() && ( had_viewport || vp_active ) ) {
         if( !test_mode ) {
-            g->set_zoom( saved_zoom );
+            const int restore_zoom = vp_active ?
+                                     av->zone_sort_viewport.saved_zoom : saved_zoom;
+            g->set_zoom( restore_zoom );
             g->mark_main_ui_adaptor_resize();
         }
-        if( you.is_avatar() ) {
-            you.as_avatar()->zone_sort_viewport = {};
+        if( vp_active ) {
+            av->zone_sort_viewport = {};
         }
     }
 }
@@ -13523,6 +13527,7 @@ void zone_sort_activity_actor::stage_init( player_activity &, Character &you )
         vp.active = true;
         vp.center = bbox.centroid;
         vp.target_zoom = target;
+        vp.saved_zoom = viewport_saved_zoom;
         vp.bbox_min = bbox.min_corner;
         vp.bbox_max = bbox.max_corner;
         if( !test_mode ) {
diff --git a/src/avatar.h b/src/avatar.h
index d2d20a99b2b7d..fd6401faec8d9 100644
--- a/src/avatar.h
+++ b/src/avatar.h
@@ -98,6 +98,7 @@ class avatar : public Character
             bool active = false;
             tripoint_abs_ms center;
             int target_zoom = 0;
+            int saved_zoom = 0;
             tripoint_abs_ms bbox_min;
             tripoint_abs_ms bbox_max;
         };


Test patch:
diff --git a/tests/clzones_test.cpp b/tests/clzones_test.cpp
index 2d47cb8fcee53..93b82b9e8d89c 100644
--- a/tests/clzones_test.cpp
+++ b/tests/clzones_test.cpp
@@ -2640,6 +2640,45 @@ TEST_CASE( "zone_sort_viewport_lifecycle", "[zones][viewport][activities]" )
         }
     }
 
+    SECTION( "deactivates when nothing to sort" ) {
+        // Remove the apple so unsorted zone is empty.
+        map_stack items_at_src = here.i_at( src_pos );
+        items_at_src.clear();
+
+        CHECK_FALSE( dummy.zone_sort_viewport.active );
+
+        dummy.assign_activity( zone_sort_activity_actor() );
+
+        // Single turn: stage_init activates viewport, stage_think finds
+        // nothing sortable, activity ends in the same do_turn call.
+        dummy.mod_moves( dummy.get_speed() );
+        while( dummy.get_moves() > 0 && dummy.activity ) {
+            dummy.activity.do_turn( dummy );
+        }
+
+        CHECK_FALSE( dummy.activity );
+        CHECK_FALSE( dummy.zone_sort_viewport.active );
+    }
+
+    SECTION( "deactivates when no matching destination zone" ) {
+        // Replace apple with something that has no destination zone.
+        map_stack items_at_src = here.i_at( src_pos );
+        items_at_src.clear();
+        here.add_item_or_charges( src_pos, item( itype_test_heavy_boulder ) );
+
+        CHECK_FALSE( dummy.zone_sort_viewport.active );
+
+        dummy.assign_activity( zone_sort_activity_actor() );
+
+        dummy.mod_moves( dummy.get_speed() );
+        while( dummy.get_moves() > 0 && dummy.activity ) {
+            dummy.activity.do_turn( dummy );
+        }
+
+        CHECK_FALSE( dummy.activity );
+        CHECK_FALSE( dummy.zone_sort_viewport.active );
+    }
+
     SECTION( "viewport_saved_zoom survives serialize roundtrip" ) {
         dummy.assign_activity( zone_sort_activity_actor() );