Last active
September 16, 2025 00:48
-
-
Save itarato/2d370b038e3b0e31347021a15a84670c to your computer and use it in GitHub Desktop.
4 in a row on terminal
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import math | |
| class FourInARow: | |
| def __init__(self) -> None: | |
| self.map = [] | |
| for _ in range(6): | |
| self.map.append([EMPTY] * 7) | |
| def calculate_column_scores(self) -> list[int]: | |
| pass | |
| def step(self, player: int, column: int) -> bool: | |
| for y in range(6): | |
| if self.map[y][column] == EMPTY: | |
| self.map[y][column] = player | |
| return True | |
| return False | |
| def unstep(self, column: int): | |
| for y in range(5, -1, -1): | |
| if self.map[y][column] != EMPTY: | |
| self.map[y][column] = EMPTY | |
| return | |
| raise "Cannot unstep empty column" | |
| def score(self) -> dict[int, float]: | |
| out = { | |
| PLAYER_HUMAN: 0, | |
| PLAYER_CPU: 0, | |
| } | |
| for y in range(6): | |
| for x in range(7): | |
| if self.map[y][x] == EMPTY: | |
| continue | |
| out[self.map[y][x]] += self.cell_score(x, y) | |
| return out | |
| def cell_score(self, x: int, y: int) -> float: | |
| player = self.map[y][x] | |
| _score = 0 | |
| # Vertical: | | |
| current_len = self.len_of(x, y, dx=0, dy=-1, match=[player]) | |
| grow_len = self.len_of(x, y + 1, dx=0, dy=1, match=[EMPTY]) | |
| _score += FourInARow.score_for_current_and_grow_len(current_len, grow_len) | |
| # Left horizontal: <- | |
| current_len = self.len_of(x, y, dx=1, dy=0, match=[player]) | |
| grow_len = self.len_of( | |
| x - 1, y, dx=-1, dy=0, match=[EMPTY, player], firstMatch=[EMPTY] | |
| ) | |
| _score += FourInARow.score_for_current_and_grow_len(current_len, grow_len) | |
| # Right horizontal: -> | |
| current_len = self.len_of(x, y, dx=-1, dy=0, match=[player]) | |
| grow_len = self.len_of( | |
| x + 1, y, dx=1, dy=0, match=[EMPTY, player], firstMatch=[EMPTY] | |
| ) | |
| _score += FourInARow.score_for_current_and_grow_len(current_len, grow_len) | |
| # Left-diag: \ | |
| current_len = self.len_of(x, y, dx=1, dy=-1, match=[player]) | |
| grow_len = self.len_of( | |
| x - 1, y + 1, dx=-1, dy=1, match=[EMPTY, player], firstMatch=[EMPTY] | |
| ) | |
| _score += FourInARow.score_for_current_and_grow_len(current_len, grow_len) | |
| # Right-diag: / | |
| current_len = self.len_of(x, y, dx=-1, dy=-1, match=[player]) | |
| grow_len = self.len_of( | |
| x + 1, y + 1, dx=1, dy=1, match=[EMPTY, player], firstMatch=[EMPTY] | |
| ) | |
| _score += FourInARow.score_for_current_and_grow_len(current_len, grow_len) | |
| return _score | |
| def score_for_current_and_grow_len(current_len: int, grow_len: int) -> float: | |
| if current_len >= 4: | |
| # It's twice the winning score so we can safely ask if the DIFF (CPU-HUMAN) is greater than winning. | |
| return WIN_SCORE * 2 | |
| elif current_len + grow_len >= 4: | |
| return current_len**2 | |
| else: | |
| return 0 | |
| def coord_is_in_range(x: int, y: int) -> bool: | |
| return x >= 0 and y >= 0 and x <= 6 and y <= 5 | |
| def len_of( | |
| self, | |
| x: int, | |
| y: int, | |
| dx: int, | |
| dy: int, | |
| match: list[int], | |
| firstMatch: list[int] = None, | |
| ) -> int: | |
| out = 0 | |
| isFirst = True | |
| if firstMatch == None: | |
| firstMatch = match | |
| while FourInARow.coord_is_in_range(x, y): | |
| if isFirst: | |
| isFirst = False | |
| didMatch = self.map[y][x] in firstMatch | |
| else: | |
| didMatch = self.map[y][x] in match | |
| if didMatch: | |
| out += 1 | |
| else: | |
| break | |
| x += dx | |
| y += dy | |
| return out | |
| def dump_board(self) -> None: | |
| print("\x1b[91m●\x1b[0m: Human") | |
| print("\x1b[93m●\x1b[0m: Computer") | |
| for y in range(5, -1, -1): | |
| print("\x1b[100m\x1b[91m \x1b[0m", end="") | |
| for x in range(7): | |
| c = "\x1b[100m \x1b[0m" | |
| if self.map[y][x] == PLAYER_HUMAN: | |
| c = "\x1b[100m\x1b[91m● \x1b[0m" | |
| elif self.map[y][x] == PLAYER_CPU: | |
| c = "\x1b[100m\x1b[93m● \x1b[0m" | |
| print(c, end="") | |
| print("") | |
| print("\x1b[44m 0 1 2 3 4 5 6 \x1b[0m\n\n") | |
| class BaseGame: | |
| def __init__(self) -> None: | |
| self.fiar = FourInARow() | |
| def run(self): | |
| isHumanStep = True | |
| while True: | |
| self.fiar.dump_board() | |
| current_score = self.fiar.score() | |
| if current_score[PLAYER_HUMAN] >= WIN_SCORE: | |
| print("HUMAN WON!!!") | |
| break | |
| elif current_score[PLAYER_CPU] >= WIN_SCORE: | |
| print("CPU WON!!!") | |
| break | |
| if isHumanStep: | |
| if not self.human_step(): | |
| break | |
| else: | |
| self.cpu_step() | |
| isHumanStep = not isHumanStep | |
| def human_step(self) -> bool: | |
| cmd = input("?> ") | |
| if cmd == "q": | |
| return False | |
| else: | |
| col = int(cmd) | |
| if col < 0 or col >= 7: | |
| print("Bad command") | |
| return self.human_step() | |
| self.fiar.step(PLAYER_HUMAN, col) | |
| return True | |
| def cpu_step(self): | |
| raise "Must be implemented" | |
| class SimpleScoreGame(BaseGame): | |
| def cpu_step(self): | |
| best_col = None | |
| best_score = -1 | |
| for x in range(7): | |
| if not self.fiar.step(PLAYER_CPU, x): | |
| continue | |
| score = self.fiar.score() | |
| if score[PLAYER_CPU] > best_score: | |
| best_score = score[PLAYER_CPU] | |
| best_col = x | |
| self.fiar.unstep(x) | |
| if best_col == None: | |
| raise "No more steps" | |
| else: | |
| self.fiar.step(PLAYER_CPU, best_col) | |
| class MinimaxGame(BaseGame): | |
| def __init__(self, depth: int = 4): | |
| super().__init__() | |
| self.dotfile = Dot() | |
| self.dotfile.record_header() | |
| self.depth = depth | |
| def __del__(self): | |
| self.dotfile.record_footer() | |
| if hasattr(self, "dotfile") and self.dotfile and hasattr(self.dotfile, "file"): | |
| try: | |
| self.dotfile.file.close() | |
| except Exception: | |
| pass | |
| def cpu_step(self): | |
| _, col = self.minimax(True, self.depth) | |
| self.fiar.step(PLAYER_CPU, col) | |
| def minimax( | |
| self, | |
| is_cpu: bool, | |
| depth: int, | |
| dot_node_name: str = "root", | |
| parent_bounds: tuple[float, float] = (-math.inf, math.inf), | |
| ) -> list: | |
| score = self.fiar.score() | |
| cpu_heuristic_val = score[PLAYER_CPU] - score[PLAYER_HUMAN] | |
| node_bounds = (-math.inf, math.inf) | |
| if ( | |
| depth <= 0 | |
| or abs(score[PLAYER_CPU]) >= WIN_SCORE | |
| or abs(score[PLAYER_HUMAN]) >= WIN_SCORE | |
| ): | |
| if ( | |
| abs(score[PLAYER_CPU]) >= WIN_SCORE | |
| or abs(score[PLAYER_HUMAN]) >= WIN_SCORE | |
| ): | |
| cpu_heuristic_val *= depth + 1 | |
| self.dotfile.record_node( | |
| dot_node_name, | |
| f"{cpu_heuristic_val}\nC{score[PLAYER_CPU]}\nH{score[PLAYER_HUMAN]}", | |
| "blue" if is_cpu else "red", | |
| ) | |
| return [cpu_heuristic_val, None] | |
| elif is_cpu: | |
| value = -math.inf | |
| col = None | |
| for x in range(7): | |
| # Alpha/beta pruning | |
| if ALPHA_BETA_PRUNING and node_bounds[0] > parent_bounds[1]: | |
| continue | |
| if not self.fiar.step(PLAYER_CPU, x): | |
| continue | |
| dot_subnode_name = f"{dot_node_name}::{depth}_{x}" | |
| # subvalue (MUST BE) (at least) [value] | |
| subvalue, _ = self.minimax( | |
| not is_cpu, depth - 1, dot_subnode_name, parent_bounds=node_bounds | |
| ) | |
| self.dotfile.record_edge(dot_node_name, dot_subnode_name) | |
| if subvalue >= value: | |
| value = subvalue | |
| col = x | |
| node_bounds = (subvalue, node_bounds[1]) | |
| self.fiar.unstep(x) | |
| self.dotfile.record_node( | |
| dot_node_name, | |
| str(value) + "(" + str(col) + ")", | |
| "blue", | |
| ) | |
| return [value, col] | |
| else: | |
| value = math.inf | |
| for x in range(7): | |
| # Alpha/beta pruning | |
| if ALPHA_BETA_PRUNING and node_bounds[1] < parent_bounds[0]: | |
| continue | |
| if not self.fiar.step(PLAYER_HUMAN, x): | |
| continue | |
| dot_subnode_name = f"{dot_node_name}::{depth}_{x}" | |
| # subvalue (MUST BE) (maximum) [value] | |
| subvalue, _ = self.minimax( | |
| not is_cpu, depth - 1, dot_subnode_name, parent_bounds=node_bounds | |
| ) | |
| self.dotfile.record_edge(dot_node_name, dot_subnode_name) | |
| if subvalue <= value: | |
| value = subvalue | |
| col = x | |
| node_bounds = (node_bounds[0], subvalue) | |
| self.fiar.unstep(x) | |
| self.dotfile.record_node( | |
| dot_node_name, | |
| str(value) + "(" + str(col) + ")", | |
| "red", | |
| ) | |
| return [value, col] | |
| class NullDot: | |
| def record_header(self): | |
| pass | |
| def record_footer(self): | |
| pass | |
| def record_node(self, name: str, label: any, color: str): | |
| pass | |
| def record_edge(self, lhs, rhs): | |
| pass | |
| class Dot: | |
| def __init__(self): | |
| self.file = open("/tmp/minimax.dot", "w") | |
| self.file.truncate(0) | |
| def record_header(self): | |
| self.file.write("digraph Tree {\n") | |
| def record_footer(self): | |
| self.file.write("}\n") | |
| def record_node(self, name: str, label: any, color: str): | |
| self.file.write(f'"{name}" [label="{label}", color={color}];\n') | |
| def record_edge(self, lhs, rhs): | |
| self.file.write(f'"{lhs}" -> "{rhs}";\n') | |
| def test_scenario_not_taking_win_step(): | |
| mmg = MinimaxGame(2) | |
| mmg.fiar.step(PLAYER_HUMAN, 3) | |
| mmg.fiar.step(PLAYER_HUMAN, 4) | |
| mmg.fiar.step(PLAYER_CPU, 3) | |
| mmg.fiar.dump_board() | |
| print(mmg.fiar.score()) | |
| mmg.cpu_step() | |
| mmg.fiar.dump_board() | |
| EMPTY = None | |
| PLAYER_HUMAN = 1 | |
| PLAYER_CPU = 2 | |
| WIN_SCORE = 1000 | |
| ALPHA_BETA_PRUNING = True | |
| if __name__ == "__main__": | |
| MinimaxGame(5).run() | |
| # test_scenario_not_taking_win_step() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment