From ee9629d4001e047b7f0355cb2b2de544aabfeb03 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 5 Mar 2026 18:36:30 -0800 Subject: [PATCH 01/10] move branch --- games/src/games/LunarLockout.py | 319 ++++++++++++++++++++++++++++++++ games/src/games/game_manager.py | 2 + 2 files changed, 321 insertions(+) create mode 100644 games/src/games/LunarLockout.py diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py new file mode 100644 index 0000000..bfa5a88 --- /dev/null +++ b/games/src/games/LunarLockout.py @@ -0,0 +1,319 @@ +from models import Game, Value, StringMode +from typing import Optional + +class LunarLockout(Game): + id = 'lunar_lockout' + variants = ["puzzle1"] + board_size = ["5x5"] + n_players = 1 + cyclic = True + + _move_up = 0 + _move_right = 1 + _move_down = 2 + _move_left = 3 + + # Store the variant and board dimensions (5x5). + # Define constants: center square index (12), removed-robot value (31), and total robots (5). + # Robot index 0 always represents the red robot. + # Each robot position is encoded using 5 bits (values 0–31), producing a 25-bit packed state. + # Prepare any movement direction offsets needed for row/column stepping. + def __init__(self, variant_id: str): + """ + Define instance variables here (i.e. variant information) + """ + if variant_id not in LunarLockout.variants: + raise ValueError("Variant not defined") + self._variant_id = variant_id + + # Board dimensions + size_string = LunarLockout.board_size[0] + self._rows, self._cols = map(int, size_string.split("x")) + self._cells = self._rows * self._cols + # Exit Position + self._center = self._cells // 2 + # Limits + self._max_row = self._rows - 1 + self._max_col = self._cols - 1 + self.row_stride = self._cols # for jumping through rows + + # Robot configs + self._robot_count = 5 + self._red_index = 0 + self._removed = 31 + # Encoding + self._bits_per_robot = 5 + self._mask = 0b11111 + # Directions + self._directions = { + self._move_up: (-1, 0), + self._move_right: ( 0, 1), + self._move_down: ( 1, 0), + self._move_left: ( 0, -1), + } + + + # Select starting squares for all robots with no duplicates. + # Ensure the red robot is active and not already at a terminal condition. + # Keep robot ordering consistent (red first). + # Encode the five robot positions into a single integer state. + def start(self) -> int: + """ + Returns the starting position of the game. + """ + # All values must be 0–24 and no duplicates. + red = 20 + r1 = 7 + r2 = 16 + r3 = 0 + r4 = 23 + robots = [red, r1, r2, r3, r4] + if red == 12: + raise ValueError("Red cannot start at exit") + return self.pack(robots) + + + # Decode the state into robot positions. + # Skip robots marked as removed (31). + # Each remaining robot can be pushed in four directions (UP, RIGHT, DOWN, LEFT). + # Encode moves as (robot_index * 4 + direction). + def generate_moves(self, position: int) -> list[int]: + """ + Returns a list of positions given the input position. + A move is encoded as: + move = robot_index * 4 + direction + Directions: + 0 = UP + 1 = RIGHT + 2 = DOWN + 3 = LEFT + Move: + robot index 0 = 0-3 + 1 = 4-7 + .... + """ + robots = self.unpack(position) + moves = [] + + for robot_index in range(self._robot_count): + + if robots[robot_index] == self._removed: + continue + + start_cell = robots[robot_index] + start_row = start_cell // self._cols + start_col = start_cell % self._cols + + for direction in range(4): + + step_row, step_col = self._directions[direction] + row = start_row + col = start_col + + while True: + row += step_row + col += step_col + + if not (0 <= row < self._rows and 0 <= col < self._cols): + break + + cell = row * self._cols + col + + if cell in robots and cell != start_cell: + moves.append(robot_index * 4 + direction) + break + + return moves + + + # Decode the state and identify the robot and direction from the move. + # If the chosen robot is already removed, return the original state. + # Slide in the chosen direction while staying aligned to the same row or column. + # Stop when the nearest blocking robot is found. + # If a blocker exists, stop immediately before it. + # If the scan reaches the board edge with no blocker, the robot leaves the board and is marked removed (31). + # Do not allow two active robots to occupy the same square. + # Re-encode the updated positions into the new state. + def do_move(self, position: int, move: int) -> int: + """ + Returns the resulting position of applying move to position. + """ + robots = self.unpack(position) + + robot_index = move // 4 + direction = move % 4 + + if robots[robot_index] == self._removed: + return position + + row = robots[robot_index] // self._cols + col = robots[robot_index] % self._cols + + step_row, step_col = self._directions[direction] + + while True: + next_row = row + step_row + next_col = col + step_col + + if not (0 <= next_row < self._rows and 0 <= next_col < self._cols): + robots[robot_index] = self._removed + break + + next_cell = next_row * self._cols + next_col + + if next_cell in robots and next_cell != robots[robot_index]: + break + + row = next_row + col = next_col + + if robots[robot_index] != self._removed: + robots[robot_index] = row * self._cols + col + + return self.pack(robots) + + + # Decode the state. + # Return Win if the red robot occupies the center square (12). + # Return Lose if the red robot has been removed (31). + # Otherwise return None for a non-terminal position. + def primitive(self, position: int) -> Optional[Value]: + """ + Returns a Value enum which defines whether the current position is a win, loss, or non-terminal. + """ + robot_positions = self.unpack(position) + red_position = robot_positions[self._red_index] + + # reach the goal? + if red_position == self._center: + return Value.Win + + if red_position == self._removed: + return Value.Loss + + return None + + + # Convert the encoded state into a readable 5x5 board. + # Display the center square and all active robots. + # Do not display removed robots. + def to_string(self, position: int, mode: StringMode) -> str: + """ + Returns a string representation of the position based on the given mode. + """ + robot_positions = self.unpack(position) + + board = [["." for _ in range(5)] for _ in range(5)] + board[2][2] = "x" + # symbols = ["0", "1", "2", "3", "4"] + symbols = ["R", "A", "B", "C", "D"] + + for index, position in enumerate(robot_positions): + if position == 31: + continue + row = position // 5 + col = position % 5 + board[row][col] = symbols[index] + + return "\n".join(" ".join(row) for row in board) + + + # Parse a readable board layout into robot positions. + # Validate positions are within bounds and not duplicated. + # Assign removed status to any robot not present. + # Encode the positions into an integer state. + def from_string(self, strposition: str) -> int: + """ + Returns the position from a string representation of the position. + Input string is StringMode.Readable. + """ + lines = strposition.strip().split("\n") + + robots = [31, 31, 31, 31, 31] + symbol_map = { + "R": 0, + "A": 1, + "B": 2, + "C": 3, + "D": 4 + } + for robot in range(5): + cells = lines[robot].split() + for c in range(5): + cell = cells[c] + if cell in symbol_map: + idx = robot * 5 + c + robots[symbol_map[cell]] = idx + return self.pack(robots) + + # Decode the move into robot index and direction. + # Return a readable description of the movement direction. + def move_to_string(self, move: int, mode: StringMode) -> str: + """ + Returns a string representation of the move based on the given mode. + """ + robot = move // 4 + direction = move % 4 + + # directions = ["u", "r", "d", "l"] + # name = str(robot) + directions = ["UP", "RIGHT", "DOWN", "LEFT"] + if robot == 0: + name = "Red" + else: + name = f"Robot {robot}" + + return f"{name} {directions[direction]}" + + + # Helper responsibilities: + # Provide pack and unpack functions converting between the integer state and the five robot positions. + # Pack and unpack must be exact inverses and produce a single canonical representation of every board state. + # Convert between index and (row, column) coordinates. + # Provide stepping logic for movement along rows and columns. + # Provide alignment checks for same-row and same-column detection. + + def pack(self, robots: list[int]) -> int: + """ + Takes the 5 robot positions and compresses them into one binary. + + robots must be a list in this exact order: + [red, helper1, helper2, helper3, helper4] + + Each robot position is a number: + 0-24 = a square on the board + 31 = the robot has been removed + + We store each position using 5 bits. + """ + state = 0 + # Add each robot position into the integer one at a time. + # Shifting left makes space for the next robot, and the OR + # places the new value into those 5 empty bits. + for position in robots: + if position < 0 or position > self._mask: + raise ValueError("Invalid robot position") + state = (state << self._bits_per_robot) | position + return state + + + def unpack(self, state: int) -> list[int]: + """ + Reverses pack(): takes the encoded binary and recovers the + original robot positions. + + Returns a list: + [red, helper1, helper2, helper3, helper4] + + The function repeatedly reads the last 5 bits of the number to + get a robot position, then shifts the number right to remove + those bits. + """ + robots = [0] * self._robot_count + + # Read robot locations backwards because the last robot inserted + # is stored in the lowest 5 bits of the integer. + for i in range(self._robot_count - 1, -1, -1): + robots[i] = state & self._mask # get last 5 bits + state >>= self._bits_per_robot # discard those bits + return robots diff --git a/games/src/games/game_manager.py b/games/src/games/game_manager.py index 503f283..5ef0373 100644 --- a/games/src/games/game_manager.py +++ b/games/src/games/game_manager.py @@ -4,11 +4,13 @@ from .chipschallenge import ChipsChallenge from .test import Test from models import * +from .LunarLockout import LunarLockout game_list = { "clobber": Clobber, "horses": Horses, "pancakes": Pancakes, + "lunar_lockout": LunarLockout, "chipschallenge": ChipsChallenge, "test": Test, } From db6720d369b4ba5adb5149ea536141a7b880c54b Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 5 Mar 2026 19:13:56 -0800 Subject: [PATCH 02/10] fix --- games/src/games/LunarLockout.py | 35 +++++++++++++++++---------------- games/src/games/game_manager.py | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index bfa5a88..8bddd33 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -111,18 +111,25 @@ def generate_moves(self, position: int) -> list[int]: col = start_col while True: - row += step_row - col += step_col + next_row = row + step_row + next_col = col + step_col - if not (0 <= row < self._rows and 0 <= col < self._cols): + if not (0 <= next_row < self._rows and 0 <= next_col < self._cols): + if row != start_row or col != start_col: + moves.append(robot_index * 4 + direction) break - cell = row * self._cols + col + next_cell = next_row * self._cols + next_col - if cell in robots and cell != start_cell: - moves.append(robot_index * 4 + direction) + if next_cell in robots and robots.index(next_cell) != robot_index: + move = robot_index * 4 + direction + if self.do_move(position, move) != position: + moves.append(move) break + row = next_row + col = next_col + return moves @@ -203,10 +210,9 @@ def to_string(self, position: int, mode: StringMode) -> str: """ robot_positions = self.unpack(position) - board = [["." for _ in range(5)] for _ in range(5)] - board[2][2] = "x" - # symbols = ["0", "1", "2", "3", "4"] - symbols = ["R", "A", "B", "C", "D"] + board = [[" . " for _ in range(5)] for _ in range(5)] + board[2][2] = " x " + symbols = [" 0 ", " 1 ", " 2 ", " 3 ", " 4 "] for index, position in enumerate(robot_positions): if position == 31: @@ -255,13 +261,8 @@ def move_to_string(self, move: int, mode: StringMode) -> str: robot = move // 4 direction = move % 4 - # directions = ["u", "r", "d", "l"] - # name = str(robot) - directions = ["UP", "RIGHT", "DOWN", "LEFT"] - if robot == 0: - name = "Red" - else: - name = f"Robot {robot}" + directions = ["u", "r", "d", "l"] + name = str(robot) return f"{name} {directions[direction]}" diff --git a/games/src/games/game_manager.py b/games/src/games/game_manager.py index 5ef0373..05f63f3 100644 --- a/games/src/games/game_manager.py +++ b/games/src/games/game_manager.py @@ -2,9 +2,9 @@ from .horses import Horses from .pancakes import Pancakes from .chipschallenge import ChipsChallenge +from .LunarLockout import LunarLockout from .test import Test from models import * -from .LunarLockout import LunarLockout game_list = { "clobber": Clobber, From 6cb0578cdd616ae0bd54fa6f44358640101b93fc Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 5 Mar 2026 19:19:44 -0800 Subject: [PATCH 03/10] cleanup --- games/src/games/LunarLockout.py | 44 ++++++++------------------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index 8bddd33..9fa4713 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -96,39 +96,14 @@ def generate_moves(self, position: int) -> list[int]: moves = [] for robot_index in range(self._robot_count): - if robots[robot_index] == self._removed: continue - start_cell = robots[robot_index] - start_row = start_cell // self._cols - start_col = start_cell % self._cols - for direction in range(4): + move = robot_index * 4 + direction - step_row, step_col = self._directions[direction] - row = start_row - col = start_col - - while True: - next_row = row + step_row - next_col = col + step_col - - if not (0 <= next_row < self._rows and 0 <= next_col < self._cols): - if row != start_row or col != start_col: - moves.append(robot_index * 4 + direction) - break - - next_cell = next_row * self._cols + next_col - - if next_cell in robots and robots.index(next_cell) != robot_index: - move = robot_index * 4 + direction - if self.do_move(position, move) != position: - moves.append(move) - break - - row = next_row - col = next_col + if self.do_move(position, move) != position: + moves.append(move) return moves @@ -243,13 +218,14 @@ def from_string(self, strposition: str) -> int: "C": 3, "D": 4 } - for robot in range(5): - cells = lines[robot].split() - for c in range(5): - cell = cells[c] + + for row in range(5): + cells = lines[row].split() + for col in range(5): + cell = cells[col] if cell in symbol_map: - idx = robot * 5 + c - robots[symbol_map[cell]] = idx + idx = row * 5 + col + robots[symbol_map[cell]] = idx return self.pack(robots) # Decode the move into robot index and direction. From 05360ee04c51226f422cc3faa41067ebcead65fc Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 6 Mar 2026 13:01:36 -0800 Subject: [PATCH 04/10] add autogui mode --- games/src/games/LunarLockout.py | 41 ++++++++++++++++----------------- games/src/games/game_manager.py | 2 +- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index 9fa4713..09ec876 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -2,7 +2,7 @@ from typing import Optional class LunarLockout(Game): - id = 'lunar_lockout' + id = 'lunarlockout' variants = ["puzzle1"] board_size = ["5x5"] n_players = 1 @@ -183,11 +183,15 @@ def to_string(self, position: int, mode: StringMode) -> str: """ Returns a string representation of the position based on the given mode. """ + + if mode == StringMode.AUTOGUI: + return + robot_positions = self.unpack(position) - board = [[" . " for _ in range(5)] for _ in range(5)] - board[2][2] = " x " - symbols = [" 0 ", " 1 ", " 2 ", " 3 ", " 4 "] + board = [["." for _ in range(5)] for _ in range(5)] + board[2][2] = "x" + symbols = ["0", "1", "2", "3", "4"] for index, position in enumerate(robot_positions): if position == 31: @@ -208,25 +212,20 @@ def from_string(self, strposition: str) -> int: Returns the position from a string representation of the position. Input string is StringMode.Readable. """ + strposition = strposition.replace("\\n", "\n") + lines = strposition.strip().split("\n") - robots = [31, 31, 31, 31, 31] - symbol_map = { - "R": 0, - "A": 1, - "B": 2, - "C": 3, - "D": 4 - } - - for row in range(5): - cells = lines[row].split() - for col in range(5): - cell = cells[col] - if cell in symbol_map: - idx = row * 5 + col - robots[symbol_map[cell]] = idx - return self.pack(robots) + robots = [self._removed] * self._robot_count + + for r, line in enumerate(lines): + cells = line.split() + + for c, cell in enumerate(cells): + if cell in ["0","1","2","3","4"]: + robots[int(cell)] = r * 5 + c + + return self.pack(robots) # Decode the move into robot index and direction. # Return a readable description of the movement direction. diff --git a/games/src/games/game_manager.py b/games/src/games/game_manager.py index 05f63f3..db31b48 100644 --- a/games/src/games/game_manager.py +++ b/games/src/games/game_manager.py @@ -10,7 +10,7 @@ "clobber": Clobber, "horses": Horses, "pancakes": Pancakes, - "lunar_lockout": LunarLockout, + "lunarlockout": LunarLockout, "chipschallenge": ChipsChallenge, "test": Test, } From fcca0d1c4b83f5fc24b5a27bd103b4f7adc5fb0f Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 6 Mar 2026 14:57:58 -0800 Subject: [PATCH 05/10] autogui --- games/src/games/LunarLockout.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index 09ec876..fc5801b 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -182,11 +182,7 @@ def primitive(self, position: int) -> Optional[Value]: def to_string(self, position: int, mode: StringMode) -> str: """ Returns a string representation of the position based on the given mode. - """ - - if mode == StringMode.AUTOGUI: - return - + """ robot_positions = self.unpack(position) board = [["." for _ in range(5)] for _ in range(5)] @@ -198,7 +194,10 @@ def to_string(self, position: int, mode: StringMode) -> str: continue row = position // 5 col = position % 5 - board[row][col] = symbols[index] + board[row][col] = symbols[index] + + if mode == StringMode.AUTOGUI: + return "".join(" ".join(row) for row in board) return "\n".join(" ".join(row) for row in board) From 1f5a570c330b3fad882d71e6277e9d8081c46a2e Mon Sep 17 00:00:00 2001 From: Kelvin Date: Sun, 15 Mar 2026 23:46:09 -0700 Subject: [PATCH 06/10] still work in progress --- games/src/games/LunarLockout.py | 126 ++++++++++++++++++-------------- 1 file changed, 72 insertions(+), 54 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index fc5801b..8f3d7d5 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -51,6 +51,8 @@ def __init__(self, variant_id: str): self._move_down: ( 1, 0), self._move_left: ( 0, -1), } + self._last_src = 0 + self._last_dest = 0 # Select starting squares for all robots with no duplicates. @@ -100,10 +102,10 @@ def generate_moves(self, position: int) -> list[int]: continue for direction in range(4): - move = robot_index * 4 + direction + dest, _ = self._slide(robots, robot_index, direction) - if self.do_move(position, move) != position: - moves.append(move) + if dest != robots[robot_index]: + moves.append(robot_index * 4 + direction) return moves @@ -128,33 +130,20 @@ def do_move(self, position: int, move: int) -> int: if robots[robot_index] == self._removed: return position - row = robots[robot_index] // self._cols - col = robots[robot_index] % self._cols + src = robots[robot_index] - step_row, step_col = self._directions[direction] - - while True: - next_row = row + step_row - next_col = col + step_col - - if not (0 <= next_row < self._rows and 0 <= next_col < self._cols): - robots[robot_index] = self._removed - break - - next_cell = next_row * self._cols + next_col - - if next_cell in robots and next_cell != robots[robot_index]: - break + dest, left_board = self._slide(robots, robot_index, direction) - row = next_row - col = next_col + if left_board: + robots[robot_index] = self._removed + else: + robots[robot_index] = dest - if robots[robot_index] != self._removed: - robots[robot_index] = row * self._cols + col + self._last_src = src + self._last_dest = dest return self.pack(robots) - # Decode the state. # Return Win if the red robot occupies the center square (12). # Return Lose if the red robot has been removed (31). @@ -185,22 +174,22 @@ def to_string(self, position: int, mode: StringMode) -> str: """ robot_positions = self.unpack(position) - board = [["." for _ in range(5)] for _ in range(5)] - board[2][2] = "x" - symbols = ["0", "1", "2", "3", "4"] + board = [["." for _ in range(self._cols)] for _ in range(self._rows)] + + symbols = ["0","1","2","3","4"] - for index, position in enumerate(robot_positions): - if position == 31: + for i, p in enumerate(robot_positions): + if p == self._removed: continue - row = position // 5 - col = position % 5 - board[row][col] = symbols[index] + + row = p // self._cols + col = p % self._cols + board[row][col] = symbols[i] if mode == StringMode.AUTOGUI: - return "".join(" ".join(row) for row in board) - - return "\n".join(" ".join(row) for row in board) + return "1_" + "".join("".join(r) for r in board) + return "\n".join(" ".join(r) for r in board) # Parse a readable board layout into robot positions. # Validate positions are within bounds and not duplicated. @@ -208,24 +197,20 @@ def to_string(self, position: int, mode: StringMode) -> str: # Encode the positions into an integer state. def from_string(self, strposition: str) -> int: """ - Returns the position from a string representation of the position. - Input string is StringMode.Readable. + Converts a readable board string into the encoded state. """ strposition = strposition.replace("\\n", "\n") - - lines = strposition.strip().split("\n") + board = strposition.replace(" ", "").replace("\n", "") robots = [self._removed] * self._robot_count - for r, line in enumerate(lines): - cells = line.split() - - for c, cell in enumerate(cells): - if cell in ["0","1","2","3","4"]: - robots[int(cell)] = r * 5 + c + for i, cell in enumerate(board): + if cell in ["0","1","2","3","4"]: + robots[int(cell)] = i return self.pack(robots) + # Decode the move into robot index and direction. # Return a readable description of the movement direction. def move_to_string(self, move: int, mode: StringMode) -> str: @@ -236,17 +221,16 @@ def move_to_string(self, move: int, mode: StringMode) -> str: direction = move % 4 directions = ["u", "r", "d", "l"] - name = str(robot) - - return f"{name} {directions[direction]}" + # if mode == StringMode.AUTOGUI: + # return f"M_{self._last_src}_{self._last_dest}" + if mode == StringMode.AUTOGUI: + if self._last_dest == self._removed: + return f"M_{self._last_src}_x" + return f"M_{self._last_src}_{self._last_dest}" + + return f"{robot} {directions[direction]}" - # Helper responsibilities: - # Provide pack and unpack functions converting between the integer state and the five robot positions. - # Pack and unpack must be exact inverses and produce a single canonical representation of every board state. - # Convert between index and (row, column) coordinates. - # Provide stepping logic for movement along rows and columns. - # Provide alignment checks for same-row and same-column detection. def pack(self, robots: list[int]) -> int: """ @@ -292,3 +276,37 @@ def unpack(self, state: int) -> list[int]: robots[i] = state & self._mask # get last 5 bits state >>= self._bits_per_robot # discard those bits return robots + + + def _slide(self, robots: list[int], robot_index: int, direction: int): + src = robots[robot_index] + + row = src // self._cols + col = src % self._cols + + step_row, step_col = self._directions[direction] + + last_row = row + last_col = col + left_board = False + + while True: + next_row = row + step_row + next_col = col + step_col + + if not (0 <= next_row < self._rows and 0 <= next_col < self._cols): + left_board = True + break + + next_cell = next_row * self._cols + next_col + + if next_cell in robots and next_cell != src: + break + + last_row = next_row + last_col = next_col + row = next_row + col = next_col + + dest = last_row * self._cols + last_col + return dest, left_board \ No newline at end of file From 4a79ced33c776857c3ce1d72bfd5aa496fe91289 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 18 Mar 2026 11:09:59 -0700 Subject: [PATCH 07/10] change --- games/src/games/LunarLockout.py | 82 ++++++++++++++------------------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index 8f3d7d5..5147141 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -6,7 +6,7 @@ class LunarLockout(Game): variants = ["puzzle1"] board_size = ["5x5"] n_players = 1 - cyclic = True + cyclic = False _move_up = 0 _move_right = 1 @@ -35,8 +35,6 @@ def __init__(self, variant_id: str): # Limits self._max_row = self._rows - 1 self._max_col = self._cols - 1 - self.row_stride = self._cols # for jumping through rows - # Robot configs self._robot_count = 5 self._red_index = 0 @@ -51,8 +49,7 @@ def __init__(self, variant_id: str): self._move_down: ( 1, 0), self._move_left: ( 0, -1), } - self._last_src = 0 - self._last_dest = 0 + self._current_position = 0 # Select starting squares for all robots with no duplicates. @@ -96,14 +93,14 @@ def generate_moves(self, position: int) -> list[int]: """ robots = self.unpack(position) moves = [] + self._current_position = position + for robot_index in range(self._robot_count): if robots[robot_index] == self._removed: continue - for direction in range(4): dest, _ = self._slide(robots, robot_index, direction) - if dest != robots[robot_index]: moves.append(robot_index * 4 + direction) @@ -123,15 +120,12 @@ def do_move(self, position: int, move: int) -> int: Returns the resulting position of applying move to position. """ robots = self.unpack(position) - robot_index = move // 4 direction = move % 4 if robots[robot_index] == self._removed: return position - src = robots[robot_index] - dest, left_board = self._slide(robots, robot_index, direction) if left_board: @@ -139,9 +133,6 @@ def do_move(self, position: int, move: int) -> int: else: robots[robot_index] = dest - self._last_src = src - self._last_dest = dest - return self.pack(robots) # Decode the state. @@ -155,16 +146,13 @@ def primitive(self, position: int) -> Optional[Value]: robot_positions = self.unpack(position) red_position = robot_positions[self._red_index] - # reach the goal? if red_position == self._center: return Value.Win - if red_position == self._removed: return Value.Loss return None - # Convert the encoded state into a readable 5x5 board. # Display the center square and all active robots. # Do not display removed robots. @@ -173,15 +161,13 @@ def to_string(self, position: int, mode: StringMode) -> str: Returns a string representation of the position based on the given mode. """ robot_positions = self.unpack(position) + board = [["-" for _ in range(self._cols)] for _ in range(self._rows)] - board = [["." for _ in range(self._cols)] for _ in range(self._rows)] - - symbols = ["0","1","2","3","4"] + symbols = ["0", "1", "2", "3", "4"] for i, p in enumerate(robot_positions): if p == self._removed: continue - row = p // self._cols col = p % self._cols board[row][col] = symbols[i] @@ -203,7 +189,6 @@ def from_string(self, strposition: str) -> int: board = strposition.replace(" ", "").replace("\n", "") robots = [self._removed] * self._robot_count - for i, cell in enumerate(board): if cell in ["0","1","2","3","4"]: robots[int(cell)] = i @@ -220,15 +205,15 @@ def move_to_string(self, move: int, mode: StringMode) -> str: robot = move // 4 direction = move % 4 - directions = ["u", "r", "d", "l"] - - # if mode == StringMode.AUTOGUI: - # return f"M_{self._last_src}_{self._last_dest}" if mode == StringMode.AUTOGUI: - if self._last_dest == self._removed: - return f"M_{self._last_src}_x" - return f"M_{self._last_src}_{self._last_dest}" - + robots = self.unpack(self._current_position) + + src = robots[robot] + dest, _ = self._slide(robots, robot, direction) + + return f"M_{src}_{dest}" + + directions = ["u", "r", "d", "l"] return f"{robot} {directions[direction]}" @@ -279,34 +264,37 @@ def unpack(self, state: int) -> list[int]: def _slide(self, robots: list[int], robot_index: int, direction: int): - src = robots[robot_index] + start_pos = robots[robot_index] - row = src // self._cols - col = src % self._cols + curr_row = start_pos // self._cols + curr_col = start_pos % self._cols - step_row, step_col = self._directions[direction] + delta_row, delta_col = self._directions[direction] - last_row = row - last_col = col - left_board = False + last_valid_row = curr_row + last_valid_col = curr_col + exited_board = False while True: - next_row = row + step_row - next_col = col + step_col + next_row = curr_row + delta_row + next_col = curr_col + delta_col + # Check if next step goes off the board if not (0 <= next_row < self._rows and 0 <= next_col < self._cols): - left_board = True + exited_board = True break - next_cell = next_row * self._cols + next_col + next_pos = next_row * self._cols + next_col - if next_cell in robots and next_cell != src: + # Check if next position is occupied by another robot + if next_pos in robots and next_pos != start_pos: break - last_row = next_row - last_col = next_col - row = next_row - col = next_col + # Advance to next valid position + last_valid_row = next_row + last_valid_col = next_col + curr_row = next_row + curr_col = next_col - dest = last_row * self._cols + last_col - return dest, left_board \ No newline at end of file + end_pos = last_valid_row * self._cols + last_valid_col + return end_pos, exited_board \ No newline at end of file From 116909bf8d9ec41b9cb04beddc10c1712824ce24 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 18 Mar 2026 19:21:00 -0700 Subject: [PATCH 08/10] progress --- games/src/games/LunarLockout.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index 5147141..63c3aa0 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -174,6 +174,8 @@ def to_string(self, position: int, mode: StringMode) -> str: if mode == StringMode.AUTOGUI: return "1_" + "".join("".join(r) for r in board) + if mode == StringMode.Readable: + return "".join("".join(r) for r in board) return "\n".join(" ".join(r) for r in board) From 31b63f6c3e6b1d157d8cd08934bcfa80a20983c1 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 19 Mar 2026 21:17:05 -0700 Subject: [PATCH 09/10] add more game variants --- games/src/games/LunarLockout.py | 49 ++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index 63c3aa0..a1b3a26 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -3,7 +3,6 @@ class LunarLockout(Game): id = 'lunarlockout' - variants = ["puzzle1"] board_size = ["5x5"] n_players = 1 cyclic = False @@ -13,6 +12,17 @@ class LunarLockout(Game): _move_down = 2 _move_left = 3 + variants = { + "easy": { + "robots": [20, 7, 16, 0, 23], + }, + "medium": { + "robots": [21, 2, 4, 11, 18], + }, + "hard": { + "robots": [2, 0, 4, 20, 24], + } + } # Store the variant and board dimensions (5x5). # Define constants: center square index (12), removed-robot value (31), and total robots (5). # Robot index 0 always represents the red robot. @@ -25,6 +35,7 @@ def __init__(self, variant_id: str): if variant_id not in LunarLockout.variants: raise ValueError("Variant not defined") self._variant_id = variant_id + self._config = LunarLockout.variants[variant_id] # Board dimensions size_string = LunarLockout.board_size[0] @@ -36,7 +47,7 @@ def __init__(self, variant_id: str): self._max_row = self._rows - 1 self._max_col = self._cols - 1 # Robot configs - self._robot_count = 5 + self._robot_count = len(self._config["robots"]) self._red_index = 0 self._removed = 31 # Encoding @@ -60,17 +71,14 @@ def start(self) -> int: """ Returns the starting position of the game. """ - # All values must be 0–24 and no duplicates. - red = 20 - r1 = 7 - r2 = 16 - r3 = 0 - r4 = 23 - robots = [red, r1, r2, r3, r4] - if red == 12: + robots = self._config["robots"].copy() + # Validate: red not already winning + if robots[self._red_index] == self._center: raise ValueError("Red cannot start at exit") + # Optional (good practice): no duplicates + if len(set(robots)) != len(robots): + raise ValueError("Duplicate robot positions") return self.pack(robots) - # Decode the state into robot positions. # Skip robots marked as removed (31). @@ -190,10 +198,25 @@ def from_string(self, strposition: str) -> int: strposition = strposition.replace("\\n", "\n") board = strposition.replace(" ", "").replace("\n", "") + # Validate board size + if len(board) != self._cells: + raise ValueError("Invalid board size") + robots = [self._removed] * self._robot_count + for i, cell in enumerate(board): - if cell in ["0","1","2","3","4"]: - robots[int(cell)] = i + if cell in ["0", "1", "2", "3", "4"]: + idx = int(cell) + + # Check duplicate robot + if robots[idx] != self._removed: + raise ValueError("Duplicate robot in board") + + robots[idx] = i + + # Red robot must exist + if robots[self._red_index] == self._removed: + raise ValueError("Red robot missing") return self.pack(robots) From ac45049d9808b6485b69b53e3589591696094a74 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 19 Mar 2026 23:10:19 -0700 Subject: [PATCH 10/10] add one more variant --- games/src/games/LunarLockout.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/games/src/games/LunarLockout.py b/games/src/games/LunarLockout.py index a1b3a26..d7f74d2 100644 --- a/games/src/games/LunarLockout.py +++ b/games/src/games/LunarLockout.py @@ -13,14 +13,17 @@ class LunarLockout(Game): _move_left = 3 variants = { - "easy": { + "beginner": { "robots": [20, 7, 16, 0, 23], }, - "medium": { + "easy": { "robots": [21, 2, 4, 11, 18], }, - "hard": { + "medium": { "robots": [2, 0, 4, 20, 24], + }, + "hard": { + "robots": [24, 2, 4, 10, 18, 20], } } # Store the variant and board dimensions (5x5). @@ -170,8 +173,7 @@ def to_string(self, position: int, mode: StringMode) -> str: """ robot_positions = self.unpack(position) board = [["-" for _ in range(self._cols)] for _ in range(self._rows)] - - symbols = ["0", "1", "2", "3", "4"] + symbols = [str(i) for i in range(self._robot_count)] for i, p in enumerate(robot_positions): if p == self._removed: @@ -204,14 +206,16 @@ def from_string(self, strposition: str) -> int: robots = [self._removed] * self._robot_count + for i, cell in enumerate(board): - if cell in ["0", "1", "2", "3", "4"]: + if cell.isdigit(): idx = int(cell) - + # Reject robots outside allowed range + if idx >= self._robot_count: + raise ValueError("Invalid robot index") # Check duplicate robot if robots[idx] != self._removed: raise ValueError("Duplicate robot in board") - robots[idx] = i # Red robot must exist