diff --git a/Convoys_GameofLife/GameOfLife.py b/Convoys_GameofLife/GameOfLife.py index de7a2f5d..b51d2a6b 100644 --- a/Convoys_GameofLife/GameOfLife.py +++ b/Convoys_GameofLife/GameOfLife.py @@ -1,146 +1,404 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 +""" +Conway's Game of Life +===================== +A full-featured simulation of Conway's cellular automaton with: +- Interactive curses UI with colors and responsive resizing +- Fallback ANSI terminal rendering mode for environments without curses +- Multiple pattern presets (Glider, Pulsar, Gosper Gun, Blinker, Beacon, Toad) +- Interactive controls (Pause, Step, Speed, Clear, Randomize, Wrap toggle) +- Decoupled, testable GameOfLifeEngine +""" -import curses +import copy +import os import random +import sys import time -import copy - - -def GameOfLife(stdscr): - k = 0 - cursor_x, cursor_y = 0, 0 - generations = 0 - grid = [] - height, width = stdscr.getmaxyx() - rows, cols = int(height-2), width - speed = .2 - pause = False - +import argparse +from typing import List, Tuple, Optional + +# Optional curses import (with Windows compatibility handling) +try: + import curses + HAS_CURSES = True +except ImportError: + HAS_CURSES = False + + +class GameOfLifeEngine: + """Core simulation engine for Conway's Game of Life.""" + + PRESETS = { + "blinker": [ + (0, 0), (0, 1), (0, 2) + ], + "toad": [ + (0, 1), (0, 2), (0, 3), + (1, 0), (1, 1), (1, 2) + ], + "beacon": [ + (0, 0), (0, 1), + (1, 0), + (2, 3), + (3, 2), (3, 3) + ], + "glider": [ + (0, 1), + (1, 2), + (2, 0), (2, 1), (2, 2) + ], + "pulsar": [ + (1, 3), (1, 4), (1, 5), (1, 9), (1, 10), (1, 11), + (3, 1), (3, 6), (3, 8), (3, 13), + (4, 1), (4, 6), (4, 8), (4, 13), + (5, 1), (5, 6), (5, 8), (5, 13), + (6, 3), (6, 4), (6, 5), (6, 9), (6, 10), (6, 11), + (8, 3), (8, 4), (8, 5), (8, 9), (8, 10), (8, 11), + (9, 1), (9, 6), (9, 8), (9, 13), + (10, 1), (10, 6), (10, 8), (10, 13), + (11, 1), (11, 6), (11, 8), (11, 13), + (13, 3), (13, 4), (13, 5), (13, 9), (13, 10), (13, 11) + ], + "gosper_gun": [ + (5, 1), (5, 2), (6, 1), (6, 2), + (5, 11), (6, 11), (7, 11), + (4, 12), (8, 12), + (3, 13), (9, 13), + (3, 14), (9, 14), + (6, 15), + (4, 16), (8, 16), + (5, 17), (6, 17), (7, 17), + (6, 18), + (3, 21), (4, 21), (5, 21), + (3, 22), (4, 22), (5, 22), + (2, 23), (6, 23), + (1, 25), (2, 25), (6, 25), (7, 25), + (3, 35), (4, 35), (3, 36), (4, 36) + ] + } + + def __init__(self, rows: int = 20, cols: int = 40, wrap: bool = True): + self.rows = max(3, rows) + self.cols = max(3, cols) + self.wrap = wrap + self.grid = self._create_grid() + self.generation = 0 + + def _create_grid(self) -> List[List[bool]]: + return [[False for _ in range(self.cols)] for _ in range(self.rows)] + + def resize(self, rows: int, cols: int): + """Resize the grid dynamically preserving existing living cells within bounds.""" + new_rows = max(3, rows) + new_cols = max(3, cols) + new_grid = [[False for _ in range(new_cols)] for _ in range(new_rows)] + for r in range(min(self.rows, new_rows)): + for c in range(min(self.cols, new_cols)): + new_grid[r][c] = self.grid[r][c] + self.rows = new_rows + self.cols = new_cols + self.grid = new_grid + + def clear(self): + """Clear all cells and reset generation count.""" + self.grid = self._create_grid() + self.generation = 0 + + def seed_random(self, density: float = 0.25): + """Seed the grid randomly with the given alive probability.""" + self.generation = 0 + self.grid = [ + [random.random() < density for _ in range(self.cols)] + for _ in range(self.rows) + ] + + def load_preset(self, name: str, start_r: Optional[int] = None, start_c: Optional[int] = None) -> bool: + """Load a predefined pattern into the grid.""" + pattern = self.PRESETS.get(name.lower()) + if not pattern: + return False + + # Calculate bounding box to center if not specified + max_pr = max(r for r, _ in pattern) + max_pc = max(c for _, c in pattern) + + if start_r is None: + start_r = max(0, (self.rows - max_pr) // 2) + if start_c is None: + start_c = max(0, (self.cols - max_pc) // 2) + + self.clear() + for r, c in pattern: + target_r = start_r + r + target_c = start_c + c + if 0 <= target_r < self.rows and 0 <= target_c < self.cols: + self.grid[target_r][target_c] = True + + return True + + def count_neighbors(self, r: int, c: int) -> int: + """Count living Moore neighbors for cell (r, c).""" + count = 0 + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + if dr == 0 and dc == 0: + continue + nr, nc = r + dr, c + dc + if self.wrap: + nr %= self.rows + nc %= self.cols + elif not (0 <= nr < self.rows and 0 <= nc < self.cols): + continue + + if self.grid[nr][nc]: + count += 1 + return count + + def step(self) -> Tuple[int, int]: + """ + Advance one generation according to Conway's Game of Life rules: + 1. Any live cell with fewer than two live neighbours dies (underpopulation). + 2. Any live cell with two or three live neighbours lives on to the next generation. + 3. Any live cell with more than three live neighbours dies (overpopulation). + 4. Any dead cell with exactly three live neighbours becomes a live cell (reproduction). + + Returns: + Tuple of (generation_number, alive_cells_count) + """ + new_grid = self._create_grid() + alive_count = 0 + + for r in range(self.rows): + for c in range(self.cols): + neighbors = self.count_neighbors(r, c) + is_alive = self.grid[r][c] + + if is_alive and (neighbors == 2 or neighbors == 3): + new_grid[r][c] = True + alive_count += 1 + elif not is_alive and neighbors == 3: + new_grid[r][c] = True + alive_count += 1 + + self.grid = new_grid + self.generation += 1 + return self.generation, alive_count + + def get_alive_count(self) -> int: + """Count total alive cells currently on the grid.""" + return sum(row.count(True) for row in self.grid) + + +def safe_addstr(window, y: int, x: int, text: str, attr: int = 0): + """Safely draw string avoiding curses boundary overflow exceptions.""" + max_y, max_x = window.getmaxyx() + if y < 0 or y >= max_y or x < 0 or x >= max_x: + return + # Truncate text if it exceeds line width + available_len = max_x - x + # If writing to the bottom-right corner, leave 1 character space to prevent auto-scroll error + if y == max_y - 1: + available_len = max(0, available_len - 1) + + truncated_text = text[:available_len] + try: + window.addstr(y, x, truncated_text, attr) + except curses.error: + pass + + +def run_curses_ui(stdscr, preset: str = "random", speed: float = 0.15, wrap: bool = True): + """Run interactive curses interface.""" + curses.curs_set(0) + stdscr.nodelay(True) stdscr.clear() - stdscr.refresh() - - # colors in curses - curses.start_color() - curses.init_pair(1, curses.COLOR_BLUE, curses.COLOR_BLACK) - curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) - curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) - - def initialize(): - return [[False for _ in range(cols)] for _ in range(rows)] - - def seed(): - grid = initialize() - for i in range(rows): - for j in range(cols): - if int(random.random() * 4) == 0: - grid[i][j] = True - return grid - def play(grid): - noOfCellsAlive = 0 - nGrid = copy.deepcopy(grid) - dR = [1, 1, 1, -1, -1, -1, 0, 0] - dC = [1, 0, -1, -1, 0, 1, 1, -1] + # Color pairs setup + if curses.has_colors(): + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_CYAN, -1) # Living cells + curses.init_pair(2, curses.COLOR_GREEN, -1) # Title & controls + curses.init_pair(3, curses.COLOR_YELLOW, -1) # Status bar + curses.init_pair(4, curses.COLOR_MAGENTA, -1) # Presets - def isValid(r, c) -> bool: - return (r >= 0 and r < rows and c >= 0 and c < cols) - - for i in range(rows): - for j in range(cols): - count = 0 - for r1, c1 in zip(dR, dC): - r = r1+i - c = c1+j - - if isValid(r, c) and grid[r][c]: - count += 1 - - if grid[i][j] and (count < 2 or count > 3): - nGrid[i][j] = False - if grid[i][j] == False and count == 3: - nGrid[i][j] = True + height, width = stdscr.getmaxyx() + grid_rows = max(3, height - 4) + grid_cols = max(3, width) - noOfCellsAlive += nGrid[i][j] + engine = GameOfLifeEngine(rows=grid_rows, cols=grid_cols, wrap=wrap) + if preset == "random" or preset not in engine.PRESETS: + engine.seed_random(density=0.25) + else: + engine.load_preset(preset) - return [nGrid, noOfCellsAlive] + paused = False + current_speed = speed + status_msg = "Running" - # Inception - grid = seed() + cell_char = "O" while True: - # Initialization - stdscr.clear() - stdscr.nodelay(1) - nHeight, nWidth = stdscr.getmaxyx() + # Check window resize + n_height, n_width = stdscr.getmaxyx() + if n_height != height or n_width != width: + height, width = n_height, n_width + grid_rows = max(3, height - 4) + grid_cols = max(3, width) + engine.resize(grid_rows, grid_cols) + stdscr.clear() + + # Update simulation if not paused + if not paused: + engine.step() + + alive_count = engine.get_alive_count() + + # Render grid + stdscr.erase() + cell_attr = curses.color_pair(1) | curses.A_BOLD if curses.has_colors() else curses.A_BOLD + + for r in range(min(grid_rows, len(engine.grid))): + row_chars = [] + for c in range(min(grid_cols, len(engine.grid[r]))): + row_chars.append(cell_char if engine.grid[r][c] else " ") + safe_addstr(stdscr, r, 0, "".join(row_chars), cell_attr) + + # Render Header / Title + header_attr = curses.color_pair(2) | curses.A_BOLD if curses.has_colors() else curses.A_BOLD + safe_addstr(stdscr, height - 3, 0, "โ”€โ”€ Conway's Game of Life โ”€โ”€", header_attr) + credits_str = "Keys: [Space] Pause | [n] Step | [r] Rand | [c] Clear | [w] Wrap | [1-5] Presets | [q] Quit" + safe_addstr(stdscr, height - 2, 0, credits_str, curses.color_pair(4) if curses.has_colors() else 0) + + # Render Status Bar + status_bar = f"Gen: {engine.generation} | Alive: {alive_count} | Speed: {current_speed:.2f}s | Wrap: {'ON' if engine.wrap else 'OFF'} | State: {status_msg}" + status_attr = curses.color_pair(3) | curses.A_REVERSE if curses.has_colors() else curses.A_REVERSE + safe_addstr(stdscr, height - 1, 0, status_bar.ljust(width - 1), status_attr) - generations += 1 - - # If windows dimension changes #responsive :) - if ((nHeight != height) or (nWidth != width)): - height, width = nHeight, nWidth - rows, cols = int(height-3), width - grid = seed() + stdscr.refresh() + time.sleep(current_speed) - # Simulating Generations - grid, noOfCellsAlive = play(grid) + # Key event handling + try: + ch = stdscr.getch() + except curses.error: + ch = -1 - # Menu Cmds - if k == ord('q'): + if ch == ord('q') or ch == 27: # 'q' or Esc break - elif k == ord('r'): - grid = seed() - generations = 0 - elif k == ord('f'): - speed = 0.1 - elif k == ord('s'): - speed = 1 - - # Displaying grid - stdscr.attron(curses.color_pair(1)) - stdscr.attron(curses.A_BOLD) - for i in range(rows): - for j in range(cols): - stdscr.addstr(i, j, chr(0x2B1A) if grid[i][j] else ' ') - - stdscr.attroff(curses.color_pair(1)) - stdscr.attroff(curses.A_BOLD) - - # Declaration of strings - title = 'Game Of Life' - credits = 'By @zeal2end' - statusbarstr = "Exit: 'q' | Seed: 'r' | Fast: 'f' | Slow: 's' | Genration: {} | Alive Cells: {}".format( - generations, noOfCellsAlive) - - # calculations - start_x_title = 0 - start_x_credit = width - len(credits) - 1 - start_y = int(height - 2) - - # Render status bar - stdscr.attron(curses.color_pair(3)) - stdscr.addstr(height-1, 0, statusbarstr) - stdscr.attroff(curses.color_pair(3)) - - # Turning on attributes for title - stdscr.attron(curses.color_pair(2)) - - # Rendering title - stdscr.addstr(start_y, start_x_title, title) - stdscr.addstr(start_y, start_x_credit, credits) - - # Turning off attributes for title - stdscr.attroff(curses.color_pair(2)) - - # Refresh the screen - stdscr.refresh() - time.sleep(speed) - - # Wait for next input - k = stdscr.getch() + elif ch == ord(' ') or ch == ord('p'): # Pause / Resume + paused = not paused + status_msg = "PAUSED" if paused else "Running" + elif ch == ord('n') and paused: # Single step when paused + engine.step() + elif ch == ord('r'): # Randomize + engine.seed_random(density=0.25) + status_msg = "Randomized" + elif ch == ord('c'): # Clear + engine.clear() + status_msg = "Cleared" + elif ch == ord('w'): # Toggle wrap + engine.wrap = not engine.wrap + elif ch in (ord('f'), ord('+'), ord('=')): # Faster + current_speed = max(0.02, current_speed - 0.03) + elif ch in (ord('s'), ord('-'), ord('_')): # Slower + current_speed = min(1.0, current_speed + 0.03) + # Preset shortcuts + elif ch == ord('1'): + engine.load_preset("blinker") + status_msg = "Preset: Blinker" + elif ch == ord('2'): + engine.load_preset("glider") + status_msg = "Preset: Glider" + elif ch == ord('3'): + engine.load_preset("pulsar") + status_msg = "Preset: Pulsar" + elif ch == ord('4'): + engine.load_preset("gosper_gun") + status_msg = "Preset: Gosper Gun" + elif ch == ord('5'): + engine.load_preset("beacon") + status_msg = "Preset: Beacon" + + +def run_ansi_terminal_mode(preset: str = "random", speed: float = 0.15, rows: int = 20, cols: int = 50, + steps: Optional[int] = None, wrap: bool = True): + """Fallback terminal rendering using ANSI escape sequences.""" + engine = GameOfLifeEngine(rows=rows, cols=cols, wrap=wrap) + if preset == "random" or preset not in engine.PRESETS: + engine.seed_random(density=0.25) + else: + engine.load_preset(preset) + + step_count = 0 + try: + while True: + # Clear screen ANSI code + sys.stdout.write("\033[H\033[J") + sys.stdout.write(f"=== Conway's Game of Life (Terminal Mode) ===\n") + sys.stdout.write(f"Gen: {engine.generation} | Alive: {engine.get_alive_count()} | Wrap: {'ON' if engine.wrap else 'OFF'}\n") + sys.stdout.write("+" + "-" * cols + "+\n") + + for r in range(engine.rows): + row_str = "".join("O" if engine.grid[r][c] else " " for c in range(engine.cols)) + sys.stdout.write(f"|{row_str}|\n") + + sys.stdout.write("+" + "-" * cols + "+\n") + sys.stdout.write("Press Ctrl+C to exit.\n") + sys.stdout.flush() + + step_count += 1 + if steps is not None and step_count >= steps: + break + + time.sleep(speed) + engine.step() + + except KeyboardInterrupt: + print("\nExiting Conway's Game of Life.") def main(): - curses.wrapper(GameOfLife) + parser = argparse.ArgumentParser( + description="Conway's Game of Life - Interactive and CLI Simulation.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + Interactive Curses Mode: + python GameOfLife.py + + Terminal (ANSI Fallback) Mode with Glider: + python GameOfLife.py --cli --preset glider + + Run 20 simulation steps: + python GameOfLife.py --cli --preset pulsar --steps 20 + """ + ) + parser.add_argument("--cli", action="store_true", help="Force ANSI terminal output mode (useful if curses is unavailable).") + parser.add_argument("--preset", choices=["random", "glider", "blinker", "toad", "beacon", "pulsar", "gosper_gun"], + default="random", help="Initial pattern preset (default: random).") + parser.add_argument("--speed", type=float, default=0.15, help="Simulation interval in seconds (default: 0.15).") + parser.add_argument("--rows", type=int, default=20, help="Grid rows (CLI mode, default: 20).") + parser.add_argument("--cols", type=int, default=50, help="Grid columns (CLI mode, default: 50).") + parser.add_argument("--steps", type=int, default=None, help="Number of steps to execute and exit (CLI mode).") + parser.add_argument("--no-wrap", action="store_true", help="Disable toroidal grid wrap-around.") + + args = parser.parse_args() + wrap = not args.no_wrap + + if args.cli or not HAS_CURSES: + if not HAS_CURSES and not args.cli: + print("Note: 'curses' module not found. Starting in ANSI terminal mode.") + print("To enable full screen UI on Windows, run: pip install windows-curses\n") + time.sleep(1.0) + run_ansi_terminal_mode(preset=args.preset, speed=args.speed, rows=args.rows, cols=args.cols, + steps=args.steps, wrap=wrap) + else: + try: + curses.wrapper(lambda stdscr: run_curses_ui(stdscr, preset=args.preset, speed=args.speed, wrap=wrap)) + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/Convoys_GameofLife/README.md b/Convoys_GameofLife/README.md index 90323e4c..6c8f681f 100644 --- a/Convoys_GameofLife/README.md +++ b/Convoys_GameofLife/README.md @@ -3,38 +3,99 @@ ![Open Source Love](https://badges.frapsoft.com/os/v1/open-source.svg?v=103) [![View My Profile](https://img.shields.io/badge/View-My_Profile-green?logo=GitHub)](https://github.com/zeal2end) -# Script Title +# Conway's Game of Life -

- +Conway's Game of Life Animation +

## ๐Ÿ› ๏ธ Description - -A simple Command line UI project for Visualizing Convoy's Game Of Life developed using python and curses(its a python library for cmd line UI). If you haven't heard about Convoy's Game of Life, definately checkout this [video](https://www.youtube.com/watch?v=6avJHaC3C2U) on youtube by Dylan Beattie. Thanks me later!! -## โš™๏ธ Languages or Frameworks Used - -If you are on linux, you are good to go just head toward your terminal. +A feature-rich terminal simulation of **Conway's Game of Life** โ€” the famous cellular automaton devised by British mathematician John Horton Conway. + +The universe of the Game of Life is an infinite, two-dimensional orthogonal grid of square cells, each of which is in one of two possible states, live or dead. Every cell interacts with its eight neighbours following Conway's four rules: +1. **Underpopulation**: Any live cell with fewer than two live neighbours dies. +2. **Survival**: Any live cell with two or three live neighbours lives on to the next generation. +3. **Overpopulation**: Any live cell with more than three live neighbours dies. +4. **Reproduction**: Any dead cell with exactly three live neighbours becomes a live cell. + +--- + +### โœจ Features +- **Interactive Curses UI**: Real-time rendering with colors, generation counters, live cell statistics, and auto-resizing. +- **ANSI Terminal Fallback**: Runs out-of-the-box in basic terminal environments even if `curses` is not installed. +- **Built-in Pattern Presets**: Instantly load famous patterns including **Glider**, **Blinker**, **Toad**, **Beacon**, **Pulsar**, and **Gosper Glider Gun**. +- **Interactive Controls**: + - `Space` / `p`: Pause / Resume simulation + - `n`: Single-step forward when paused + - `r`: Randomize grid + - `c`: Clear grid + - `w`: Toggle Toroidal wrap-around boundaries + - `1`-`5`: Load preset patterns + - `f` / `+`: Increase speed (faster) + - `s` / `-`: Decrease speed (slower) + - `q` / `Esc`: Quit +- **CLI Automation**: Support for flags (`--cli`, `--preset`, `--speed`, `--rows`, `--cols`, `--steps`, `--no-wrap`). + +--- + +## โš™๏ธ Requirements & Installation + +### Linux & macOS +Python standard library includes `curses` out of the box: +```sh +pip install -r requirements.txt +``` -On Windows, +### Windows +On Windows, install `windows-curses` for the interactive full-screen curses UI: ```sh -pip install curses +pip install windows-curses ``` +*(Note: If `windows-curses` is not installed, the script will automatically run in ANSI Terminal Mode!)* + +--- + +## ๐ŸŒŸ How to Run -## ๐ŸŒŸ How to run - -Running the Script is really simple just head over to the directory of the code into your terminal and run this line: +### 1. Interactive UI (Curses) ```sh python GameOfLife.py ``` +### 2. Launch with a Specific Preset +```sh +python GameOfLife.py --preset pulsar +``` + +### 3. Command-Line (ANSI Fallback) Mode +```sh +python GameOfLife.py --cli --preset glider --speed 0.1 +``` + +### 4. Run for a Fixed Number of Steps +```sh +python GameOfLife.py --cli --preset gosper_gun --steps 50 +``` + +--- + ## ๐Ÿ“บ Demo

-Convoy's Game of line in CommandLine +Conway's Game of Life in CommandLine +

+ +--- + +## ๐Ÿงช Running Automated Tests + +Run the unit test suite to verify the simulation rules, oscillators, still lifes, and presets: +```sh +python -m unittest test_game_of_life.py -v +``` + +--- -## ๐Ÿค– Author - -[Vivek Kumar](https://github.com/zeal2end) \ No newline at end of file +## ๐Ÿค– Authors & Contributors +- **Original Author**: [Vivek Kumar](https://github.com/zeal2end) +- **Improvements & Fixes**: Open Source Contributors \ No newline at end of file diff --git a/Convoys_GameofLife/requirements.txt b/Convoys_GameofLife/requirements.txt new file mode 100644 index 00000000..608c9d30 --- /dev/null +++ b/Convoys_GameofLife/requirements.txt @@ -0,0 +1,2 @@ +windows-curses>=2.3.0; sys_platform == 'win32' +pytest>=7.0.0 diff --git a/Convoys_GameofLife/test_game_of_life.py b/Convoys_GameofLife/test_game_of_life.py new file mode 100644 index 00000000..4e2415d1 --- /dev/null +++ b/Convoys_GameofLife/test_game_of_life.py @@ -0,0 +1,123 @@ +""" +Unit tests for Conway's Game of Life (GameOfLifeEngine) +""" + +import unittest +from GameOfLife import GameOfLifeEngine + + +class TestGameOfLifeEngine(unittest.TestCase): + + def test_initialization(self): + engine = GameOfLifeEngine(rows=10, cols=15, wrap=True) + self.assertEqual(engine.rows, 10) + self.assertEqual(engine.cols, 15) + self.assertTrue(engine.wrap) + self.assertEqual(engine.generation, 0) + self.assertEqual(engine.get_alive_count(), 0) + + def test_clear_and_random_seed(self): + engine = GameOfLifeEngine(rows=10, cols=10) + engine.seed_random(density=0.5) + self.assertGreater(engine.get_alive_count(), 0) + + engine.clear() + self.assertEqual(engine.get_alive_count(), 0) + self.assertEqual(engine.generation, 0) + + def test_still_life_block(self): + # 2x2 Block should remain completely static forever + engine = GameOfLifeEngine(rows=6, cols=6, wrap=False) + engine.grid[2][2] = True + engine.grid[2][3] = True + engine.grid[3][2] = True + engine.grid[3][3] = True + self.assertEqual(engine.get_alive_count(), 4) + + for _ in range(5): + gen, alive = engine.step() + self.assertEqual(alive, 4) + self.assertTrue(engine.grid[2][2]) + self.assertTrue(engine.grid[2][3]) + self.assertTrue(engine.grid[3][2]) + self.assertTrue(engine.grid[3][3]) + + def test_blinker_oscillator_period_2(self): + # Horizontal blinker of 3 cells centered at (2, 2) + engine = GameOfLifeEngine(rows=5, cols=5, wrap=False) + engine.grid[2][1] = True + engine.grid[2][2] = True + engine.grid[2][3] = True + + # Generation 1: Becomes vertical + engine.step() + self.assertEqual(engine.get_alive_count(), 3) + self.assertTrue(engine.grid[1][2]) + self.assertTrue(engine.grid[2][2]) + self.assertTrue(engine.grid[3][2]) + self.assertFalse(engine.grid[2][1]) + self.assertFalse(engine.grid[2][3]) + + # Generation 2: Back to horizontal + engine.step() + self.assertEqual(engine.get_alive_count(), 3) + self.assertTrue(engine.grid[2][1]) + self.assertTrue(engine.grid[2][2]) + self.assertTrue(engine.grid[2][3]) + + def test_underpopulation_and_overpopulation(self): + engine = GameOfLifeEngine(rows=5, cols=5, wrap=False) + # Single isolated cell dies (underpopulation) + engine.grid[2][2] = True + engine.step() + self.assertEqual(engine.get_alive_count(), 0) + + # 4 neighbors on center cell causes overpopulation death + engine.clear() + engine.grid[2][2] = True + engine.grid[1][2] = True + engine.grid[3][2] = True + engine.grid[2][1] = True + engine.grid[2][3] = True + engine.step() + self.assertFalse(engine.grid[2][2]) + + def test_reproduction(self): + # Dead cell with exactly 3 live neighbors becomes alive + engine = GameOfLifeEngine(rows=5, cols=5, wrap=False) + engine.grid[1][2] = True + engine.grid[2][1] = True + engine.grid[3][2] = True + self.assertFalse(engine.grid[2][2]) + engine.step() + self.assertTrue(engine.grid[2][2]) + + def test_presets_loading(self): + engine = GameOfLifeEngine(rows=30, cols=50) + for preset_name in ["blinker", "toad", "beacon", "glider", "pulsar", "gosper_gun"]: + success = engine.load_preset(preset_name) + self.assertTrue(success, f"Failed to load preset '{preset_name}'") + self.assertGreater(engine.get_alive_count(), 0) + + self.assertFalse(engine.load_preset("non_existent_preset")) + + def test_toroidal_wrapping(self): + # Glider hitting right-bottom edge wraps around with wrap=True + engine = GameOfLifeEngine(rows=5, cols=5, wrap=True) + engine.grid[0][0] = True + # Count neighbors of (0, 0) includes wrapped cells + engine.grid[4][4] = True + engine.grid[4][0] = True + self.assertEqual(engine.count_neighbors(0, 0), 2) + + def test_resize(self): + engine = GameOfLifeEngine(rows=5, cols=5) + engine.grid[1][1] = True + engine.resize(10, 12) + self.assertEqual(engine.rows, 10) + self.assertEqual(engine.cols, 12) + self.assertTrue(engine.grid[1][1]) + + +if __name__ == "__main__": + unittest.main()