Repository: ASPP/pelita
Pull request #871: Make returning an illegal position a fatal error
Author: login: otizonaizit
Created: 2025-04-26T23:32:04Z | Merged: 2025-04-28T12:08:12Z

This is the minimal implementation of making illegal moves a fatal error.

Given that the only other possible non-fatal error are timeouts, it may be worth it to refactor the error handling. I decided not to try this in this PR, because it would be a lot of work and I'd like this minimal implementation to go into the next release, so that we can start seeing the benefits right away. After all the error handling is not a user-facing feature.

In the process I fixed some tests that were still using the old API for the move function and were working because of the side effect of having `error_limit=5`, but these bots were returning invalid positions all along...

If and when this PR is merged I'll have a go at updating the docs in pelita_template.

Fixes: #832


Diff:
diff --git a/pelita/game.py b/pelita/game.py
index a3801c115..9f71f2ace 100644
--- a/pelita/game.py
+++ b/pelita/game.py
@@ -764,7 +764,7 @@ def play_turn(game_state, allow_exceptions=False):
         game_state = apply_move(game_state, position)
 
         # If there was no error, we claim a success in requested_moves
-        if (round, turn) not in game_state["errors"][team]:
+        if (round, turn) not in game_state["errors"][team] and not game_state['fatal_errors'][team]:
             game_state['requested_moves'][turn]['success'] = True
 
         # Check again, if we had errors or if this was the last move of the game (final round or food eaten)
@@ -837,13 +837,16 @@ def apply_move(gamestate, bot_position):
     # unless we have already made an error, check if we made a legal move
     if not (n_round, turn) in team_errors:
         if bot_position not in legal_positions:
-            error_dict = {
-                "reason": 'illegal move',
-                "bot_position": bot_position
-                }
-            # add the error to the team’s errors
-            game_print(turn, f"Illegal position. {bot_position} not in legal positions: {sorted(legal_positions)}.")
-            team_errors[(n_round, turn)] = error_dict
+            previous_position = gamestate["bots"][gamestate["turn"]]
+            game_print(turn, f"Illegal position. {previous_position}➔{bot_position} not in legal positions:"
+                             f" {sorted(legal_positions)}.")
+            exception_event = {
+                    'type': 'IllegalPosition',
+                    'description': f"bot{turn}: {previous_position}➔{bot_position}",
+                    'turn': turn,
+                    'round': n_round,
+            }
+            gamestate['fatal_errors'][team].append(exception_event)
 
     # only execute move if errors not exceeded
     gamestate.update(check_gameover(gamestate))
@@ -1044,6 +1047,9 @@ def split_food(width, food):
     return team_food
 
 
+unicode_to_ascii = {
+        '➔' : '->'
+        }
 def game_print(turn, msg):
     allow_unicode = not _mswindows
     if turn % 2 == 0:
@@ -1052,4 +1058,7 @@ def game_print(turn, msg):
     elif turn % 2 == 1:
         pie = ('\033[91m' + 'ᗧ' + '\033[0m' + ' ') if allow_unicode else ''
         pie += f'red team, bot {turn // 2}'
+    if not allow_unicode:
+        for uchar, achar in unicode_to_ascii.items():
+            msg = msg.replace(uchar, achar)
     print(f'{pie}: {msg}')
diff --git a/test/test_game.py b/test/test_game.py
index 036b82dc0..3206d31ad 100644
--- a/test/test_game.py
+++ b/test/test_game.py
@@ -182,8 +182,7 @@ def test_play_turn_apply_error(turn):
     """check that quits when there are too many errors"""
     game_state = setup_random_basic_gamestate()
     error_dict = {
-        "reason": 'illegal move',
-        "bot_position": (1, 2)
+        "type": 'PlayerTimeout',
     }
     game_state["turn"] = turn
     team = turn % 2
@@ -192,13 +191,32 @@ def test_play_turn_apply_error(turn):
     # we pretend that two rounds have already been played
     # so that the error dictionaries are sane
     game_state["round"] = 3
+    # add a timeout to the current bot
+    game_state["errors"][team][(3, turn%2)] = error_dict
+    game_state_new = apply_move(game_state, game_state["bots"][turn])
+    assert game_state_new["gameover"]
+    assert len(game_state_new["errors"][team]) == 5
+    assert game_state_new["whowins"] == int(not team)
+    assert game_state_new["errors"][team][(3, turn%2)] == error_dict
+
+
+@pytest.mark.parametrize('turn', (0, 1, 2, 3))
+def test_illegal_position_is_fatal(turn):
+    """check that quits when illegal position"""
+    game_state = setup_random_basic_gamestate()
+    game_state["turn"] = turn
+    team = turn % 2
+    # we pretend that two rounds have already been played
+    game_state["round"] = 3
 
     illegal_position = (0, 0) # should always be a wall
     game_state_new = apply_move(game_state, illegal_position)
     assert game_state_new["gameover"]
-    assert len(game_state_new["errors"][team]) == 5
+    assert len(game_state_new["fatal_errors"][team]) == 1
     assert game_state_new["whowins"] == int(not team)
-    assert set(game_state_new["errors"][team][(3, turn)].keys()) == set(["reason", "bot_position"])
+    assert game_state_new["fatal_errors"][team][0]['type'] == 'IllegalPosition'
+    assert '(0, 0)' in game_state_new["fatal_errors"][team][0]['description']
+
 
 @pytest.mark.parametrize('turn', (0, 1, 2, 3))
 def test_play_turn_fatal(turn):
@@ -214,17 +232,6 @@ def test_play_turn_fatal(turn):
     assert game_state_new["gameover"]
     assert game_state_new["whowins"] == int(not team)
 
-@pytest.mark.parametrize('turn', (0, 1, 2, 3))
-def test_play_turn_illegal_position(turn):
-    """check that illegal moves are added to error dict and bot still takes move"""
-    game_state = setup_random_basic_gamestate()
-    game_state["turn"] = turn
-    team = turn % 2
-    illegal_position = (0, 0) # should always be a wall
-    game_state_new = apply_move(game_state, illegal_position)
-    assert len(game_state_new["errors"][team]) == 1
-    assert game_state_new["errors"][team][(1, turn)].keys() == set(["reason", "bot_position"])
-    assert game_state_new["bots"][turn] in get_legal_positions(game_state["walls"], game_state["shape"], game_state["bots"][turn])
 
 @pytest.mark.parametrize('turn', (0, 1, 2, 3))
 @pytest.mark.parametrize('which_food', (0, 1))
@@ -335,7 +342,7 @@ def test_multiple_enemies_killing():
     ########
     """
     # dummy bots
-    stopping = lambda bot, s: (bot.position, s)
+    stopping = lambda bot, s: bot.position
 
     parsed_l0 = layout.parse_layout(l0, bots={'y':(3,2)})
     for bot in (0, 2):
@@ -383,7 +390,7 @@ def test_suicide():
     ########
     """
     # dummy bots
-    stopping = lambda bot, s: (bot.position, s)
+    stopping = lambda bot, s: bot.position
 
     parsed_l0 = layout.parse_layout(l0)
     for bot in (1, 3):
@@ -815,7 +822,7 @@ def setup_random_basic_gamestate(*, round=1, turn=0):
     """helper function for testing play turn"""
     parsed_l = layout.parse_layout(small_layout)
 
-    stopping = lambda bot, s: (bot.position, s)
+    stopping = lambda bot, s: bot.position
 
     game_state = setup_game([stopping, stopping], layout_dict=parsed_l)
     game_state['round'] = round
@@ -837,7 +844,7 @@ def setup_specific_basic_gamestate(round=0, turn=0):
 """
     parsed_l = layout.parse_layout(l)
 
-    stopping = lambda bot, s: (bot.position, s)
+    stopping = lambda bot, s: bot.position
 
     game_state = setup_game([stopping, stopping], layout_dict=parsed_l)
     game_state['round'] = round
@@ -1017,7 +1024,7 @@ def move(b, s):
     assert final_state['score'] == [0, 0]
     assert final_state['round'] == 20
 
-def test_minimal_losing_game_has_one_error():
+def test_minimal_losing_game_has_one_fatal_error():
     def move0(b, s):
         if b.round == 1 and b._bot_index == 0:
             # trigger a bad move in the first round
@@ -1032,9 +1039,9 @@ def move1(b, s):
     final_state = run_game([move0, move1], max_rounds=20, layout_dict=l)
     assert final_state['gameover'] is True
     assert final_state['score'] == [0, 0]
-    assert len(final_state['errors'][0]) == 1
-    assert len(final_state['errors'][1]) == 0
-    assert final_state['round'] == 20
+    assert len(final_state['fatal_errors'][0]) == 1
+    assert len(final_state['fatal_errors'][1]) == 0
+    assert final_state['round'] == 1
 
 
 def test_minimal_remote_game():
diff --git a/test/test_player_base.py b/test/test_player_base.py
index 66f6780cc..0df29b0b6 100644
--- a/test/test_player_base.py
+++ b/test/test_player_base.py
@@ -154,7 +154,7 @@ def init_rng_players():
             player_rngs = []
             def rng_test(bot, state):
                 player_rngs.append(bot.random)
-                return bot.position, state
+                return bot.position
             team = [
                 rng_test,
                 rng_test


Reviews:
user: login: Debilski
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T23:57:45Z
user: login: Debilski
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T00:10:38Z
user: login: otizonaizit
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T00:21:02Z
user: login: otizonaizit
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T00:24:13Z
user: login: otizonaizit
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T00:29:08Z
user: login: Debilski
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T09:44:16Z

Review comments:
diff_hunk: @@ -335,7 +335,7 @@ def test_multiple_enemies_killing():
     ########
     """
     # dummy bots
-    stopping = lambda bot, s: (bot.position, s)
+    stopping = lambda bot, s: bot.position
path: test/test_game.py
user: login: Debilski
body: The stopping players ‘worked’ because they were only used as dummies to get through `setup_game`. The game was then simulated with `apply_move` without ever calling the function. I suppose we could have used a more obvious dummy for this.
created_at: 2025-04-26T23:57:45Z
author_association: MEMBER
diff_hunk: @@ -837,27 +837,22 @@ def apply_move(gamestate, bot_position):
     # unless we have already made an error, check if we made a legal move
     if not (n_round, turn) in team_errors:
         if bot_position not in legal_positions:
-            error_dict = {
-                "reason": 'illegal move',
-                "bot_position": bot_position
-                }
-            # add the error to the team’s errors
-            game_print(turn, f"Illegal position. {bot_position} not in legal positions: {sorted(legal_positions)}.")
-            team_errors[(n_round, turn)] = error_dict
+            previous_position = gamestate["bots"][gamestate["turn"]]
+            game_print(turn, f"Illegal position. {previous_position}➔{bot_position} not in legal positions:"
+                             f" {sorted(legal_positions)}.")
+            exception_event = {
+                    'type': 'IllegalPosition',
+                    'description': f"bot{turn}: {previous_position}➔{bot_position}",
+                    'turn': turn,
+                    'round': n_round,
+            }
+            gamestate['fatal_errors'][team].append(exception_event)
 
     # only execute move if errors not exceeded
     gamestate.update(check_gameover(gamestate))
     if gamestate['gameover']:
         return gamestate
 
-    # Now check if we must make a random move
path: pelita/game.py
user: login: Debilski
body: You need this section to handle slow bots. Without it the whole game crashes on a timeout.
created_at: 2025-04-27T00:10:37Z
author_association: MEMBER
diff_hunk: @@ -837,27 +837,22 @@ def apply_move(gamestate, bot_position):
     # unless we have already made an error, check if we made a legal move
     if not (n_round, turn) in team_errors:
         if bot_position not in legal_positions:
-            error_dict = {
-                "reason": 'illegal move',
-                "bot_position": bot_position
-                }
-            # add the error to the team’s errors
-            game_print(turn, f"Illegal position. {bot_position} not in legal positions: {sorted(legal_positions)}.")
-            team_errors[(n_round, turn)] = error_dict
+            previous_position = gamestate["bots"][gamestate["turn"]]
+            game_print(turn, f"Illegal position. {previous_position}➔{bot_position} not in legal positions:"
+                             f" {sorted(legal_positions)}.")
+            exception_event = {
+                    'type': 'IllegalPosition',
+                    'description': f"bot{turn}: {previous_position}➔{bot_position}",
+                    'turn': turn,
+                    'round': n_round,
+            }
+            gamestate['fatal_errors'][team].append(exception_event)
 
     # only execute move if errors not exceeded
     gamestate.update(check_gameover(gamestate))
     if gamestate['gameover']:
         return gamestate
 
-    # Now check if we must make a random move
path: pelita/game.py
user: login: otizonaizit
body: yep, fixed
created_at: 2025-04-27T00:21:01Z
author_association: MEMBER
diff_hunk: @@ -837,27 +837,22 @@ def apply_move(gamestate, bot_position):
     # unless we have already made an error, check if we made a legal move
     if not (n_round, turn) in team_errors:
         if bot_position not in legal_positions:
-            error_dict = {
-                "reason": 'illegal move',
-                "bot_position": bot_position
-                }
-            # add the error to the team’s errors
-            game_print(turn, f"Illegal position. {bot_position} not in legal positions: {sorted(legal_positions)}.")
-            team_errors[(n_round, turn)] = error_dict
+            previous_position = gamestate["bots"][gamestate["turn"]]
+            game_print(turn, f"Illegal position. {previous_position}➔{bot_position} not in legal positions:"
+                             f" {sorted(legal_positions)}.")
+            exception_event = {
+                    'type': 'IllegalPosition',
+                    'description': f"bot{turn}: {previous_position}➔{bot_position}",
+                    'turn': turn,
+                    'round': n_round,
+            }
+            gamestate['fatal_errors'][team].append(exception_event)
 
     # only execute move if errors not exceeded
     gamestate.update(check_gameover(gamestate))
     if gamestate['gameover']:
         return gamestate
 
-    # Now check if we must make a random move
path: pelita/game.py
user: login: otizonaizit
body: I forced pushed to recover the code that handled random moves in the face of non-fatal errors. I also re-wrote the corresponding test to use timeouts instead of illegal moves to test that the error limit still works
created_at: 2025-04-27T00:24:13Z
author_association: MEMBER
diff_hunk: @@ -335,7 +335,7 @@ def test_multiple_enemies_killing():
     ########
     """
     # dummy bots
-    stopping = lambda bot, s: (bot.position, s)
+    stopping = lambda bot, s: bot.position
path: test/test_game.py
user: login: otizonaizit
body: No, no, `apply_move` was called in some tests and the check `if bot_position not in legal_moves` was failing, because `(position, {})` could not be found in the list of tuples `legal_positions`. This was filing an error for the team. The simulated games only had one round, though, so the error never became fatal and all other checks in the tests were still correct.
they started failing for me when I transformed the check `if bot_position not in legal_moves` into a fatal thing. Then the tests couldn't proceed and would fail early.
created_at: 2025-04-27T00:29:08Z
author_association: MEMBER
diff_hunk: @@ -335,7 +335,7 @@ def test_multiple_enemies_killing():
     ########
     """
     # dummy bots
-    stopping = lambda bot, s: (bot.position, s)
+    stopping = lambda bot, s: bot.position
path: test/test_game.py
user: login: Debilski
body: Yes, in some tests it failed. But in most others it didn’t. Doesn’t really matter but we can refactor the tests anyway at some point.
created_at: 2025-04-27T09:44:16Z
author_association: MEMBER

PR comments:
user: login: otizonaizit
created_at: 2025-04-28T11:43:27Z
body: Ready to merge?
author_association: MEMBER
user: login: Debilski
created_at: 2025-04-28T12:08:28Z
body: Curious how this will break things. :)
author_association: MEMBER