Repository: fiveofswords/botc-bot
Pull request #87: Reorder st chats on reseat
Author: login: jking-roar
Created: 2025-05-13T01:23:52Z | Merged: 2025-05-13T03:15:10Z



Diff:
diff --git a/bot_impl.py b/bot_impl.py
index e13e24c..530e61e 100644
--- a/bot_impl.py
+++ b/bot_impl.py
@@ -25,6 +25,7 @@
 from model.player import Player, STORYTELLER_ALIGNMENT
 from model.settings import GlobalSettings, GameSettings
 from time_utils import parse_deadline
+from model.channels.channel_utils import reorder_channels
 from utils.character_utils import the_ability
 from utils.message_utils import safe_send
 from utils.player_utils import check_and_print_if_one_or_zero_to_check_in
@@ -164,14 +165,6 @@ async def yes_no(user, text):
         )
 
 
-async def reorder_channels(st_channels: list[TextChannel]):
-    for st in global_vars.gamemaster_role.members:
-        await safe_send(st, "Setting up channels for game...")
-    await ChannelManager(client).setup_channels_in_order(st_channels)
-    for st in global_vars.gamemaster_role.members:
-        await safe_send(st, "Channels setup successfully!")
-
-
 async def get_player(user) -> Optional[Player]:
     # returns the Player object corresponding to user
     if global_vars.game is NULL_GAME:
@@ -325,28 +318,6 @@ async def aexec(code):
     return await locals()["__ex"]()
 
 
-async def safe_send(target: discord.abc.Messageable, msg: str):
-    """Messages target, with protection from message length errors.
-
-    Returns the first message.
-    """
-
-    try:
-        # This is the only place that should send the message raw. all other message sendings should use this function
-        return await target.send(msg)
-
-    except discord.HTTPException as e:
-        if e.code == 50035:
-
-            n = len(msg) // 2
-            out = await safe_send(target, msg[:n])
-            await safe_send(target, msg[n:])
-            return out
-
-        else:
-            raise (e)
-
-
 # NULL_GAME is imported from model.game
 
 ### Event Handling
@@ -1911,7 +1882,7 @@ async def on_message(message):
 
             # Clears history
             elif command == "clear":
-                await safe_send(message.author, "{}Clearing\n{}".format("\u200B\n" * 25, "\u200B\n" * 25))
+                await safe_send(message.author, "{}Clearing\n{}".format("\u200b\n" * 25, "\u200b\n" * 25))
                 return
 
             # Checks active players
diff --git a/model/channels/channel_manager.py b/model/channels/channel_manager.py
index b489de5..41c25ab 100644
--- a/model/channels/channel_manager.py
+++ b/model/channels/channel_manager.py
@@ -130,17 +130,63 @@ async def setup_channels_in_order(self, ordered_player_channels: list[TextChanne
         ordered_channels: list[TextChannel] = [self._hands_channel, self._observer_channel,
                                                self._info_channel, self._whisper_channel] + ordered_player_channels + [
                                                   self._town_square_channel]
-
+        ordered_channels_set = set(ordered_channels)
         to_move_out: list[TextChannel] = [channel for channel in self._in_play_category.channels if
-                                          channel not in ordered_channels]
+                                          channel not in ordered_channels_set]
+        to_move_in: list[TextChannel] = [channel for channel in ordered_channels if
+                                         channel.category != self._in_play_category]
+
+        for channel in to_move_in:
+            if channel.category != self._in_play_category:
+                await channel.move(category=self._in_play_category, end=True)
+                logger.debug(f"Channel {channel.name} has been moved to In Play category.")
+            else:
+                logger.debug(f"Channel {channel.name} is already in the correct category.")
 
         # Move unused channels out of play
         for channel in to_move_out:
             await channel.move(category=self._out_of_play_category, end=True)
             logger.debug(f"Channel {channel.name} has been moved to Out of Play category.")
 
-        # Move remaining channels in play in order
-        for index, channel in enumerate(ordered_channels):
-            if channel.category != self._in_play_category or channel.position != index:
-                await channel.edit(category=self._in_play_category, position=index)
-                logger.debug(f"{channel.name} has been moved to position {index} of {self._in_play_category.name}.")
+
+        num_needing_changed = None
+        attempt_num = 1
+        while attempt_num < 5:
+            # ordered_channels = [
+            #     self._client.get_channel(channel.id)
+            #     for channel in ordered_channels
+            # ]
+            #
+            sorted_positions = sorted([c.position for c in ordered_channels])
+            current_index = {p: i for i, p in enumerate(sorted_positions)}
+            current_position_for_index = {i: p for i, p in enumerate(sorted_positions)}
+            position_for_index = {i: c.position for i, c in enumerate(ordered_channels)}
+            # {0: 0, 1: 3, 2: 7, 3: 6, 4: 2, 5: 9, 6: 4}
+
+            # Find the channel with the largest distance from its desired position
+            distance_desired_and_channel = [
+                (abs(current_index[c.position] - index), index, c)
+                for index, c in enumerate(ordered_channels)
+                if current_index[c.position] != index
+            ]
+
+            new_num_needing_changed = len(distance_desired_and_channel)
+            if(new_num_needing_changed == num_needing_changed):
+                attempt_num += 1
+            else:
+                num_needing_changed = new_num_needing_changed
+                attempt_num = 1
+            if not distance_desired_and_channel:
+                break  # All channels are in the correct position
+
+            # Move the most out-of-place channel
+            _, idx, channel = max(distance_desired_and_channel)
+
+            # discord channels are 1 indexed
+            await channel.edit(position=current_position_for_index[idx] + 1)
+            logger.debug(f"{channel.name} has been moved to position {idx} of {self._in_play_category.name}.")
+        else:
+            logger.warning("Channel positions could not be set correctly after 5 attempts.")
+            return False
+        return True
+#
diff --git a/model/channels/channel_utils.py b/model/channels/channel_utils.py
new file mode 100644
index 0000000..0ca9f8d
--- /dev/null
+++ b/model/channels/channel_utils.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from discord import TextChannel
+
+import global_vars
+from utils.message_utils import safe_send
+from . import ChannelManager
+from bot_client import client
+
+
+async def reorder_channels(st_channels: list[TextChannel]):
+    for st in global_vars.gamemaster_role.members:
+        await safe_send(st, "Setting up channels for game...")
+    result = await ChannelManager(client).setup_channels_in_order(st_channels)
+    if not result:
+        for st in global_vars.gamemaster_role.members:
+            await safe_send(st, "Failed to set up channels. Please review the channel order.")
+        return
+    for st in global_vars.gamemaster_role.members:
+        await safe_send(st, "Channels setup successfully!")
diff --git a/model/game/game.py b/model/game/game.py
index 5f6badf..8b5103e 100644
--- a/model/game/game.py
+++ b/model/game/game.py
@@ -5,7 +5,7 @@
 from model.player import Player, STORYTELLER_ALIGNMENT
 from utils.game_utils import update_presence, remove_backup
 from utils.message_utils import safe_send
-
+from model.channels.channel_utils import reorder_channels
 
 class Game:
     """Represents a game of Blood on the Clocktower.
@@ -117,8 +117,7 @@ async def reseat(self, new_seating_order):
             person.position = index
 
         await self.seatingOrderMessage.edit(content=message_text)
-        #  fixme: reorder_channels is doing more work than needed. until that is fixed, don't reorder on reseat.
-        # await reorder_channels([x.st_channel for x in self.seatingOrder])
+        await reorder_channels([x.st_channel for x in self.seatingOrder])
 
     async def add_traveler(self, person):
         """Add a traveler to the game.
diff --git a/tests/core/test_game_class.py b/tests/core/test_game_class.py
index f959f38..cd6d210 100644
--- a/tests/core/test_game_class.py
+++ b/tests/core/test_game_class.py
@@ -13,7 +13,6 @@
 from model.game import Day
 from tests.test_bot_integration import mock_discord_setup, setup_test_game, MockChannel
 
-
 # Mock interfaces for testing since we can't create multiple inheritance with them
 class MockSeatingOrderModifier:
     def seating_order_message(self, seating_order):
@@ -85,13 +84,15 @@ async def test_reseat_method(mock_discord_setup, setup_test_game):
     game.seatingOrderMessage = mock_message
 
     # Create a new seating order by shuffling the current one
-    original_order = game.seatingOrder.copy()
     new_order = game.seatingOrder.copy()
+
     # Make one player a ghost to test special formatting
     new_order[0].is_ghost = True
 
     # Test reseat with new order
-    await game.reseat(new_order)
+    with patch('model.game.game.reorder_channels', new_callable=AsyncMock) as mock_reorder:
+        await game.reseat(new_order)
+        assert mock_reorder.called
 
     # Check positions were updated correctly
     for i, player in enumerate(new_order):
diff --git a/tests/discord/test_on_message.py b/tests/discord/test_on_message.py
index cc991e9..9efab10 100644
--- a/tests/discord/test_on_message.py
+++ b/tests/discord/test_on_message.py
@@ -750,4 +750,4 @@ async def test_clear_command(mock_discord_setup):
                 mock_discord_setup['members']['alice'],
                 mock_safe_send.call_args[0][1]
             )
-            assert "\u200B\n" in mock_safe_send.call_args[0][1]  # Contains whitespace
+            assert "\u200b\n" in mock_safe_send.call_args[0][1]  # Contains whitespace
diff --git a/tests/model/channels/test_channel_manager.py b/tests/model/channels/test_channel_manager.py
index 45e77fb..60d77fe 100644
--- a/tests/model/channels/test_channel_manager.py
+++ b/tests/model/channels/test_channel_manager.py
@@ -28,6 +28,13 @@ def setup_test(self):
         self.info_channel = MagicMock(spec=discord.TextChannel, name="info")
         self.whisper_channel = MagicMock(spec=discord.TextChannel, name="whisper")
         self.town_square_channel = MagicMock(spec=discord.TextChannel, name="town_square")
+
+        self.hands_channel.position = 0
+        self.observer_channel.position = 1
+        self.info_channel.position = 2
+        self.whisper_channel.position = 3
+        self.town_square_channel.position = 4
+
         self.channel_manager._hands_channel = self.hands_channel
         self.channel_manager._observer_channel = self.observer_channel
         self.channel_manager._info_channel = self.info_channel
@@ -161,44 +168,32 @@ async def test_create_channel(self):
     async def test_setup_channels_in_order_success(self):
         # Mock channels
         in_play_st_channels = [MagicMock(spec=discord.TextChannel, name=f'in_play_{i}') for i in range(5)]
+
         extra_channels = [MagicMock(spec=discord.TextChannel, name=f'out_of_play_{i}') for i in range(2)]
 
         self.in_play_category.channels = in_play_st_channels + extra_channels
-
+        for idx, channel in enumerate(self.in_play_category.channels):
+            channel.category = self.in_play_category
+            channel.position = (idx+6)*2
+
+        in_play_st_channels[0].position = 9999  # Ensure the last channel is out of order
+        for channel in [self.hands_channel, self.observer_channel, self.info_channel,
+                        self.whisper_channel] + in_play_st_channels + [self.town_square_channel] + extra_channels:
+            def make_edit_side_effect(ch):
+                async def edit_side_effect(*args, **kwargs):
+                    if 'position' in kwargs:
+                        ch.position = kwargs['position']
+                    if 'category' in kwargs:
+                        ch.category = kwargs['category']
+                return edit_side_effect
+            channel.edit = AsyncMock(side_effect=make_edit_side_effect(channel))
         # Execute
         result = await self.channel_manager.setup_channels_in_order(in_play_st_channels)
 
         # Verify
+        assert result is True
         expected_in_play_channels = [self.hands_channel, self.observer_channel, self.info_channel,
                                      self.whisper_channel] + in_play_st_channels + [self.town_square_channel]
         for channel in extra_channels:
             channel.move.assert_called_once_with(category=self.out_of_play_category, end=True)
-        for index, channel in enumerate(expected_in_play_channels):
-            channel.edit.assert_called_once_with(category=self.in_play_category, position=index)
-
-    @pytest.mark.asyncio
-    async def test_setup_channels_in_order_skips_in_place_channels(self):
-        # Setup:
-        # Put the hands channel in the correct position already
-        self.hands_channel.category = self.in_play_category
-        self.hands_channel.position = 0
-        # Create mock channels already in their correct positions
-        in_play_st_channels = [
-            MagicMock(spec=discord.TextChannel, name=f'in_play_{i}', category=self.in_play_category, position=i + 4)
-            for i in range(5)
-        ]
-
-        # Execute: Attempt to reorder channels, expecting no changes for already correctly placed channels
-        await self.channel_manager.setup_channels_in_order(in_play_st_channels)
-
-        # Verify: Assert that edit is not called on channels already in the correct position
-        self.hands_channel.edit.assert_not_called()
-        for channel in in_play_st_channels:
-            channel.edit.assert_not_called()
-
-        # Verify: Assert that edit is called on channels not in the list to ensure they are moved correctly
-        self.observer_channel.edit.assert_called_once_with(category=self.in_play_category, position=1)
-        self.info_channel.edit.assert_called_once_with(category=self.in_play_category, position=2)
-        self.whisper_channel.edit.assert_called_once_with(category=self.in_play_category, position=3)
-        self.town_square_channel.edit.assert_called_once_with(category=self.in_play_category,
-                                                              position=len(in_play_st_channels) + 4)
+        assert in_play_st_channels[0].edit.called # Ensure the first channel is moved out of last position
diff --git a/tests/test_bot_integration.py b/tests/test_bot_integration.py
index 9bbec14..1834f67 100644
--- a/tests/test_bot_integration.py
+++ b/tests/test_bot_integration.py
@@ -1844,7 +1844,7 @@ async def test_on_message_direct_utility_commands(mock_discord_setup, setup_test
         # The first argument should be the storyteller
         assert mock_safe_send.call_args[0][0] == mock_discord_setup['members']['storyteller']
         # The second argument should contain a bunch of whitespace
-        assert "\u200B\n" in mock_safe_send.call_args[0][1]
+        assert "\u200b\n" in mock_safe_send.call_args[0][1]
 
 
 @pytest.mark.asyncio
diff --git a/tests/utils/test_message_utils.py b/tests/utils/test_message_utils.py
index 7034dc0..7a248a5 100644
--- a/tests/utils/test_message_utils.py
+++ b/tests/utils/test_message_utils.py
@@ -7,114 +7,7 @@
 import discord
 import pytest
 
-# Import safe_send from both sources to test the functionality is consistent
-from bot_impl import safe_send as bot_safe_send
 from utils.message_utils import safe_send, safe_send_dm, _split_text
-from utils.message_utils import safe_send as utils_safe_send
-
-
-@pytest.fixture
-def mock_target():
-    """Create a mock target for sending messages."""
-    target = MagicMock()
-    target.send = AsyncMock(return_value=MagicMock())
-    return target
-
-
-@pytest.mark.asyncio
-async def test_safe_send_normal(mock_target):
-    """Test safe_send with normal message lengths."""
-    # Test with a normal message
-    message = "This is a test message that is well under the character limit."
-
-    # Test both implementations with the same inputs
-    bot_result = await bot_safe_send(mock_target, message)
-
-    # Reset mock calls
-    mock_target.send.reset_mock()
-
-    # Now test the utils version
-    utils_result = await utils_safe_send(mock_target, message)
-
-    # Verify send was called once with the message
-    mock_target.send.assert_called_once_with(message)
-
-    # Both implementations should return the same type of result
-    assert type(bot_result) == type(utils_result)
-
-
-@pytest.mark.asyncio
-async def test_safe_send_empty(mock_target):
-    """Test safe_send with an empty message."""
-    # Test with an empty message
-    message = ""
-
-    # Test the implementation
-    result = await bot_safe_send(mock_target, message)
-
-    # Verify send was called once with the empty message
-    mock_target.send.assert_called_once_with(message)
-
-    # Verify the result is what was returned by send
-    assert result == mock_target.send.return_value
-
-
-@pytest.mark.asyncio
-async def test_safe_send_split():
-    """Test safe_send with a message that needs to be split."""
-    # Create a mock target
-    mock_target = MagicMock()
-
-    # Create mock return values for the first and second sends
-    first_result = MagicMock()
-    second_result = MagicMock()
-
-    # Configure mock to raise exception on first call, then return values on subsequent calls
-    mock_exception = discord.HTTPException(MagicMock(), {"code": 50035})
-    mock_exception.code = 50035  # Ensure code attribute is set properly
-
-    mock_target.send = AsyncMock(side_effect=[mock_exception, first_result, second_result])
-
-    # Create a message
-    message = "A message that will be split"
-
-    # Mock the recursive calls to safe_send
-    with patch('bot_impl.safe_send', side_effect=[first_result, second_result]) as mock_safe_send:
-        # Call safe_send
-        result = await bot_safe_send(mock_target, message)
-
-        # Verify send was called with the original message
-        mock_target.send.assert_called_once_with(message)
-
-        # Verify result is the first successful send
-        assert result == first_result
-
-
-@pytest.mark.asyncio
-async def test_safe_send_other_exception():
-    """Test safe_send with a different HTTP exception."""
-    # Create a mock target
-    mock_target = MagicMock()
-
-    # Create exception with a different code
-    mock_exception = discord.HTTPException(MagicMock(), {"code": 40001})
-    mock_exception.code = 40001  # Ensure code attribute is set properly
-
-    # Configure mock to raise the exception
-    mock_target.send = AsyncMock(side_effect=mock_exception)
-
-    # Test with a normal message
-    message = "Test message"
-
-    # Should raise the exception
-    with pytest.raises(discord.HTTPException) as exc_info:
-        await bot_safe_send(mock_target, message)
-
-    # Verify the exception has the expected code
-    assert exc_info.value.code == 40001
-
-    # Verify send was called once with the message
-    mock_target.send.assert_called_once_with(message)
 
 
 class TestSplitText:


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR reorders storyteller channels during game reseat and refactors channel management logic. Key changes include the introduction of a new reorder_channels method in the utilities, updates to test cases (including adjustments to Unicode escape sequences), and modifications to the channel reordering logic in the ChannelManager class.

### Reviewed Changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                      | Description                                                            |
| ----------------------------------------- | ---------------------------------------------------------------------- |
| utils/channel_utils.py                    | Adds new reorder_channels function to manage channel setup messages.   |
| tests/utils/test_message_utils.py         | Removes redundant safe_send tests and updates Unicode formatting.      |
| tests/test_bot_integration.py             | Updates Unicode escape in test assertions for consistency.             |
| tests/model/channels/test_channel_manager.py | Refactors tests for channel ordering and adds assertions on channel edits. |
| tests/discord/test_on_message.py           | Updates Unicode escape in test assertions.                             |
| tests/core/test_game_class.py             | Patches reorder_channels during reseat testing.                        |
| model/game/game.py                        | Uncomments and activates the reorder_channels call on reseat.           |
| model/channels/channel_manager.py         | Refactors channel position adjustments with an iterative approach.     |
| bot_impl.py                                | Removes duplicate implementations and updates Unicode escape usage.    |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**tests/discord/test_on_message.py:753**
* [nitpick] The Unicode escape for ZERO WIDTH SPACE was changed from '\u200B' to '\u200b'. Confirm that this change is intentional and consistent with your project’s coding style.
```
assert "\u200b\n" in mock_safe_send.call_args[0][1]
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T01:24:27Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR updates the channel reordering logic to reorder storyteller chats during reseat operations. Key changes include:
- Addition of the reorder_channels function in utils/channel_utils.py.
- Updates to tests to validate proper channel reordering and to standardize the Unicode zero-width space usage.
- A refactored iterative reordering algorithm in model/channels/channel_manager.py to adjust channel positions.

### Reviewed Changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                         | Description                                                                                   |
|----------------------------------------------|-----------------------------------------------------------------------------------------------|
| utils/channel_utils.py                       | Introduces a new asynchronous function to send notifications and invoke channel reordering.   |
| tests/utils/test_message_utils.py            | Removes redundant test cases and updates Unicode space usage for message formatting.           |
| tests/test_bot_integration.py                | Adjusts assertions to check for consistent Unicode zero-width space usage in messages.         |
| tests/model/channels/test_channel_manager.py   | Modifies tests to ensure channel position updates are verified correctly.                      |
| tests/discord/test_on_message.py             | Updates message assertions to use the standardized Unicode zero-width character.               |
| tests/core/test_game_class.py                | Patches reseat tests to confirm that reorder_channels is invoked.                              |
| model/game/game.py                           | Updates reseat method to call reorder_channels, removing a temporary fixme comment.            |
| model/channels/channel_manager.py            | Refactors the channel ordering logic with an iterative algorithm for reordering positions.      |
| bot_impl.py                                   | Removes duplicate implementations of reorder_channels and safe_send.                         |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**tests/test_bot_integration.py:1847**
* [nitpick] Ensure the usage of the Unicode zero-width space (\u200b) is consistent across tests and production messages.
```
assert "\u200b\n" in mock_safe_send.call_args[0][1]
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T01:32:15Z
user: login: ratteler50
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T02:34:50Z

Review comments:
diff_hunk: @@ -130,17 +130,61 @@ async def setup_channels_in_order(self, ordered_player_channels: list[TextChanne
         ordered_channels: list[TextChannel] = [self._hands_channel, self._observer_channel,
                                                self._info_channel, self._whisper_channel] + ordered_player_channels + [
                                                   self._town_square_channel]
-
+        ordered_channels_set = set(ordered_channels)
         to_move_out: list[TextChannel] = [channel for channel in self._in_play_category.channels if
-                                          channel not in ordered_channels]
+                                          channel not in ordered_channels_set]
+        to_move_in: list[TextChannel] = [channel for channel in ordered_channels if
+                                         channel.category != self._in_play_category]
+
+        for channel in to_move_in:
+            if channel.category != self._in_play_category:
+                await channel.move(category=self._in_play_category, end=True)
+                logger.debug(f"Channel {channel.name} has been moved to In Play category.")
+            else:
+                logger.debug(f"Channel {channel.name} is already in the correct category.")
 
         # Move unused channels out of play
         for channel in to_move_out:
             await channel.move(category=self._out_of_play_category, end=True)
             logger.debug(f"Channel {channel.name} has been moved to Out of Play category.")
 
-        # Move remaining channels in play in order
-        for index, channel in enumerate(ordered_channels):
-            if channel.category != self._in_play_category or channel.position != index:
-                await channel.edit(category=self._in_play_category, position=index)
-                logger.debug(f"{channel.name} has been moved to position {index} of {self._in_play_category.name}.")
+
+        num_needing_changed = None
+        attempt_num = 1
+        while attempt_num < 5:
+            # ordered_channels = [
+            #     self._client.get_channel(channel.id)
+            #     for channel in ordered_channels
+            # ]
+            #
+            sorted_positions = sorted([c.position for c in ordered_channels])
+            current_index = {p: i for i, p in enumerate(sorted_positions)}
+            current_position_for_index = {i: p for i, p in enumerate(sorted_positions)}
+            position_for_index = {i: c.position for i, c in enumerate(ordered_channels)}
+            # {0: 0, 1: 3, 2: 7, 3: 6, 4: 2, 5: 9, 6: 4}
+
+            # Find the channel with the largest distance from its desired position
+            distance_desired_and_channel = [
+                (abs(current_index[c.position] - index), index, c)
+                for index, c in enumerate(ordered_channels)
+                if current_index[c.position] != index
+            ]
+
+            new_num_needing_changed = len(distance_desired_and_channel)
+            if(new_num_needing_changed == num_needing_changed):
+                attempt_num += 1
+            else:
+                num_needing_changed = new_num_needing_changed
+                attempt_num = 1
+            if not distance_desired_and_channel:
+                break  # All channels are in the correct position
+
+            # Move the most out-of-place channel
+            _, idx, channel = max(distance_desired_and_channel)
path: model/channels/channel_manager.py
user: login: Copilot
body: [nitpick] Clarify the rationale behind using 'current_position_for_index[idx] + 1' when adjusting channel positions. Consider adding a comment to explain the intended position offset to aid future maintenance.
```suggestion
            _, idx, channel = max(distance_desired_and_channel)
            # The `+1` adjustment ensures the channel is moved to the next position in the category,
            # as Discord channel positions are 0-based, and the API requires a 1-based index for insertion.
```
created_at: 2025-05-13T01:24:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -130,17 +130,63 @@ async def setup_channels_in_order(self, ordered_player_channels: list[TextChanne
         ordered_channels: list[TextChannel] = [self._hands_channel, self._observer_channel,
                                                self._info_channel, self._whisper_channel] + ordered_player_channels + [
                                                   self._town_square_channel]
-
+        ordered_channels_set = set(ordered_channels)
         to_move_out: list[TextChannel] = [channel for channel in self._in_play_category.channels if
-                                          channel not in ordered_channels]
+                                          channel not in ordered_channels_set]
+        to_move_in: list[TextChannel] = [channel for channel in ordered_channels if
+                                         channel.category != self._in_play_category]
+
+        for channel in to_move_in:
+            if channel.category != self._in_play_category:
+                await channel.move(category=self._in_play_category, end=True)
+                logger.debug(f"Channel {channel.name} has been moved to In Play category.")
+            else:
+                logger.debug(f"Channel {channel.name} is already in the correct category.")
 
         # Move unused channels out of play
         for channel in to_move_out:
             await channel.move(category=self._out_of_play_category, end=True)
             logger.debug(f"Channel {channel.name} has been moved to Out of Play category.")
 
-        # Move remaining channels in play in order
-        for index, channel in enumerate(ordered_channels):
-            if channel.category != self._in_play_category or channel.position != index:
-                await channel.edit(category=self._in_play_category, position=index)
-                logger.debug(f"{channel.name} has been moved to position {index} of {self._in_play_category.name}.")
+
+        num_needing_changed = None
+        attempt_num = 1
+        while attempt_num < 5:
+            # ordered_channels = [
+            #     self._client.get_channel(channel.id)
+            #     for channel in ordered_channels
+            # ]
+            #
+            sorted_positions = sorted([c.position for c in ordered_channels])
+            current_index = {p: i for i, p in enumerate(sorted_positions)}
+            current_position_for_index = {i: p for i, p in enumerate(sorted_positions)}
+            position_for_index = {i: c.position for i, c in enumerate(ordered_channels)}
+            # {0: 0, 1: 3, 2: 7, 3: 6, 4: 2, 5: 9, 6: 4}
+
+            # Find the channel with the largest distance from its desired position
+            distance_desired_and_channel = [
+                (abs(current_index[c.position] - index), index, c)
+                for index, c in enumerate(ordered_channels)
+                if current_index[c.position] != index
+            ]
+
+            new_num_needing_changed = len(distance_desired_and_channel)
+            if(new_num_needing_changed == num_needing_changed):
+                attempt_num += 1
+            else:
+                num_needing_changed = new_num_needing_changed
+                attempt_num = 1
+            if not distance_desired_and_channel:
+                break  # All channels are in the correct position
+
+            # Move the most out-of-place channel
+            _, idx, channel = max(distance_desired_and_channel)
+
+            # discord channels are 1 indexed
path: model/channels/channel_manager.py
user: login: Copilot
body: [nitpick] The iterative reordering algorithm is complex; consider refactoring or adding inline comments to clarify the logic, especially the rationale behind the '+1' adjustment for positions.
```suggestion
            # Discord channels are 1-indexed, meaning the first position is 1, not 0.
            # The `+1` adjustment ensures the channel is moved to the correct position
            # as expected by the Discord API.
```
created_at: 2025-05-13T01:32:15Z
author_association: CONTRIBUTOR
path: utils/channel_utils.py
user: login: ratteler50
body: Should this go in the channels directory?
created_at: 2025-05-13T02:27:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,7 +118,7 @@ async def reseat(self, new_seating_order):
 
         await self.seatingOrderMessage.edit(content=message_text)
         #  fixme: reorder_channels is doing more work than needed. until that is fixed, don't reorder on reseat.
path: model/game/game.py
user: login: ratteler50
body: nit: remove this comment now?
created_at: 2025-05-13T02:33:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: ratteler50
created_at: 2025-05-13T02:35:58Z
body: I did kindof hope this change would have included a new ST command to reorder as well, but maybe now that it's added back on traveler changes it won't be needed.
author_association: CONTRIBUTOR
user: login: jking-roar
created_at: 2025-05-13T03:15:23Z
body: st reorder command is `@reseat`
the issue the bot had earlier with not ordering the traveler st chats was that it was created after the traveler was added.  the traveler fixups done elsewhere make it less likely for that to happen
author_association: CONTRIBUTOR