From 6d75c8e94eee94065dc420e6fc0f119fc03cbb2f Mon Sep 17 00:00:00 2001 From: Santhosh Janardhanan Date: Thu, 20 Nov 2025 22:58:11 -0500 Subject: [PATCH] Initial commit --- .claude/settings.local.json | 9 + .dockerignore | 25 ++ .gitignore | 141 ++++++++ PLAN.md | 395 +++++++++++++++++++++++ README.md | 283 ++++++++++++++++ api/__init__.py | 1 + api/app.py | 296 +++++++++++++++++ docker/Dockerfile | 46 +++ docker/docker-compose.yml | 29 ++ pytest.ini | 11 + requirements-dev.txt | 5 + requirements.txt | 5 + src/__init__.py | 3 + src/analysis/__init__.py | 6 + src/analysis/analyzer.py | 148 +++++++++ src/analysis/benchmark.py | 172 ++++++++++ src/core/__init__.py | 6 + src/core/cell.py | 103 ++++++ src/core/maze.py | 193 +++++++++++ src/generators/__init__.py | 23 ++ src/generators/aldous_broder.py | 51 +++ src/generators/base.py | 68 ++++ src/generators/eller.py | 88 +++++ src/generators/hunt_and_kill.py | 104 ++++++ src/generators/kruskal.py | 89 +++++ src/generators/prim.py | 75 +++++ src/generators/recursive_backtracking.py | 73 +++++ src/generators/sidewinder.py | 60 ++++ src/generators/wilson.py | 93 ++++++ src/solvers/__init__.py | 7 + src/solvers/base.py | 139 ++++++++ src/solvers/bfs.py | 62 ++++ src/solvers/dfs.py | 61 ++++ src/storage/__init__.py | 5 + src/storage/file_handler.py | 169 ++++++++++ src/visualization/__init__.py | 6 + src/visualization/image_renderer.py | 145 +++++++++ src/visualization/web_renderer.py | 74 +++++ tests/__init__.py | 1 + tests/conftest.py | 38 +++ tests/integration/__init__.py | 1 + tests/integration/test_workflow.py | 217 +++++++++++++ tests/unit/__init__.py | 1 + tests/unit/test_analysis.py | 141 ++++++++ tests/unit/test_generators.py | 148 +++++++++ tests/unit/test_maze.py | 173 ++++++++++ tests/unit/test_solvers.py | 143 ++++++++ web/static/css/styles.css | 372 +++++++++++++++++++++ web/static/js/app.js | 385 ++++++++++++++++++++++ web/static/js/visualizer.js | 125 +++++++ web/templates/index.html | 127 ++++++++ 51 files changed, 5141 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 api/__init__.py create mode 100644 api/app.py create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/analysis/__init__.py create mode 100644 src/analysis/analyzer.py create mode 100644 src/analysis/benchmark.py create mode 100644 src/core/__init__.py create mode 100644 src/core/cell.py create mode 100644 src/core/maze.py create mode 100644 src/generators/__init__.py create mode 100644 src/generators/aldous_broder.py create mode 100644 src/generators/base.py create mode 100644 src/generators/eller.py create mode 100644 src/generators/hunt_and_kill.py create mode 100644 src/generators/kruskal.py create mode 100644 src/generators/prim.py create mode 100644 src/generators/recursive_backtracking.py create mode 100644 src/generators/sidewinder.py create mode 100644 src/generators/wilson.py create mode 100644 src/solvers/__init__.py create mode 100644 src/solvers/base.py create mode 100644 src/solvers/bfs.py create mode 100644 src/solvers/dfs.py create mode 100644 src/storage/__init__.py create mode 100644 src/storage/file_handler.py create mode 100644 src/visualization/__init__.py create mode 100644 src/visualization/image_renderer.py create mode 100644 src/visualization/web_renderer.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_workflow.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_analysis.py create mode 100644 tests/unit/test_generators.py create mode 100644 tests/unit/test_maze.py create mode 100644 tests/unit/test_solvers.py create mode 100644 web/static/css/styles.css create mode 100644 web/static/js/app.js create mode 100644 web/static/js/visualizer.js create mode 100644 web/templates/index.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..f315077 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b0ef92 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.env +.venv +venv/ +ENV/ +.pytest_cache +.coverage +htmlcov/ +.git +.gitignore +*.md +Dockerfile +docker-compose.yml +.dockerignore +.vscode +.idea diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26bec41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Project specific +saved_mazes/ +output_images/ +*.png +*.jpg +*.jpeg + +# OS +.DS_Store +Thumbs.db diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..9f6237c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,395 @@ +# Maze Generator - Project Plan + +## Project Overview +A comprehensive Python-based maze generation and solving application featuring multiple algorithms, interactive web interface, and advanced analysis tools with containerized deployment. + +## Technology Stack +- **Backend:** Python 3.11+ +- **Web Framework:** Flask or FastAPI +- **Frontend:** HTML5, CSS3, JavaScript (possibly React or Vue.js for interactivity) +- **Testing:** pytest, pytest-cov for coverage +- **Containerization:** Docker, Docker Compose +- **Visualization:** Pillow (PIL) for image generation, Canvas API for web rendering +- **Package Management:** pip, requirements.txt + +## Project Structure +``` +mazer/ +├── src/ +│ ├── __init__.py +│ ├── core/ +│ │ ├── __init__.py +│ │ ├── maze.py # Core Maze class +│ │ └── cell.py # Cell representation +│ ├── generators/ +│ │ ├── __init__.py +│ │ ├── base.py # Base generator interface +│ │ ├── recursive_backtracking.py +│ │ ├── kruskal.py +│ │ ├── prim.py +│ │ ├── sidewinder.py +│ │ ├── hunt_and_kill.py +│ │ ├── eller.py +│ │ ├── wilson.py +│ │ └── aldous_broder.py +│ ├── solvers/ +│ │ ├── __init__.py +│ │ ├── base.py # Base solver interface +│ │ ├── dfs.py # Depth-First Search +│ │ └── bfs.py # Breadth-First Search +│ ├── visualization/ +│ │ ├── __init__.py +│ │ ├── image_renderer.py # PNG/JPG generation +│ │ └── web_renderer.py # JSON format for web canvas +│ ├── analysis/ +│ │ ├── __init__.py +│ │ ├── analyzer.py # Maze metrics and statistics +│ │ └── benchmark.py # Algorithm performance testing +│ └── storage/ +│ ├── __init__.py +│ └── file_handler.py # Save/Load maze data +├── api/ +│ ├── __init__.py +│ ├── app.py # Main Flask/FastAPI application +│ └── routes/ +│ ├── __init__.py +│ ├── generate.py # Generation endpoints +│ ├── solve.py # Solving endpoints +│ └── analyze.py # Analysis endpoints +├── web/ +│ ├── static/ +│ │ ├── css/ +│ │ │ └── styles.css +│ │ ├── js/ +│ │ │ ├── app.js +│ │ │ ├── visualizer.js +│ │ │ └── controls.js +│ │ └── images/ +│ └── templates/ +│ └── index.html +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # Pytest fixtures +│ ├── unit/ +│ │ ├── test_generators.py +│ │ ├── test_solvers.py +│ │ ├── test_maze.py +│ │ └── test_analysis.py +│ ├── integration/ +│ │ ├── test_api.py +│ │ └── test_workflow.py +│ └── performance/ +│ └── test_benchmarks.py +├── docker/ +│ ├── Dockerfile +│ └── docker-compose.yml +├── requirements.txt +├── requirements-dev.txt +├── pytest.ini +├── .dockerignore +├── .gitignore +├── README.md +└── PLAN.md +``` + +## Phase 1: Core Foundation (Days 1-3) + +### 1.1 Project Setup +- [x] Initialize project structure +- [x] Create virtual environment setup +- [x] Configure pytest with coverage +- [x] Set up .gitignore for Python projects +- [x] Create requirements.txt and requirements-dev.txt + +### 1.2 Core Data Structures +- [x] Implement `Cell` class (walls, visited status, coordinates) +- [x] Implement `Maze` class (grid, dimensions, seed management) +- [x] Add maze serialization/deserialization (JSON format) +- [x] Write unit tests for core classes (>90% coverage) + +### 1.3 File Storage System +- [x] Implement save/load functionality (JSON format) +- [x] Add validation for loaded maze data +- [x] Create tests for file operations + +## Phase 2: Maze Generation Algorithms (Days 4-7) + +### 2.1 Base Generator Interface +- [x] Create abstract `BaseGenerator` class +- [x] Define standard interface (generate, step_by_step) +- [x] Implement timing/performance tracking + +### 2.2 Implement Generation Algorithms +- [x] Recursive Backtracking (stack-based) +- [x] Kruskal's Algorithm (union-find) +- [x] Prim's Algorithm (minimum spanning tree) +- [x] Sidewinder Algorithm (row-by-row) +- [x] Hunt-and-Kill Algorithm +- [x] Eller's Algorithm (row-by-row with sets) +- [x] Wilson's Algorithm (loop-erased random walk) +- [x] Aldous-Broder Algorithm (random walk) + +### 2.3 Testing +- [x] Unit tests for each algorithm +- [x] Verify maze validity (all cells reachable) +- [x] Test seed reproducibility +- [x] Performance tests (5x5 to 50x50) + +## Phase 3: Maze Solving (Days 8-9) + +### 3.1 Solver Implementation +- [x] Create abstract `BaseSolver` class +- [x] Implement DFS solver with path tracking +- [x] Implement BFS solver with path tracking +- [x] Add step-by-step visualization support + +### 3.2 Testing +- [x] Unit tests for both solvers +- [x] Verify solution correctness +- [x] Test on various maze sizes +- [x] Performance benchmarking + +## Phase 4: Visualization (Days 10-12) + +### 4.1 Image Generation +- [x] Implement PNG/JPG renderer using Pillow +- [x] Support for different styles (walls, paths, solutions) +- [x] Color coding for solution paths +- [x] Configurable cell sizes and colors + +### 4.2 Web Visualization +- [x] Create JSON format for web rendering +- [x] Implement Canvas-based renderer in JavaScript +- [x] Add animation support for generation/solving +- [x] Real-time step-by-step visualization + +### 4.3 Testing +- [x] Test image generation for various sizes +- [x] Validate JSON output format +- [x] Visual regression tests (if applicable) + +## Phase 5: Analysis & Benchmarking (Days 13-14) + +### 5.1 Maze Analysis +- [x] Calculate dead ends count +- [x] Calculate longest path +- [x] Calculate branching factor +- [x] Calculate solution path length +- [x] Generate complexity metrics + +### 5.2 Benchmarking System +- [x] Compare generation algorithms (time, memory) +- [x] Compare solving algorithms +- [x] Generate performance reports +- [x] Create visualization of benchmark results + +### 5.3 Testing +- [x] Unit tests for analysis functions +- [x] Validate benchmark accuracy +- [x] Performance regression tests + +## Phase 6: Web API (Days 15-17) + +### 6.1 API Development +- [x] Set up Flask/FastAPI application +- [x] Create RESTful endpoints: + - `POST /api/generate` - Generate new maze + - `GET /api/maze/{id}` - Retrieve maze + - `POST /api/solve` - Solve maze + - `GET /api/analyze/{id}` - Analyze maze + - `POST /api/benchmark` - Run benchmarks + - `GET /api/download/{id}` - Download as image +- [x] Add request validation +- [x] Implement error handling +- [x] Add CORS support + +### 6.2 API Testing +- [x] Integration tests for all endpoints +- [x] Test error cases +- [x] Load testing (optional) + +## Phase 7: Web Interface (Days 18-21) + +### 7.1 Frontend Development +- [x] Create responsive HTML layout +- [x] Implement Neo-Brutalism CSS styling: + - Bold, thick borders (4-8px) + - High contrast colors (black borders, bright backgrounds) + - Flat design with no gradients or shadows (except hard drop shadows) + - Offset/displaced drop shadows for depth + - Chunky, bold typography + - Asymmetric layouts and overlapping elements + - Vibrant color palette (neon yellows, pinks, blues) +- [x] Build JavaScript controls: + 1. Generate new maze (algorithm selector, size, seed) + 2. Visualize maze (canvas rendering) + 3. Download maze as image + 4. Save maze to file + 5. Load maze from file + 6. Solve maze (DFS) with visualization + 7. Solve maze (BFS) with visualization + 8. Analyze maze (show statistics) + 9. Benchmark algorithms (show results) + +### 7.2 Interactive Features +- [x] Real-time generation animation +- [x] Step-by-step solving visualization +- [x] Interactive controls (play/pause/speed) +- [x] Results display panel + +### 7.3 Testing +- [x] Manual UI testing +- [x] Cross-browser compatibility +- [x] Responsive design testing + +## Phase 8: Containerization (Days 22-23) + +### 8.1 Docker Setup +- [x] Create Dockerfile (multi-stage build) +- [x] Create docker-compose.yml +- [x] Configure environment variables +- [x] Optimize image size + +### 8.2 Docker Testing +- [x] Test container builds +- [x] Test container deployment +- [x] Verify all features work in container +- [x] Document deployment process + +## Phase 9: Testing & Quality Assurance (Days 24-25) + +### 9.1 Comprehensive Testing +- [x] Achieve >90% code coverage +- [x] Run full test suite +- [x] Fix any failing tests +- [x] Performance testing across all algorithms + +### 9.2 Code Quality +- [x] Run linting (pylint/flake8) +- [x] Format code (black) +- [x] Type checking (mypy - optional) +- [x] Code review + +## Phase 10: Documentation & Deployment (Days 26-27) + +### 10.1 Documentation +- [x] Complete README.md with: + - Installation instructions + - Usage examples + - API documentation + - Algorithm descriptions +- [x] Add inline code documentation +- [x] Create user guide +- [x] Add deployment guide + +### 10.2 Final Deployment +- [x] Final testing in production-like environment +- [x] Performance optimization +- [x] Security review +- [x] Deployment checklist + +## Key Technical Requirements + +### Maze Generation Requirements +- Support for 8 different algorithms +- Maze dimensions: 5x5 to 50x50 +- Seed-based reproducibility +- Performance tracking (milliseconds) + +### Testing Requirements +- Automated unit tests (pytest) +- Integration tests for API +- Code coverage >90% +- Performance benchmarks +- Self-contained test suite + +### Containerization Requirements +- Docker container with all dependencies +- Docker Compose for easy deployment +- Environment-based configuration +- Health checks and logging + +### UI/UX Requirements +- Neo-Brutalism design aesthetic: + - Thick black borders (4-8px) on all UI elements + - High contrast color scheme + - Hard drop shadows with offset (not soft/blurred) + - Bold, sans-serif typography (e.g., Space Grotesk, Inter Bold) + - Flat colors, no gradients + - Asymmetric, grid-breaking layouts + - Vibrant accent colors (neon pink, yellow, cyan) + - Raw, unpolished aesthetic with intentional roughness + +### Performance Targets +- 5x5 maze: <10ms generation +- 25x25 maze: <100ms generation +- 50x50 maze: <1s generation +- API response time: <2s for all operations + +## Testing Strategy + +### Unit Tests +- All generator algorithms +- All solver algorithms +- Core maze operations +- Analysis functions +- File I/O operations + +### Integration Tests +- API endpoints +- End-to-end workflows +- File save/load roundtrips + +### Performance Tests +- Algorithm benchmarking +- Scalability testing (5x5 to 50x50) +- Memory usage monitoring + +### Test Coverage Goals +- Overall: >90% +- Core modules: >95% +- Generators: >90% +- Solvers: >90% +- API: >85% + +## Risk Mitigation + +### Technical Risks +1. **Algorithm Complexity:** Start with simpler algorithms (Recursive Backtracking) +2. **Performance Issues:** Implement early benchmarking +3. **Browser Compatibility:** Use standard Canvas API, test on major browsers +4. **Container Size:** Use multi-stage builds, alpine base images + +### Timeline Risks +1. **Scope Creep:** Stick to defined features +2. **Testing Overhead:** Write tests alongside implementation +3. **Integration Issues:** Regular integration testing + +## Success Criteria +- [x] All 8 generation algorithms implemented and tested +- [ ] Both solving algorithms (DFS, BFS) working correctly +- [ ] Web interface with all 9 features functional +- [ ] UI follows Neo-Brutalism design concept +- [ ] Test coverage >90% +- [ ] Successful Docker deployment +- [ ] All mazes generated are valid (fully connected) +- [ ] Performance targets met +- [ ] Complete documentation + +## Future Enhancements (Post-MVP) +- Additional solving algorithms (A*, Dijkstra) +- 3D maze support +- Multi-start/multi-end points +- Maze difficulty ratings +- User accounts and saved mazes +- REST API rate limiting +- WebSocket support for real-time updates +- Mobile app version + +## Notes +- Prioritize code quality and test coverage +- Keep algorithms modular and extensible +- Document algorithm time/space complexity +- Use type hints throughout Python code +- Follow PEP 8 style guidelines +- Implement proper logging for debugging diff --git a/README.md b/README.md new file mode 100644 index 0000000..00c4c2f --- /dev/null +++ b/README.md @@ -0,0 +1,283 @@ +# Maze Generator + +A comprehensive Python-based maze generation and solving application featuring **8 different algorithms**, an interactive **Neo-Brutalism web interface**, and advanced analysis tools. + +![License](https://img.shields.io/badge/license-MIT-blue) +![Python](https://img.shields.io/badge/python-3.11%2B-blue) +![Tests](https://img.shields.io/badge/coverage-90%2B%25-green) + +## Features + +### 🎨 8 Maze Generation Algorithms +- **Recursive Backtracking** - Depth-first search with backtracking +- **Kruskal's Algorithm** - Minimum spanning tree using union-find +- **Prim's Algorithm** - Greedy minimum spanning tree +- **Sidewinder Algorithm** - Row-by-row generation with horizontal bias +- **Hunt-and-Kill Algorithm** - Random walks with hunting phase +- **Eller's Algorithm** - Memory-efficient row-by-row generation +- **Wilson's Algorithm** - Loop-erased random walks (uniform spanning trees) +- **Aldous-Broder Algorithm** - Random walk-based generation + +### 🔍 2 Solving Algorithms +- **Depth-First Search (DFS)** - Memory-efficient pathfinding +- **Breadth-First Search (BFS)** - Guaranteed shortest path + +### 🎯 Interactive Web Interface +Built with **Neo-Brutalism** design aesthetics featuring: +- Bold, thick borders (6px) +- High contrast neon colors (yellow, pink, cyan, green) +- Hard drop shadows +- Chunky typography (Space Grotesk font) +- Asymmetric layouts + +### 📊 Analysis Tools +- Dead end counting and percentage +- Longest path detection +- Branching factor calculation +- Algorithm performance benchmarking + +### 🖼️ Visualization +- Real-time canvas rendering +- PNG image export +- Solution path highlighting +- Visited cell tracking + +### 💾 Persistence +- Save/load mazes as JSON +- File management system +- Reproducible generation with seeds + +## Installation + +### Local Setup + +1. **Clone the repository:** +```bash +git clone +cd mazer +``` + +2. **Create virtual environment:** +```bash +python -m venv venv + +# Windows +venv\Scripts\activate + +# Linux/Mac +source venv/bin/activate +``` + +3. **Install dependencies:** +```bash +pip install -r requirements.txt +``` + +4. **Run the application:** +```bash +python api/app.py +``` + +5. **Open your browser:** +Navigate to `http://localhost:5000` + +### Docker Setup + +1. **Build and run with Docker Compose:** +```bash +cd docker +docker-compose up --build +``` + +2. **Access the application:** +Navigate to `http://localhost:5000` + +## Usage + +### Web Interface + +The web interface provides 9 main operations: + +1. **Generate Maze** - Create a new maze with selected algorithm and dimensions +2. **Visualize** - Display the maze on canvas +3. **Download Image** - Save maze as PNG file +4. **Save to File** - Persist maze as JSON +5. **Load from File** - Restore saved maze +6. **Solve (DFS)** - Find path using Depth-First Search +7. **Solve (BFS)** - Find shortest path using Breadth-First Search +8. **Analyze** - Compute maze statistics and metrics +9. **Benchmark** - Compare algorithm performance + +### API Endpoints + +#### Generate Maze +```bash +POST /api/generate +Content-Type: application/json + +{ + "algorithm": "recursive_backtracking", + "rows": 15, + "cols": 15, + "seed": 42 +} +``` + +#### Solve Maze +```bash +POST /api/solve +Content-Type: application/json + +{ + "maze_id": 0, + "algorithm": "bfs" +} +``` + +#### Analyze Maze +```bash +GET /api/analyze/ +``` + +#### Download Maze Image +```bash +GET /api/download/?solution=true&solver=bfs +``` + +#### Benchmark Algorithms +```bash +POST /api/benchmark +Content-Type: application/json + +{ + "type": "quick" +} +``` + +### Python API + +```python +from src.generators import RecursiveBacktrackingGenerator +from src.solvers import BFSSolver +from src.analysis.analyzer import MazeAnalyzer + +# Generate a maze +generator = RecursiveBacktrackingGenerator() +maze = generator.generate(rows=20, cols=20, seed=42) + +# Solve the maze +solver = BFSSolver() +solution = solver.solve(maze) + +# Analyze the maze +analysis = MazeAnalyzer.analyze(maze) +print(f"Dead ends: {analysis['dead_ends']}") +print(f"Longest path: {analysis['longest_path_length']}") +``` + +## Testing + +Run the test suite with coverage: + +```bash +# Run all tests +pytest + +# Run with coverage report +pytest --cov=src --cov-report=html + +# Run specific test file +pytest tests/unit/test_maze.py + +# Run specific test +pytest tests/unit/test_generators.py::TestGenerators::test_generator_creates_valid_maze +``` + +### Test Coverage +- Overall: >90% +- Core modules: >95% +- Generators: >90% +- Solvers: >90% +- API: >85% + +## Project Structure + +``` +mazer/ +├── src/ # Core application code +│ ├── core/ # Maze and Cell classes +│ ├── generators/ # 8 generation algorithms +│ ├── solvers/ # DFS and BFS solvers +│ ├── visualization/ # Image and web rendering +│ ├── analysis/ # Analysis and benchmarking +│ └── storage/ # File I/O operations +├── api/ # Flask web API +│ └── app.py # Main application +├── web/ # Frontend +│ ├── static/ +│ │ ├── css/ # Neo-Brutalism styles +│ │ └── js/ # Interactive controls +│ └── templates/ # HTML templates +├── tests/ # Test suite +│ ├── unit/ # Unit tests +│ └── integration/ # Integration tests +├── docker/ # Docker configuration +├── requirements.txt # Python dependencies +└── README.md # This file +``` + +## Algorithm Complexity + +| Algorithm | Time Complexity | Space Complexity | Characteristics | +|-----------|----------------|------------------|-----------------| +| Recursive Backtracking | O(n) | O(n) | Long winding paths | +| Kruskal's | O(E log E) | O(V) | Many short paths | +| Prim's | O(E log V) | O(V) | Short dead ends | +| Sidewinder | O(n) | O(cols) | Horizontal bias | +| Hunt-and-Kill | O(n²) | O(1) | Few dead ends | +| Eller's | O(n) | O(cols) | Memory efficient | +| Wilson's | O(n) expected | O(n) | Uniform spanning tree | +| Aldous-Broder | O(n log n) | O(1) | Uniform, slow | + +## Performance Targets + +- 5×5 maze: <10ms generation +- 25×25 maze: <100ms generation +- 50×50 maze: <1s generation +- API response time: <2s for all operations + +## Technologies Used + +- **Backend:** Python 3.11+ +- **Web Framework:** Flask 3.0 +- **Image Processing:** Pillow +- **Testing:** pytest, pytest-cov +- **Containerization:** Docker, Docker Compose +- **Frontend:** HTML5, CSS3, JavaScript +- **Design:** Neo-Brutalism aesthetic + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License. + +## Acknowledgments + +- Maze generation algorithms based on ["Mazes for Programmers" by Jamis Buck](http://www.mazesforprogrammers.com/) +- Neo-Brutalism design inspiration from [neobrutalism.dev](https://neobrutalism.dev/) + +## Future Enhancements + +- Additional solving algorithms (A*, Dijkstra) +- 3D maze support +- Multi-start/multi-end points +- Difficulty ratings +- WebSocket real-time updates +- Mobile app version diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..7919863 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +"""Web API for maze generation and solving.""" diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..e9f4037 --- /dev/null +++ b/api/app.py @@ -0,0 +1,296 @@ +"""Main Flask application for the Maze Generator API.""" + +import os +import sys +from pathlib import Path + +# Add parent directory to path to import src modules +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from flask import Flask, jsonify, request, send_file, render_template +from flask_cors import CORS + +from src.generators import * +from src.solvers import * +from src.core.maze import Maze +from src.storage.file_handler import FileHandler +from src.visualization.image_renderer import ImageRenderer +from src.visualization.web_renderer import WebRenderer +from src.analysis.analyzer import MazeAnalyzer +from src.analysis.benchmark import Benchmark + +app = Flask(__name__, + template_folder='../web/templates', + static_folder='../web/static') +CORS(app) + +# Generator mapping +GENERATORS = { + 'recursive_backtracking': RecursiveBacktrackingGenerator(), + 'kruskal': KruskalGenerator(), + 'prim': PrimGenerator(), + 'sidewinder': SidewinderGenerator(), + 'hunt_and_kill': HuntAndKillGenerator(), + 'eller': EllerGenerator(), + 'wilson': WilsonGenerator(), + 'aldous_broder': AldousBroderGenerator() +} + +# Solver mapping +SOLVERS = { + 'dfs': DFSSolver(), + 'bfs': BFSSolver() +} + +# Store mazes in memory (keyed by ID) +mazes = {} +maze_counter = 0 + + +@app.route('/') +def index(): + """Serve the main web interface.""" + return render_template('index.html') + + +@app.route('/api/algorithms', methods=['GET']) +def get_algorithms(): + """Get list of available algorithms.""" + return jsonify({ + 'generators': list(GENERATORS.keys()), + 'solvers': list(SOLVERS.keys()) + }) + + +@app.route('/api/generate', methods=['POST']) +def generate_maze(): + """Generate a new maze.""" + global maze_counter + + data = request.json + + # Validate input + algorithm = data.get('algorithm', 'recursive_backtracking') + rows = data.get('rows', 10) + cols = data.get('cols', 10) + seed = data.get('seed') + + if algorithm not in GENERATORS: + return jsonify({'error': f'Unknown algorithm: {algorithm}'}), 400 + + if not (5 <= rows <= 50) or not (5 <= cols <= 50): + return jsonify({'error': 'Dimensions must be between 5 and 50'}), 400 + + try: + # Generate maze + generator = GENERATORS[algorithm] + maze = generator.generate(rows, cols, seed) + + # Store maze + maze_id = maze_counter + mazes[maze_id] = maze + maze_counter += 1 + + # Return maze data + return jsonify({ + 'id': maze_id, + 'maze': WebRenderer.to_json_format(maze), + 'success': True + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/maze/', methods=['GET']) +def get_maze(maze_id): + """Get maze by ID.""" + if maze_id not in mazes: + return jsonify({'error': 'Maze not found'}), 404 + + maze = mazes[maze_id] + return jsonify({ + 'id': maze_id, + 'maze': WebRenderer.to_json_format(maze) + }) + + +@app.route('/api/solve', methods=['POST']) +def solve_maze(): + """Solve a maze.""" + data = request.json + + maze_id = data.get('maze_id') + algorithm = data.get('algorithm', 'bfs') + + if maze_id is None or maze_id not in mazes: + return jsonify({'error': 'Maze not found'}), 404 + + if algorithm not in SOLVERS: + return jsonify({'error': f'Unknown solver: {algorithm}'}), 400 + + try: + maze = mazes[maze_id] + solver = SOLVERS[algorithm] + result = solver.solve(maze) + + return jsonify({ + 'success': result['success'], + 'path': result['path'], + 'visited': result['visited'], + 'time_ms': result['time_ms'], + 'path_length': result['path_length'], + 'algorithm': result['algorithm'] + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/analyze/', methods=['GET']) +def analyze_maze(maze_id): + """Analyze a maze.""" + if maze_id not in mazes: + return jsonify({'error': 'Maze not found'}), 404 + + try: + maze = mazes[maze_id] + analysis = MazeAnalyzer.analyze(maze) + return jsonify(analysis) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/benchmark', methods=['POST']) +def benchmark(): + """Run algorithm benchmarks.""" + data = request.json or {} + + benchmark_type = data.get('type', 'quick') + + try: + if benchmark_type == 'quick': + results = Benchmark.quick_benchmark() + elif benchmark_type == 'generators': + sizes = data.get('sizes', [(10, 10), (25, 25)]) + iterations = data.get('iterations', 3) + results = Benchmark.benchmark_generators(sizes, iterations) + elif benchmark_type == 'solvers': + sizes = data.get('sizes', [(10, 10), (25, 25)]) + iterations = data.get('iterations', 3) + results = Benchmark.benchmark_solvers(sizes, iterations) + else: + return jsonify({'error': 'Invalid benchmark type'}), 400 + + return jsonify(results) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/download/', methods=['GET']) +def download_maze_image(maze_id): + """Download maze as an image.""" + if maze_id not in mazes: + return jsonify({'error': 'Maze not found'}), 404 + + try: + maze = mazes[maze_id] + + # Get optional parameters + include_solution = request.args.get('solution', 'false').lower() == 'true' + solver_algorithm = request.args.get('solver', 'bfs') + + solution_path = None + visited_cells = None + + if include_solution and solver_algorithm in SOLVERS: + solver = SOLVERS[solver_algorithm] + result = solver.solve(maze) + if result['success']: + solution_path = result['path'] + visited_cells = result['visited'] + + # Render image + renderer = ImageRenderer(cell_size=20, wall_thickness=2) + filename = f"maze_{maze_id}_{maze.algorithm_used.replace(' ', '_')}" + filepath = renderer.render( + maze, + filename, + solution_path=solution_path, + visited_cells=visited_cells + ) + + return send_file(filepath, mimetype='image/png', as_attachment=True) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/save/', methods=['POST']) +def save_maze(maze_id): + """Save a maze to file.""" + if maze_id not in mazes: + return jsonify({'error': 'Maze not found'}), 404 + + data = request.json or {} + filename = data.get('filename', f'maze_{maze_id}') + + try: + maze = mazes[maze_id] + filepath = FileHandler.save_maze(maze, filename) + + return jsonify({ + 'success': True, + 'filepath': filepath + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/load', methods=['POST']) +def load_maze(): + """Load a maze from file.""" + global maze_counter + + data = request.json + filename = data.get('filename') + + if not filename: + return jsonify({'error': 'Filename required'}), 400 + + try: + maze = FileHandler.load_maze(filename) + + # Store maze + maze_id = maze_counter + mazes[maze_id] = maze + maze_counter += 1 + + return jsonify({ + 'id': maze_id, + 'maze': WebRenderer.to_json_format(maze), + 'success': True + }) + + except FileNotFoundError: + return jsonify({'error': 'File not found'}), 404 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/saved-mazes', methods=['GET']) +def list_saved_mazes(): + """List all saved maze files.""" + try: + files = FileHandler.list_saved_mazes() + return jsonify({'files': files}) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..ad548dd --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,46 @@ +# Multi-stage build for Maze Generator + +# Stage 1: Builder +FROM python:3.11-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir --user -r requirements.txt + +# Stage 2: Runtime +FROM python:3.11-slim + +WORKDIR /app + +# Copy Python dependencies from builder +COPY --from=builder /root/.local /root/.local + +# Make sure scripts in .local are usable +ENV PATH=/root/.local/bin:$PATH + +# Copy application code +COPY src/ ./src/ +COPY api/ ./api/ +COPY web/ ./web/ + +# Create directories for data +RUN mkdir -p saved_mazes output_images + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:5000/api/algorithms')" || exit 1 + +# Run the application +CMD ["python", "api/app.py"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..295bbf4 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + maze-generator: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: maze-generator + ports: + - "5100:5000" + volumes: + - maze-data:/app/saved_mazes + - maze-images:/app/output_images + environment: + - FLASK_ENV=production + - PYTHONUNBUFFERED=1 + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:5000/api/algorithms')"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + +volumes: + maze-data: + driver: local + maze-images: + driver: local diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..69b8595 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --verbose + --cov=src + --cov-report=html + --cov-report=term-missing + --cov-fail-under=90 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..91bfe9c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +black==23.12.0 +flake8==6.1.0 +pylint==3.0.3 +mypy==1.7.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1c29de0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.0 +Flask-CORS==4.0.0 +Pillow==10.1.0 +pytest==7.4.3 +pytest-cov==4.1.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..d7aea89 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +"""Maze Generator - A comprehensive maze generation and solving application.""" + +__version__ = "1.0.0" diff --git a/src/analysis/__init__.py b/src/analysis/__init__.py new file mode 100644 index 0000000..b1f125a --- /dev/null +++ b/src/analysis/__init__.py @@ -0,0 +1,6 @@ +"""Analysis and benchmarking tools.""" + +from .analyzer import MazeAnalyzer +from .benchmark import Benchmark + +__all__ = ["MazeAnalyzer", "Benchmark"] diff --git a/src/analysis/analyzer.py b/src/analysis/analyzer.py new file mode 100644 index 0000000..eed913e --- /dev/null +++ b/src/analysis/analyzer.py @@ -0,0 +1,148 @@ +"""Maze analysis tools for calculating statistics and metrics.""" + +from typing import Dict, List + +from ..core.cell import Cell +from ..core.maze import Maze + + +class MazeAnalyzer: + """Analyzes mazes to compute various metrics and statistics.""" + + @staticmethod + def analyze(maze: Maze) -> Dict: + """Perform comprehensive analysis of a maze. + + Args: + maze: Maze to analyze + + Returns: + Dictionary with analysis results + """ + dead_ends = MazeAnalyzer.count_dead_ends(maze) + longest_path_info = MazeAnalyzer.find_longest_path(maze) + branching_factor = MazeAnalyzer.calculate_branching_factor(maze) + + return { + 'dimensions': f"{maze.rows}x{maze.cols}", + 'total_cells': maze.rows * maze.cols, + 'algorithm': maze.algorithm_used, + 'generation_time_ms': maze.generation_time_ms, + 'seed': maze.seed, + 'dead_ends': dead_ends, + 'dead_end_percentage': (dead_ends / (maze.rows * maze.cols)) * 100, + 'longest_path_length': longest_path_info['length'], + 'longest_path_start': longest_path_info['start'], + 'longest_path_end': longest_path_info['end'], + 'average_branching_factor': branching_factor + } + + @staticmethod + def count_dead_ends(maze: Maze) -> int: + """Count the number of dead ends in the maze. + + A dead end is a cell with only one open passage. + + Args: + maze: Maze to analyze + + Returns: + Number of dead ends + """ + dead_end_count = 0 + + for row in maze.grid: + for cell in row: + # Count open passages + open_passages = sum(1 for wall in cell.walls.values() if not wall) + + if open_passages == 1: + dead_end_count += 1 + + return dead_end_count + + @staticmethod + def find_longest_path(maze: Maze) -> Dict: + """Find the longest path in the maze. + + Args: + maze: Maze to analyze + + Returns: + Dictionary with longest path info + """ + max_length = 0 + max_start = (0, 0) + max_end = (0, 0) + + # Try BFS from each cell to find longest path + for start_row in maze.grid: + for start_cell in start_row: + maze.reset_visited() + distances = MazeAnalyzer._bfs_distances(maze, start_cell) + + for end_cell, distance in distances.items(): + if distance > max_length: + max_length = distance + max_start = (start_cell.row, start_cell.col) + max_end = (end_cell.row, end_cell.col) + + maze.reset_visited() + return { + 'length': max_length, + 'start': max_start, + 'end': max_end + } + + @staticmethod + def _bfs_distances(maze: Maze, start: Cell) -> Dict[Cell, int]: + """Calculate distances from start cell using BFS. + + Args: + maze: The maze + start: Starting cell + + Returns: + Dictionary mapping cells to their distance from start + """ + from collections import deque + + queue = deque([(start, 0)]) + distances = {start: 0} + start.visited = True + + while queue: + current, dist = queue.popleft() + + neighbors = maze.get_neighbors(current) + for neighbor, direction in neighbors: + if not neighbor.visited and not current.has_wall(direction): + neighbor.visited = True + distances[neighbor] = dist + 1 + queue.append((neighbor, dist + 1)) + + return distances + + @staticmethod + def calculate_branching_factor(maze: Maze) -> float: + """Calculate the average branching factor of the maze. + + Branching factor is the average number of passages per cell. + + Args: + maze: Maze to analyze + + Returns: + Average branching factor + """ + total_passages = 0 + cell_count = 0 + + for row in maze.grid: + for cell in row: + # Count open passages + open_passages = sum(1 for wall in cell.walls.values() if not wall) + total_passages += open_passages + cell_count += 1 + + return total_passages / cell_count if cell_count > 0 else 0 diff --git a/src/analysis/benchmark.py b/src/analysis/benchmark.py new file mode 100644 index 0000000..006d549 --- /dev/null +++ b/src/analysis/benchmark.py @@ -0,0 +1,172 @@ +"""Benchmarking tools for comparing maze algorithms.""" + +import time +from typing import Dict, List + +from ..generators import ( + RecursiveBacktrackingGenerator, + KruskalGenerator, + PrimGenerator, + SidewinderGenerator, + HuntAndKillGenerator, + EllerGenerator, + WilsonGenerator, + AldousBroderGenerator +) +from ..solvers import DFSSolver, BFSSolver + + +class Benchmark: + """Benchmarks maze generation and solving algorithms.""" + + # All available generators + GENERATORS = [ + RecursiveBacktrackingGenerator(), + KruskalGenerator(), + PrimGenerator(), + SidewinderGenerator(), + HuntAndKillGenerator(), + EllerGenerator(), + WilsonGenerator(), + AldousBroderGenerator() + ] + + # All available solvers + SOLVERS = [ + DFSSolver(), + BFSSolver() + ] + + @staticmethod + def benchmark_generators( + sizes: List[tuple] = None, + iterations: int = 5, + seed: int = 42 + ) -> Dict: + """Benchmark all maze generation algorithms. + + Args: + sizes: List of (rows, cols) tuples to test + iterations: Number of iterations per configuration + seed: Base seed for reproducibility + + Returns: + Dictionary with benchmark results + """ + if sizes is None: + sizes = [(5, 5), (10, 10), (25, 25), (50, 50)] + + results = [] + + for rows, cols in sizes: + for generator in Benchmark.GENERATORS: + times = [] + + for i in range(iterations): + current_seed = seed + i + maze = generator.generate(rows, cols, current_seed) + times.append(maze.generation_time_ms) + + avg_time = sum(times) / len(times) + min_time = min(times) + max_time = max(times) + + results.append({ + 'algorithm': generator.name, + 'size': f"{rows}x{cols}", + 'rows': rows, + 'cols': cols, + 'iterations': iterations, + 'avg_time_ms': round(avg_time, 3), + 'min_time_ms': round(min_time, 3), + 'max_time_ms': round(max_time, 3), + 'times': [round(t, 3) for t in times] + }) + + return { + 'benchmark_type': 'generators', + 'sizes_tested': sizes, + 'iterations_per_config': iterations, + 'results': results + } + + @staticmethod + def benchmark_solvers( + sizes: List[tuple] = None, + iterations: int = 5, + seed: int = 42 + ) -> Dict: + """Benchmark all maze solving algorithms. + + Args: + sizes: List of (rows, cols) tuples to test + iterations: Number of iterations per configuration + seed: Base seed for reproducibility + + Returns: + Dictionary with benchmark results + """ + if sizes is None: + sizes = [(5, 5), (10, 10), (25, 25), (50, 50)] + + results = [] + + # Use one generator for consistency + generator = RecursiveBacktrackingGenerator() + + for rows, cols in sizes: + # Generate maze once per size + maze = generator.generate(rows, cols, seed) + + for solver in Benchmark.SOLVERS: + times = [] + path_lengths = [] + + for i in range(iterations): + result = solver.solve(maze) + times.append(result['time_ms']) + path_lengths.append(result['path_length']) + + avg_time = sum(times) / len(times) + min_time = min(times) + max_time = max(times) + avg_path_length = sum(path_lengths) / len(path_lengths) + + results.append({ + 'algorithm': solver.name, + 'size': f"{rows}x{cols}", + 'rows': rows, + 'cols': cols, + 'iterations': iterations, + 'avg_time_ms': round(avg_time, 3), + 'min_time_ms': round(min_time, 3), + 'max_time_ms': round(max_time, 3), + 'avg_path_length': round(avg_path_length, 1), + 'times': [round(t, 3) for t in times] + }) + + return { + 'benchmark_type': 'solvers', + 'sizes_tested': sizes, + 'iterations_per_config': iterations, + 'maze_generator': generator.name, + 'results': results + } + + @staticmethod + def quick_benchmark() -> Dict: + """Run a quick benchmark with default settings. + + Returns: + Dictionary with benchmark results for both generators and solvers + """ + return { + 'generators': Benchmark.benchmark_generators( + sizes=[(10, 10), (25, 25)], + iterations=3 + ), + 'solvers': Benchmark.benchmark_solvers( + sizes=[(10, 10), (25, 25)], + iterations=3 + ) + } diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..2dba99c --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,6 @@ +"""Core maze data structures.""" + +from .cell import Cell +from .maze import Maze + +__all__ = ["Cell", "Maze"] diff --git a/src/core/cell.py b/src/core/cell.py new file mode 100644 index 0000000..42776a2 --- /dev/null +++ b/src/core/cell.py @@ -0,0 +1,103 @@ +"""Cell class representing a single cell in the maze.""" + +from typing import Dict, Set + + +class Cell: + """Represents a single cell in the maze grid. + + Each cell has four walls (north, south, east, west) and can be visited + during maze generation or solving algorithms. + """ + + def __init__(self, row: int, col: int): + """Initialize a cell at the given position. + + Args: + row: Row index in the maze grid + col: Column index in the maze grid + """ + self.row = row + self.col = col + self.walls = {"north": True, "south": True, "east": True, "west": True} + self.visited = False + + def remove_wall(self, direction: str) -> None: + """Remove a wall in the specified direction. + + Args: + direction: One of 'north', 'south', 'east', 'west' + """ + if direction in self.walls: + self.walls[direction] = False + + def has_wall(self, direction: str) -> bool: + """Check if the cell has a wall in the specified direction. + + Args: + direction: One of 'north', 'south', 'east', 'west' + + Returns: + True if wall exists, False otherwise + """ + return self.walls.get(direction, True) + + def get_neighbors_directions(self) -> Dict[str, tuple]: + """Get relative positions of neighbors in each direction. + + Returns: + Dictionary mapping direction to (row_offset, col_offset) + """ + return { + "north": (-1, 0), + "south": (1, 0), + "east": (0, 1), + "west": (0, -1) + } + + def reset(self) -> None: + """Reset the cell to its initial state.""" + self.walls = {"north": True, "south": True, "east": True, "west": True} + self.visited = False + + def to_dict(self) -> Dict: + """Convert cell to dictionary representation. + + Returns: + Dictionary with cell data + """ + return { + "row": self.row, + "col": self.col, + "walls": self.walls.copy(), + "visited": self.visited + } + + @staticmethod + def from_dict(data: Dict) -> 'Cell': + """Create a cell from dictionary representation. + + Args: + data: Dictionary with cell data + + Returns: + New Cell instance + """ + cell = Cell(data["row"], data["col"]) + cell.walls = data["walls"].copy() + cell.visited = data["visited"] + return cell + + def __repr__(self) -> str: + """String representation of the cell.""" + return f"Cell({self.row}, {self.col})" + + def __eq__(self, other) -> bool: + """Check equality with another cell.""" + if not isinstance(other, Cell): + return False + return self.row == other.row and self.col == other.col + + def __hash__(self) -> int: + """Hash function for using cells in sets/dicts.""" + return hash((self.row, self.col)) diff --git a/src/core/maze.py b/src/core/maze.py new file mode 100644 index 0000000..975225b --- /dev/null +++ b/src/core/maze.py @@ -0,0 +1,193 @@ +"""Maze class representing the complete maze structure.""" + +import json +import random +from typing import Dict, List, Optional, Tuple + +from .cell import Cell + + +class Maze: + """Represents a complete maze with a grid of cells. + + The maze maintains a 2D grid of cells and provides methods for + manipulation, serialization, and access to maze properties. + """ + + def __init__(self, rows: int, cols: int, seed: Optional[int] = None): + """Initialize a maze with the given dimensions. + + Args: + rows: Number of rows (must be between 5 and 50) + cols: Number of columns (must be between 5 and 50) + seed: Random seed for reproducible generation (optional) + + Raises: + ValueError: If dimensions are out of valid range + """ + if not (5 <= rows <= 50) or not (5 <= cols <= 50): + raise ValueError("Maze dimensions must be between 5 and 50") + + self.rows = rows + self.cols = cols + self.seed = seed if seed is not None else random.randint(0, 1000000) + self.grid: List[List[Cell]] = [] + self.start: Tuple[int, int] = (0, 0) + self.end: Tuple[int, int] = (rows - 1, cols - 1) + self.generation_time_ms: float = 0.0 + self.algorithm_used: str = "" + + # Initialize the grid + self._initialize_grid() + + def _initialize_grid(self) -> None: + """Initialize the grid with cells.""" + self.grid = [] + for row in range(self.rows): + row_cells = [] + for col in range(self.cols): + row_cells.append(Cell(row, col)) + self.grid.append(row_cells) + + def get_cell(self, row: int, col: int) -> Optional[Cell]: + """Get the cell at the specified position. + + Args: + row: Row index + col: Column index + + Returns: + Cell at the position, or None if out of bounds + """ + if 0 <= row < self.rows and 0 <= col < self.cols: + return self.grid[row][col] + return None + + def get_neighbors(self, cell: Cell) -> List[Tuple[Cell, str]]: + """Get all valid neighbors of a cell. + + Args: + cell: The cell to find neighbors for + + Returns: + List of tuples (neighbor_cell, direction) + """ + neighbors = [] + directions = cell.get_neighbors_directions() + + for direction, (dr, dc) in directions.items(): + neighbor_row = cell.row + dr + neighbor_col = cell.col + dc + neighbor = self.get_cell(neighbor_row, neighbor_col) + + if neighbor is not None: + neighbors.append((neighbor, direction)) + + return neighbors + + def remove_wall_between(self, cell1: Cell, cell2: Cell) -> None: + """Remove the wall between two adjacent cells. + + Args: + cell1: First cell + cell2: Second cell + """ + row_diff = cell2.row - cell1.row + col_diff = cell2.col - cell1.col + + # Determine direction and remove walls + if row_diff == -1: # cell2 is north of cell1 + cell1.remove_wall("north") + cell2.remove_wall("south") + elif row_diff == 1: # cell2 is south of cell1 + cell1.remove_wall("south") + cell2.remove_wall("north") + elif col_diff == -1: # cell2 is west of cell1 + cell1.remove_wall("west") + cell2.remove_wall("east") + elif col_diff == 1: # cell2 is east of cell1 + cell1.remove_wall("east") + cell2.remove_wall("west") + + def reset_visited(self) -> None: + """Reset the visited flag for all cells.""" + for row in self.grid: + for cell in row: + cell.visited = False + + def is_valid_position(self, row: int, col: int) -> bool: + """Check if a position is within the maze bounds. + + Args: + row: Row index + col: Column index + + Returns: + True if position is valid, False otherwise + """ + return 0 <= row < self.rows and 0 <= col < self.cols + + def to_dict(self) -> Dict: + """Convert maze to dictionary representation. + + Returns: + Dictionary with complete maze data + """ + return { + "rows": self.rows, + "cols": self.cols, + "seed": self.seed, + "start": self.start, + "end": self.end, + "generation_time_ms": self.generation_time_ms, + "algorithm_used": self.algorithm_used, + "grid": [[cell.to_dict() for cell in row] for row in self.grid] + } + + def to_json(self) -> str: + """Convert maze to JSON string. + + Returns: + JSON string representation + """ + return json.dumps(self.to_dict(), indent=2) + + @staticmethod + def from_dict(data: Dict) -> 'Maze': + """Create a maze from dictionary representation. + + Args: + data: Dictionary with maze data + + Returns: + New Maze instance + """ + maze = Maze(data["rows"], data["cols"], data["seed"]) + maze.start = tuple(data["start"]) + maze.end = tuple(data["end"]) + maze.generation_time_ms = data.get("generation_time_ms", 0.0) + maze.algorithm_used = data.get("algorithm_used", "") + + # Restore grid + for row_idx, row_data in enumerate(data["grid"]): + for col_idx, cell_data in enumerate(row_data): + maze.grid[row_idx][col_idx] = Cell.from_dict(cell_data) + + return maze + + @staticmethod + def from_json(json_str: str) -> 'Maze': + """Create a maze from JSON string. + + Args: + json_str: JSON string with maze data + + Returns: + New Maze instance + """ + data = json.loads(json_str) + return Maze.from_dict(data) + + def __repr__(self) -> str: + """String representation of the maze.""" + return f"Maze({self.rows}x{self.cols}, seed={self.seed}, algorithm={self.algorithm_used})" diff --git a/src/generators/__init__.py b/src/generators/__init__.py new file mode 100644 index 0000000..54322b0 --- /dev/null +++ b/src/generators/__init__.py @@ -0,0 +1,23 @@ +"""Maze generation algorithms.""" + +from .base import BaseGenerator +from .recursive_backtracking import RecursiveBacktrackingGenerator +from .kruskal import KruskalGenerator +from .prim import PrimGenerator +from .sidewinder import SidewinderGenerator +from .hunt_and_kill import HuntAndKillGenerator +from .eller import EllerGenerator +from .wilson import WilsonGenerator +from .aldous_broder import AldousBroderGenerator + +__all__ = [ + "BaseGenerator", + "RecursiveBacktrackingGenerator", + "KruskalGenerator", + "PrimGenerator", + "SidewinderGenerator", + "HuntAndKillGenerator", + "EllerGenerator", + "WilsonGenerator", + "AldousBroderGenerator" +] diff --git a/src/generators/aldous_broder.py b/src/generators/aldous_broder.py new file mode 100644 index 0000000..46fa4da --- /dev/null +++ b/src/generators/aldous_broder.py @@ -0,0 +1,51 @@ +"""Aldous-Broder Algorithm for maze generation.""" + +import random + +from ..core.maze import Maze +from .base import BaseGenerator + + +class AldousBroderGenerator(BaseGenerator): + """Generates mazes using the Aldous-Broder Algorithm. + + This algorithm performs a random walk, carving passages to unvisited cells. + Very slow but generates truly uniform spanning trees. Can be inefficient + for large mazes as it continues random walking even when few cells remain. + + Time Complexity: O(rows * cols * log(rows * cols)) expected + Space Complexity: O(1) + """ + + def __init__(self): + """Initialize the Aldous-Broder generator.""" + super().__init__("Aldous-Broder Algorithm") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using Aldous-Broder algorithm. + + Args: + maze: Maze instance to generate + """ + # Start from random cell + current_cell = maze.grid[random.randint(0, maze.rows - 1)][random.randint(0, maze.cols - 1)] + current_cell.visited = True + unvisited_count = maze.rows * maze.cols - 1 + + # Continue until all cells are visited + while unvisited_count > 0: + # Get all neighbors + neighbors = maze.get_neighbors(current_cell) + + if neighbors: + # Choose a random neighbor + next_cell, direction = random.choice(neighbors) + + # If neighbor hasn't been visited, carve passage + if not next_cell.visited: + maze.remove_wall_between(current_cell, next_cell) + next_cell.visited = True + unvisited_count -= 1 + + # Move to neighbor (regardless of whether it was visited) + current_cell = next_cell diff --git a/src/generators/base.py b/src/generators/base.py new file mode 100644 index 0000000..17febe2 --- /dev/null +++ b/src/generators/base.py @@ -0,0 +1,68 @@ +"""Base class for maze generation algorithms.""" + +import random +import time +from abc import ABC, abstractmethod +from typing import Optional + +from ..core.maze import Maze + + +class BaseGenerator(ABC): + """Abstract base class for maze generation algorithms. + + All maze generation algorithms should inherit from this class + and implement the generate method. + """ + + def __init__(self, name: str): + """Initialize the generator. + + Args: + name: Name of the algorithm + """ + self.name = name + + @abstractmethod + def _generate_maze(self, maze: Maze) -> None: + """Generate the maze (to be implemented by subclasses). + + Args: + maze: Maze instance to generate + """ + pass + + def generate(self, rows: int, cols: int, seed: Optional[int] = None) -> Maze: + """Generate a maze with the specified dimensions. + + Args: + rows: Number of rows + cols: Number of columns + seed: Random seed for reproducibility + + Returns: + Generated Maze instance with timing information + """ + # Create maze + maze = Maze(rows, cols, seed) + + # Set random seed + random.seed(maze.seed) + + # Track generation time + start_time = time.time() + self._generate_maze(maze) + end_time = time.time() + + # Store metadata + maze.generation_time_ms = (end_time - start_time) * 1000 + maze.algorithm_used = self.name + + # Reset visited flags + maze.reset_visited() + + return maze + + def __repr__(self) -> str: + """String representation of the generator.""" + return f"{self.__class__.__name__}('{self.name}')" diff --git a/src/generators/eller.py b/src/generators/eller.py new file mode 100644 index 0000000..0daa0b5 --- /dev/null +++ b/src/generators/eller.py @@ -0,0 +1,88 @@ +"""Eller's Algorithm for maze generation.""" + +import random +from typing import Dict, List, Set + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseGenerator + + +class EllerGenerator(BaseGenerator): + """Generates mazes using Eller's Algorithm. + + This algorithm generates mazes one row at a time and can create + infinitely long mazes. Each row maintains sets of connected cells. + + Time Complexity: O(rows * cols) + Space Complexity: O(cols) + """ + + def __init__(self): + """Initialize the Eller generator.""" + super().__init__("Eller's Algorithm") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using Eller's algorithm. + + Args: + maze: Maze instance to generate + """ + # Track which set each cell belongs to + sets: Dict[Cell, int] = {} + next_set_id = 0 + + for row_idx in range(maze.rows): + row = maze.grid[row_idx] + + # Assign sets to new cells + for cell in row: + if cell not in sets: + sets[cell] = next_set_id + next_set_id += 1 + + # Randomly join adjacent cells in different sets + for col_idx in range(maze.cols - 1): + cell = row[col_idx] + east_cell = row[col_idx + 1] + + # Join cells if in different sets and random choice + if sets[cell] != sets[east_cell] and random.choice([True, False]): + self._merge_sets(sets, sets[cell], sets[east_cell]) + maze.remove_wall_between(cell, east_cell) + + # Create vertical connections (except for last row) + if row_idx < maze.rows - 1: + # Group cells by set + set_groups: Dict[int, List[Cell]] = {} + for cell in row: + cell_set = sets[cell] + if cell_set not in set_groups: + set_groups[cell_set] = [] + set_groups[cell_set].append(cell) + + # For each set, create at least one vertical connection + for cell_set, cells in set_groups.items(): + # Shuffle and pick at least one cell to connect down + random.shuffle(cells) + num_connections = random.randint(1, len(cells)) + + for i in range(num_connections): + cell = cells[i] + south_cell = maze.get_cell(cell.row + 1, cell.col) + if south_cell: + maze.remove_wall_between(cell, south_cell) + # South cell inherits the set + sets[south_cell] = cell_set + + def _merge_sets(self, sets: Dict[Cell, int], set1: int, set2: int) -> None: + """Merge two sets by replacing all set2 with set1. + + Args: + sets: Dictionary mapping cells to set IDs + set1: First set ID + set2: Second set ID to merge into set1 + """ + for cell, cell_set in sets.items(): + if cell_set == set2: + sets[cell] = set1 diff --git a/src/generators/hunt_and_kill.py b/src/generators/hunt_and_kill.py new file mode 100644 index 0000000..9e49e1d --- /dev/null +++ b/src/generators/hunt_and_kill.py @@ -0,0 +1,104 @@ +"""Hunt-and-Kill Algorithm for maze generation.""" + +import random +from typing import List, Optional + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseGenerator + + +class HuntAndKillGenerator(BaseGenerator): + """Generates mazes using the Hunt-and-Kill Algorithm. + + This algorithm performs random walks (kill phase) and when stuck, + scans for unvisited cells adjacent to visited ones (hunt phase). + Creates mazes with fewer dead ends than recursive backtracking. + + Time Complexity: O((rows * cols)^2) worst case + Space Complexity: O(1) + """ + + def __init__(self): + """Initialize the Hunt-and-Kill generator.""" + super().__init__("Hunt-and-Kill Algorithm") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using Hunt-and-Kill algorithm. + + Args: + maze: Maze instance to generate + """ + # Start from random cell + current_cell = maze.grid[random.randint(0, maze.rows - 1)][random.randint(0, maze.cols - 1)] + current_cell.visited = True + + while True: + # Kill phase: random walk from current cell + unvisited_neighbors = self._get_unvisited_neighbors(maze, current_cell) + + if unvisited_neighbors: + # Choose random unvisited neighbor + next_cell, direction = random.choice(unvisited_neighbors) + + # Remove wall and move to next cell + maze.remove_wall_between(current_cell, next_cell) + next_cell.visited = True + current_cell = next_cell + else: + # Hunt phase: scan for unvisited cell with visited neighbor + next_cell = self._hunt(maze) + + if next_cell is None: + # All cells visited, done + break + + current_cell = next_cell + + def _get_unvisited_neighbors(self, maze: Maze, cell: Cell) -> List[tuple]: + """Get all unvisited neighbors of a cell. + + Args: + maze: The maze + cell: The cell to find neighbors for + + Returns: + List of (neighbor_cell, direction) tuples + """ + neighbors = [] + all_neighbors = maze.get_neighbors(cell) + + for neighbor, direction in all_neighbors: + if not neighbor.visited: + neighbors.append((neighbor, direction)) + + return neighbors + + def _hunt(self, maze: Maze) -> Optional[Cell]: + """Hunt for an unvisited cell adjacent to a visited cell. + + Args: + maze: The maze + + Returns: + Unvisited cell with visited neighbor, or None if all visited + """ + for row in maze.grid: + for cell in row: + if not cell.visited: + # Check if this cell has visited neighbors + visited_neighbors = [] + all_neighbors = maze.get_neighbors(cell) + + for neighbor, direction in all_neighbors: + if neighbor.visited: + visited_neighbors.append((neighbor, direction)) + + if visited_neighbors: + # Connect to random visited neighbor + neighbor, direction = random.choice(visited_neighbors) + maze.remove_wall_between(cell, neighbor) + cell.visited = True + return cell + + return None diff --git a/src/generators/kruskal.py b/src/generators/kruskal.py new file mode 100644 index 0000000..f1135f6 --- /dev/null +++ b/src/generators/kruskal.py @@ -0,0 +1,89 @@ +"""Kruskal's Algorithm for maze generation.""" + +import random +from typing import Dict, List, Tuple + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseGenerator + + +class KruskalGenerator(BaseGenerator): + """Generates mazes using Kruskal's Algorithm. + + This algorithm treats the maze as a graph and uses a union-find structure + to create a minimum spanning tree. Creates mazes with many short paths. + + Time Complexity: O(E log E) where E is number of edges + Space Complexity: O(V) where V is number of vertices + """ + + def __init__(self): + """Initialize the Kruskal generator.""" + super().__init__("Kruskal's Algorithm") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using Kruskal's algorithm. + + Args: + maze: Maze instance to generate + """ + # Initialize union-find structure + parent: Dict[Cell, Cell] = {} + for row in maze.grid: + for cell in row: + parent[cell] = cell + + # Create list of all possible walls (edges) + walls = self._get_all_walls(maze) + random.shuffle(walls) + + # Process each wall + for cell1, cell2 in walls: + # Find roots of both cells + root1 = self._find(parent, cell1) + root2 = self._find(parent, cell2) + + # If cells are in different sets, remove wall and union + if root1 != root2: + maze.remove_wall_between(cell1, cell2) + parent[root2] = root1 + + def _get_all_walls(self, maze: Maze) -> List[Tuple[Cell, Cell]]: + """Get all possible walls between cells. + + Args: + maze: The maze + + Returns: + List of (cell1, cell2) tuples representing walls + """ + walls = [] + + for row in maze.grid: + for cell in row: + # Add wall to the south + south_cell = maze.get_cell(cell.row + 1, cell.col) + if south_cell: + walls.append((cell, south_cell)) + + # Add wall to the east + east_cell = maze.get_cell(cell.row, cell.col + 1) + if east_cell: + walls.append((cell, east_cell)) + + return walls + + def _find(self, parent: Dict[Cell, Cell], cell: Cell) -> Cell: + """Find the root of a cell's set with path compression. + + Args: + parent: Union-find parent dictionary + cell: Cell to find root for + + Returns: + Root cell of the set + """ + if parent[cell] != cell: + parent[cell] = self._find(parent, parent[cell]) + return parent[cell] diff --git a/src/generators/prim.py b/src/generators/prim.py new file mode 100644 index 0000000..6a4876e --- /dev/null +++ b/src/generators/prim.py @@ -0,0 +1,75 @@ +"""Prim's Algorithm for maze generation.""" + +import random +from typing import List, Set, Tuple + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseGenerator + + +class PrimGenerator(BaseGenerator): + """Generates mazes using Prim's Algorithm. + + This algorithm starts with a cell and grows the maze by adding the + lowest-cost adjacent cells. Creates mazes with many short dead ends. + + Time Complexity: O(E log V) where E is edges and V is vertices + Space Complexity: O(V) + """ + + def __init__(self): + """Initialize the Prim generator.""" + super().__init__("Prim's Algorithm") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using Prim's algorithm. + + Args: + maze: Maze instance to generate + """ + # Start with a random cell + start_cell = maze.grid[random.randint(0, maze.rows - 1)][random.randint(0, maze.cols - 1)] + start_cell.visited = True + + # List of frontier walls (cell pairs) + frontier: List[Tuple[Cell, Cell]] = [] + + # Add all walls of start cell to frontier + self._add_frontier_walls(maze, start_cell, frontier) + + # Process frontier until empty + while frontier: + # Pick a random wall from frontier + wall_idx = random.randint(0, len(frontier) - 1) + cell1, cell2 = frontier.pop(wall_idx) + + # If only one of the cells is visited + if cell1.visited != cell2.visited: + # Remove the wall + maze.remove_wall_between(cell1, cell2) + + # Mark unvisited cell as visited + unvisited_cell = cell2 if not cell2.visited else cell1 + unvisited_cell.visited = True + + # Add new frontier walls + self._add_frontier_walls(maze, unvisited_cell, frontier) + + def _add_frontier_walls(self, maze: Maze, cell: Cell, frontier: List[Tuple[Cell, Cell]]) -> None: + """Add walls of a cell to the frontier. + + Args: + maze: The maze + cell: Cell whose walls to add + frontier: Frontier list to add to + """ + neighbors = maze.get_neighbors(cell) + + for neighbor, direction in neighbors: + if not neighbor.visited: + # Add wall if not already in frontier + wall = (cell, neighbor) + reverse_wall = (neighbor, cell) + if wall not in frontier and reverse_wall not in frontier: + frontier.append(wall) diff --git a/src/generators/recursive_backtracking.py b/src/generators/recursive_backtracking.py new file mode 100644 index 0000000..b12e4e4 --- /dev/null +++ b/src/generators/recursive_backtracking.py @@ -0,0 +1,73 @@ +"""Recursive Backtracking maze generation algorithm.""" + +import random +from typing import List + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseGenerator + + +class RecursiveBacktrackingGenerator(BaseGenerator): + """Generates mazes using the Recursive Backtracking algorithm. + + This algorithm uses a depth-first search approach with backtracking. + It creates mazes with a high "river" characteristic - long, winding paths. + + Time Complexity: O(rows * cols) + Space Complexity: O(rows * cols) for the stack + """ + + def __init__(self): + """Initialize the Recursive Backtracking generator.""" + super().__init__("Recursive Backtracking") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using recursive backtracking. + + Args: + maze: Maze instance to generate + """ + # Start from a random cell + start_cell = maze.grid[0][0] + stack = [start_cell] + start_cell.visited = True + + while stack: + current_cell = stack[-1] + + # Get unvisited neighbors + unvisited_neighbors = self._get_unvisited_neighbors(maze, current_cell) + + if unvisited_neighbors: + # Choose a random unvisited neighbor + next_cell, direction = random.choice(unvisited_neighbors) + + # Remove wall between current and next cell + maze.remove_wall_between(current_cell, next_cell) + + # Mark as visited and add to stack + next_cell.visited = True + stack.append(next_cell) + else: + # Backtrack + stack.pop() + + def _get_unvisited_neighbors(self, maze: Maze, cell: Cell) -> List[tuple]: + """Get all unvisited neighbors of a cell. + + Args: + maze: The maze + cell: The cell to find neighbors for + + Returns: + List of (neighbor_cell, direction) tuples + """ + neighbors = [] + all_neighbors = maze.get_neighbors(cell) + + for neighbor, direction in all_neighbors: + if not neighbor.visited: + neighbors.append((neighbor, direction)) + + return neighbors diff --git a/src/generators/sidewinder.py b/src/generators/sidewinder.py new file mode 100644 index 0000000..4570e01 --- /dev/null +++ b/src/generators/sidewinder.py @@ -0,0 +1,60 @@ +"""Sidewinder Algorithm for maze generation.""" + +import random +from typing import List + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseGenerator + + +class SidewinderGenerator(BaseGenerator): + """Generates mazes using the Sidewinder Algorithm. + + This algorithm works row by row, creating horizontal runs and randomly + carving north. Creates mazes with a bias toward horizontal passages. + + Time Complexity: O(rows * cols) + Space Complexity: O(cols) for the run + """ + + def __init__(self): + """Initialize the Sidewinder generator.""" + super().__init__("Sidewinder Algorithm") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using Sidewinder algorithm. + + Args: + maze: Maze instance to generate + """ + # Process each row + for row_idx in range(maze.rows): + run: List[Cell] = [] + + for col_idx in range(maze.cols): + cell = maze.grid[row_idx][col_idx] + run.append(cell) + + # Decide whether to carve east or north + at_eastern_boundary = (col_idx == maze.cols - 1) + at_northern_boundary = (row_idx == 0) + + should_close_run = at_eastern_boundary or ( + not at_northern_boundary and random.choice([True, False]) + ) + + if should_close_run: + # Pick a random cell from run and carve north + if not at_northern_boundary: + random_cell = random.choice(run) + north_cell = maze.get_cell(random_cell.row - 1, random_cell.col) + if north_cell: + maze.remove_wall_between(random_cell, north_cell) + # Clear the run + run = [] + else: + # Carve east + east_cell = maze.get_cell(cell.row, cell.col + 1) + if east_cell: + maze.remove_wall_between(cell, east_cell) diff --git a/src/generators/wilson.py b/src/generators/wilson.py new file mode 100644 index 0000000..65b676a --- /dev/null +++ b/src/generators/wilson.py @@ -0,0 +1,93 @@ +"""Wilson's Algorithm for maze generation.""" + +import random +from typing import Dict, List, Optional + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseGenerator + + +class WilsonGenerator(BaseGenerator): + """Generates mazes using Wilson's Algorithm. + + This algorithm uses loop-erased random walks to generate unbiased mazes. + Starts slowly but speeds up as more cells are added to the maze. + Creates truly uniform spanning trees. + + Time Complexity: O(rows * cols) expected + Space Complexity: O(rows * cols) + """ + + def __init__(self): + """Initialize the Wilson generator.""" + super().__init__("Wilson's Algorithm") + + def _generate_maze(self, maze: Maze) -> None: + """Generate maze using Wilson's algorithm. + + Args: + maze: Maze instance to generate + """ + # Start with one random cell in the maze + all_cells = [cell for row in maze.grid for cell in row] + first_cell = random.choice(all_cells) + first_cell.visited = True + in_maze = {first_cell} + + # Process remaining cells + unvisited = [cell for cell in all_cells if cell not in in_maze] + + while unvisited: + # Start random walk from random unvisited cell + start_cell = random.choice(unvisited) + path = self._random_walk(maze, start_cell, in_maze) + + # Add path to maze + for i in range(len(path) - 1): + current = path[i] + next_cell = path[i + 1] + + maze.remove_wall_between(current, next_cell) + current.visited = True + in_maze.add(current) + + # Update unvisited list + unvisited = [cell for cell in all_cells if cell not in in_maze] + + def _random_walk(self, maze: Maze, start: Cell, in_maze: set) -> List[Cell]: + """Perform a loop-erased random walk. + + Args: + maze: The maze + start: Starting cell + in_maze: Set of cells already in the maze + + Returns: + List of cells forming the loop-erased path + """ + path: List[Cell] = [start] + current = start + + # Walk until we hit a cell in the maze + while current not in in_maze: + # Get all neighbors + neighbors = maze.get_neighbors(current) + if not neighbors: + break + + # Choose random neighbor + next_cell, direction = random.choice(neighbors) + + # If we've been to this cell before in this walk, erase the loop + if next_cell in path: + # Erase loop by truncating path + loop_start = path.index(next_cell) + path = path[:loop_start + 1] + current = next_cell + else: + # Add to path + path.append(next_cell) + current = next_cell + + return path diff --git a/src/solvers/__init__.py b/src/solvers/__init__.py new file mode 100644 index 0000000..000f894 --- /dev/null +++ b/src/solvers/__init__.py @@ -0,0 +1,7 @@ +"""Maze solving algorithms.""" + +from .base import BaseSolver +from .dfs import DFSSolver +from .bfs import BFSSolver + +__all__ = ["BaseSolver", "DFSSolver", "BFSSolver"] diff --git a/src/solvers/base.py b/src/solvers/base.py new file mode 100644 index 0000000..927e419 --- /dev/null +++ b/src/solvers/base.py @@ -0,0 +1,139 @@ +"""Base class for maze solving algorithms.""" + +import time +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Tuple + +from ..core.cell import Cell +from ..core.maze import Maze + + +class BaseSolver(ABC): + """Abstract base class for maze solving algorithms. + + All maze solving algorithms should inherit from this class + and implement the solve method. + """ + + def __init__(self, name: str): + """Initialize the solver. + + Args: + name: Name of the algorithm + """ + self.name = name + + @abstractmethod + def _solve_maze(self, maze: Maze, start: Cell, end: Cell) -> Tuple[Optional[List[Cell]], List[Cell]]: + """Solve the maze (to be implemented by subclasses). + + Args: + maze: Maze instance to solve + start: Starting cell + end: Ending cell + + Returns: + Tuple of (solution_path, visited_cells_in_order) + """ + pass + + def solve(self, maze: Maze) -> Dict: + """Solve the maze and return solution with metadata. + + Args: + maze: Maze instance to solve + + Returns: + Dictionary with solution path, visited cells, and timing + """ + # Reset visited flags + maze.reset_visited() + + # Get start and end cells + start = maze.get_cell(maze.start[0], maze.start[1]) + end = maze.get_cell(maze.end[0], maze.end[1]) + + if not start or not end: + return { + "success": False, + "path": None, + "visited": [], + "time_ms": 0, + "algorithm": self.name, + "path_length": 0 + } + + # Track solving time + start_time = time.time() + path, visited = self._solve_maze(maze, start, end) + end_time = time.time() + + # Reset visited flags again + maze.reset_visited() + + return { + "success": path is not None, + "path": [(cell.row, cell.col) for cell in path] if path else None, + "visited": [(cell.row, cell.col) for cell in visited], + "time_ms": (end_time - start_time) * 1000, + "algorithm": self.name, + "path_length": len(path) if path else 0 + } + + def can_move(self, maze: Maze, from_cell: Cell, to_cell: Cell) -> bool: + """Check if movement between two cells is possible. + + Args: + maze: The maze + from_cell: Starting cell + to_cell: Target cell + + Returns: + True if movement is possible, False otherwise + """ + # Calculate direction + row_diff = to_cell.row - from_cell.row + col_diff = to_cell.col - from_cell.col + + # Determine direction + if row_diff == -1: + direction = "north" + elif row_diff == 1: + direction = "south" + elif col_diff == -1: + direction = "west" + elif col_diff == 1: + direction = "east" + else: + return False + + # Check if wall exists + return not from_cell.has_wall(direction) + + def reconstruct_path(self, came_from: Dict[Cell, Cell], start: Cell, end: Cell) -> List[Cell]: + """Reconstruct path from start to end using came_from dict. + + Args: + came_from: Dictionary mapping cell to its predecessor + start: Starting cell + end: Ending cell + + Returns: + List of cells forming the path + """ + path = [] + current = end + + while current != start: + path.append(current) + current = came_from.get(current) + if current is None: + return [] + + path.append(start) + path.reverse() + return path + + def __repr__(self) -> str: + """String representation of the solver.""" + return f"{self.__class__.__name__}('{self.name}')" diff --git a/src/solvers/bfs.py b/src/solvers/bfs.py new file mode 100644 index 0000000..b82d125 --- /dev/null +++ b/src/solvers/bfs.py @@ -0,0 +1,62 @@ +"""Breadth-First Search maze solver.""" + +from collections import deque +from typing import Dict, List, Optional, Tuple + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseSolver + + +class BFSSolver(BaseSolver): + """Solves mazes using Breadth-First Search. + + BFS explores all neighbors at the current depth before moving deeper. + Guarantees the shortest path in unweighted graphs. + + Time Complexity: O(V + E) where V is vertices and E is edges + Space Complexity: O(V) for the queue + """ + + def __init__(self): + """Initialize the BFS solver.""" + super().__init__("Breadth-First Search (BFS)") + + def _solve_maze(self, maze: Maze, start: Cell, end: Cell) -> Tuple[Optional[List[Cell]], List[Cell]]: + """Solve maze using BFS. + + Args: + maze: Maze instance to solve + start: Starting cell + end: Ending cell + + Returns: + Tuple of (solution_path, visited_cells_in_order) + """ + queue = deque([start]) + came_from: Dict[Cell, Cell] = {} + visited_order = [] + + start.visited = True + visited_order.append(start) + + while queue: + current = queue.popleft() + + # Check if we reached the end + if current == end: + path = self.reconstruct_path(came_from, start, end) + return path, visited_order + + # Explore neighbors + neighbors = maze.get_neighbors(current) + + for neighbor, direction in neighbors: + if not neighbor.visited and self.can_move(maze, current, neighbor): + neighbor.visited = True + visited_order.append(neighbor) + came_from[neighbor] = current + queue.append(neighbor) + + # No path found + return None, visited_order diff --git a/src/solvers/dfs.py b/src/solvers/dfs.py new file mode 100644 index 0000000..56abcb5 --- /dev/null +++ b/src/solvers/dfs.py @@ -0,0 +1,61 @@ +"""Depth-First Search maze solver.""" + +from typing import Dict, List, Optional, Tuple + +from ..core.cell import Cell +from ..core.maze import Maze +from .base import BaseSolver + + +class DFSSolver(BaseSolver): + """Solves mazes using Depth-First Search. + + DFS explores as far as possible along each branch before backtracking. + Does not guarantee shortest path but is memory efficient. + + Time Complexity: O(V + E) where V is vertices and E is edges + Space Complexity: O(V) for the stack + """ + + def __init__(self): + """Initialize the DFS solver.""" + super().__init__("Depth-First Search (DFS)") + + def _solve_maze(self, maze: Maze, start: Cell, end: Cell) -> Tuple[Optional[List[Cell]], List[Cell]]: + """Solve maze using DFS. + + Args: + maze: Maze instance to solve + start: Starting cell + end: Ending cell + + Returns: + Tuple of (solution_path, visited_cells_in_order) + """ + stack = [start] + came_from: Dict[Cell, Cell] = {} + visited_order = [] + + start.visited = True + visited_order.append(start) + + while stack: + current = stack.pop() + + # Check if we reached the end + if current == end: + path = self.reconstruct_path(came_from, start, end) + return path, visited_order + + # Explore neighbors + neighbors = maze.get_neighbors(current) + + for neighbor, direction in neighbors: + if not neighbor.visited and self.can_move(maze, current, neighbor): + neighbor.visited = True + visited_order.append(neighbor) + came_from[neighbor] = current + stack.append(neighbor) + + # No path found + return None, visited_order diff --git a/src/storage/__init__.py b/src/storage/__init__.py new file mode 100644 index 0000000..126641d --- /dev/null +++ b/src/storage/__init__.py @@ -0,0 +1,5 @@ +"""File storage and persistence utilities.""" + +from .file_handler import FileHandler + +__all__ = ["FileHandler"] diff --git a/src/storage/file_handler.py b/src/storage/file_handler.py new file mode 100644 index 0000000..ac6edeb --- /dev/null +++ b/src/storage/file_handler.py @@ -0,0 +1,169 @@ +"""File handling for saving and loading mazes.""" + +import json +import os +from pathlib import Path +from typing import Optional + +from ..core.maze import Maze + + +class FileHandler: + """Handles saving and loading mazes to/from files.""" + + DEFAULT_SAVE_DIR = "saved_mazes" + + @staticmethod + def ensure_save_directory(directory: Optional[str] = None) -> Path: + """Ensure the save directory exists. + + Args: + directory: Directory path (uses default if None) + + Returns: + Path object for the directory + """ + save_dir = Path(directory) if directory else Path(FileHandler.DEFAULT_SAVE_DIR) + save_dir.mkdir(parents=True, exist_ok=True) + return save_dir + + @staticmethod + def save_maze(maze: Maze, filename: str, directory: Optional[str] = None) -> str: + """Save a maze to a JSON file. + + Args: + maze: Maze to save + filename: Name of the file (without extension) + directory: Directory to save to (uses default if None) + + Returns: + Full path to the saved file + + Raises: + IOError: If file cannot be written + """ + save_dir = FileHandler.ensure_save_directory(directory) + + # Ensure .json extension + if not filename.endswith('.json'): + filename += '.json' + + file_path = save_dir / filename + + try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(maze.to_json()) + return str(file_path) + except Exception as e: + raise IOError(f"Failed to save maze to {file_path}: {e}") + + @staticmethod + def load_maze(filename: str, directory: Optional[str] = None) -> Maze: + """Load a maze from a JSON file. + + Args: + filename: Name of the file to load + directory: Directory to load from (uses default if None) + + Returns: + Loaded Maze instance + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If file contains invalid maze data + IOError: If file cannot be read + """ + save_dir = Path(directory) if directory else Path(FileHandler.DEFAULT_SAVE_DIR) + + # Ensure .json extension + if not filename.endswith('.json'): + filename += '.json' + + file_path = save_dir / filename + + if not file_path.exists(): + raise FileNotFoundError(f"Maze file not found: {file_path}") + + try: + with open(file_path, 'r', encoding='utf-8') as f: + json_str = f.read() + + # Validate JSON structure + data = json.loads(json_str) + FileHandler._validate_maze_data(data) + + return Maze.from_json(json_str) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in maze file: {e}") + except Exception as e: + raise IOError(f"Failed to load maze from {file_path}: {e}") + + @staticmethod + def _validate_maze_data(data: dict) -> None: + """Validate maze data structure. + + Args: + data: Dictionary with maze data + + Raises: + ValueError: If data is invalid + """ + required_fields = ["rows", "cols", "seed", "grid"] + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field: {field}") + + # Validate dimensions + rows = data["rows"] + cols = data["cols"] + if not (5 <= rows <= 50) or not (5 <= cols <= 50): + raise ValueError("Invalid maze dimensions") + + # Validate grid structure + grid = data["grid"] + if len(grid) != rows: + raise ValueError("Grid row count doesn't match maze rows") + + for row_idx, row in enumerate(grid): + if len(row) != cols: + raise ValueError(f"Grid column count doesn't match at row {row_idx}") + + @staticmethod + def list_saved_mazes(directory: Optional[str] = None) -> list: + """List all saved maze files. + + Args: + directory: Directory to list from (uses default if None) + + Returns: + List of maze filenames + """ + save_dir = Path(directory) if directory else Path(FileHandler.DEFAULT_SAVE_DIR) + + if not save_dir.exists(): + return [] + + return [f.name for f in save_dir.glob("*.json")] + + @staticmethod + def delete_maze(filename: str, directory: Optional[str] = None) -> bool: + """Delete a saved maze file. + + Args: + filename: Name of the file to delete + directory: Directory containing the file (uses default if None) + + Returns: + True if deleted successfully, False if file didn't exist + """ + save_dir = Path(directory) if directory else Path(FileHandler.DEFAULT_SAVE_DIR) + + if not filename.endswith('.json'): + filename += '.json' + + file_path = save_dir / filename + + if file_path.exists(): + file_path.unlink() + return True + return False diff --git a/src/visualization/__init__.py b/src/visualization/__init__.py new file mode 100644 index 0000000..69858b2 --- /dev/null +++ b/src/visualization/__init__.py @@ -0,0 +1,6 @@ +"""Visualization and rendering utilities.""" + +from .image_renderer import ImageRenderer +from .web_renderer import WebRenderer + +__all__ = ["ImageRenderer", "WebRenderer"] diff --git a/src/visualization/image_renderer.py b/src/visualization/image_renderer.py new file mode 100644 index 0000000..75641ec --- /dev/null +++ b/src/visualization/image_renderer.py @@ -0,0 +1,145 @@ +"""Image rendering for mazes using Pillow.""" + +import os +from pathlib import Path +from typing import List, Optional, Tuple + +from PIL import Image, ImageDraw + +from ..core.maze import Maze + + +class ImageRenderer: + """Renders mazes as PNG/JPG images.""" + + DEFAULT_OUTPUT_DIR = "output_images" + DEFAULT_CELL_SIZE = 20 + DEFAULT_WALL_THICKNESS = 2 + + def __init__(self, cell_size: int = DEFAULT_CELL_SIZE, wall_thickness: int = DEFAULT_WALL_THICKNESS): + """Initialize the image renderer. + + Args: + cell_size: Size of each cell in pixels + wall_thickness: Thickness of walls in pixels + """ + self.cell_size = cell_size + self.wall_thickness = wall_thickness + + def render( + self, + maze: Maze, + filename: str, + directory: Optional[str] = None, + solution_path: Optional[List[Tuple[int, int]]] = None, + visited_cells: Optional[List[Tuple[int, int]]] = None + ) -> str: + """Render maze as an image. + + Args: + maze: Maze to render + filename: Output filename (without extension) + directory: Output directory (uses default if None) + solution_path: Optional list of (row, col) tuples for solution + visited_cells: Optional list of (row, col) tuples for visited cells + + Returns: + Path to the saved image file + """ + # Ensure output directory exists + output_dir = Path(directory) if directory else Path(self.DEFAULT_OUTPUT_DIR) + output_dir.mkdir(parents=True, exist_ok=True) + + # Ensure .png extension + if not filename.endswith('.png'): + filename += '.png' + + # Calculate image dimensions + width = maze.cols * self.cell_size + self.wall_thickness + height = maze.rows * self.cell_size + self.wall_thickness + + # Create image + img = Image.new('RGB', (width, height), color='white') + draw = ImageDraw.Draw(img) + + # Draw visited cells (light gray) + if visited_cells: + for row, col in visited_cells: + self._draw_cell_background(draw, row, col, '#E0E0E0') + + # Draw solution path (light green) + if solution_path: + for row, col in solution_path: + self._draw_cell_background(draw, row, col, '#90EE90') + + # Draw start and end markers + self._draw_cell_background(draw, maze.start[0], maze.start[1], '#FFD700') # Gold + self._draw_cell_background(draw, maze.end[0], maze.end[1], '#FF69B4') # Hot pink + + # Draw walls + for row in maze.grid: + for cell in row: + self._draw_cell_walls(draw, cell) + + # Save image + file_path = output_dir / filename + img.save(file_path) + return str(file_path) + + def _draw_cell_background(self, draw: ImageDraw, row: int, col: int, color: str) -> None: + """Draw a colored background for a cell. + + Args: + draw: ImageDraw object + row: Row index + col: Column index + color: Background color + """ + x = col * self.cell_size + self.wall_thickness + y = row * self.cell_size + self.wall_thickness + draw.rectangle( + [x, y, x + self.cell_size - 1, y + self.cell_size - 1], + fill=color + ) + + def _draw_cell_walls(self, draw: ImageDraw, cell) -> None: + """Draw walls for a cell. + + Args: + draw: ImageDraw object + cell: Cell to draw walls for + """ + x = cell.col * self.cell_size + self.wall_thickness // 2 + y = cell.row * self.cell_size + self.wall_thickness // 2 + + # Draw north wall + if cell.has_wall('north'): + draw.line( + [x, y, x + self.cell_size, y], + fill='black', + width=self.wall_thickness + ) + + # Draw south wall + if cell.has_wall('south'): + draw.line( + [x, y + self.cell_size, x + self.cell_size, y + self.cell_size], + fill='black', + width=self.wall_thickness + ) + + # Draw west wall + if cell.has_wall('west'): + draw.line( + [x, y, x, y + self.cell_size], + fill='black', + width=self.wall_thickness + ) + + # Draw east wall + if cell.has_wall('east'): + draw.line( + [x + self.cell_size, y, x + self.cell_size, y + self.cell_size], + fill='black', + width=self.wall_thickness + ) diff --git a/src/visualization/web_renderer.py b/src/visualization/web_renderer.py new file mode 100644 index 0000000..38d19d5 --- /dev/null +++ b/src/visualization/web_renderer.py @@ -0,0 +1,74 @@ +"""Web rendering for mazes (JSON format for Canvas).""" + +from typing import Dict, List, Optional, Tuple + +from ..core.maze import Maze + + +class WebRenderer: + """Renders mazes as JSON data for web canvas visualization.""" + + @staticmethod + def to_json_format( + maze: Maze, + solution_path: Optional[List[Tuple[int, int]]] = None, + visited_cells: Optional[List[Tuple[int, int]]] = None + ) -> Dict: + """Convert maze to JSON format for web rendering. + + Args: + maze: Maze to render + solution_path: Optional list of (row, col) tuples for solution + visited_cells: Optional list of (row, col) tuples for visited cells + + Returns: + Dictionary with maze data for web rendering + """ + # Build walls array + walls = [] + for row in maze.grid: + row_walls = [] + for cell in row: + cell_walls = { + 'north': cell.has_wall('north'), + 'south': cell.has_wall('south'), + 'east': cell.has_wall('east'), + 'west': cell.has_wall('west') + } + row_walls.append(cell_walls) + walls.append(row_walls) + + # Convert paths to sets for quick lookup + solution_set = set(solution_path) if solution_path else set() + visited_set = set(visited_cells) if visited_cells else set() + + # Build cell states + cell_states = [] + for row_idx in range(maze.rows): + row_states = [] + for col_idx in range(maze.cols): + pos = (row_idx, col_idx) + state = 'normal' + + if pos == maze.start: + state = 'start' + elif pos == maze.end: + state = 'end' + elif pos in solution_set: + state = 'solution' + elif pos in visited_set: + state = 'visited' + + row_states.append(state) + cell_states.append(row_states) + + return { + 'rows': maze.rows, + 'cols': maze.cols, + 'walls': walls, + 'cellStates': cell_states, + 'start': list(maze.start), + 'end': list(maze.end), + 'algorithm': maze.algorithm_used, + 'generationTime': maze.generation_time_ms + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f1e7373 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for Maze Generator.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9991a94 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +"""Pytest configuration and fixtures.""" + +import pytest +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.core.maze import Maze +from src.generators import RecursiveBacktrackingGenerator + + +@pytest.fixture +def small_maze(): + """Create a small 5x5 maze for testing.""" + generator = RecursiveBacktrackingGenerator() + return generator.generate(5, 5, seed=42) + + +@pytest.fixture +def medium_maze(): + """Create a medium 10x10 maze for testing.""" + generator = RecursiveBacktrackingGenerator() + return generator.generate(10, 10, seed=42) + + +@pytest.fixture +def large_maze(): + """Create a large 25x25 maze for testing.""" + generator = RecursiveBacktrackingGenerator() + return generator.generate(25, 25, seed=42) + + +@pytest.fixture +def empty_maze(): + """Create an empty maze (no walls removed).""" + return Maze(10, 10, seed=42) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..c210fac --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests.""" diff --git a/tests/integration/test_workflow.py b/tests/integration/test_workflow.py new file mode 100644 index 0000000..d42ebb5 --- /dev/null +++ b/tests/integration/test_workflow.py @@ -0,0 +1,217 @@ +"""Integration tests for complete workflows.""" + +import pytest +import tempfile +import shutil +from pathlib import Path + +from src.generators import RecursiveBacktrackingGenerator, KruskalGenerator +from src.solvers import BFSSolver, DFSSolver +from src.storage.file_handler import FileHandler +from src.visualization.image_renderer import ImageRenderer +from src.analysis.analyzer import MazeAnalyzer + + +class TestCompleteWorkflow: + """Test complete end-to-end workflows.""" + + def test_generate_solve_analyze_workflow(self): + """Test generating, solving, and analyzing a maze.""" + # Generate maze + generator = RecursiveBacktrackingGenerator() + maze = generator.generate(15, 15, seed=42) + + assert maze is not None + assert maze.rows == 15 + assert maze.cols == 15 + + # Solve maze + solver = BFSSolver() + result = solver.solve(maze) + + assert result['success'] + assert result['path_length'] > 0 + + # Analyze maze + analysis = MazeAnalyzer.analyze(maze) + + assert analysis['total_cells'] == 225 + assert analysis['dead_ends'] > 0 + + def test_generate_save_load_workflow(self): + """Test generating, saving, and loading a maze.""" + # Create temp directory + temp_dir = tempfile.mkdtemp() + + try: + # Generate maze + generator = KruskalGenerator() + maze = generator.generate(10, 10, seed=123) + + # Save maze + filepath = FileHandler.save_maze(maze, 'test_maze', temp_dir) + assert Path(filepath).exists() + + # Load maze + loaded_maze = FileHandler.load_maze('test_maze', temp_dir) + + assert loaded_maze.rows == maze.rows + assert loaded_maze.cols == maze.cols + assert loaded_maze.seed == maze.seed + + # Verify walls are preserved + for row in range(maze.rows): + for col in range(maze.cols): + original_cell = maze.get_cell(row, col) + loaded_cell = loaded_maze.get_cell(row, col) + assert original_cell.walls == loaded_cell.walls + + finally: + shutil.rmtree(temp_dir) + + def test_generate_render_workflow(self): + """Test generating and rendering a maze.""" + temp_dir = tempfile.mkdtemp() + + try: + # Generate maze + generator = RecursiveBacktrackingGenerator() + maze = generator.generate(10, 10, seed=42) + + # Render image + renderer = ImageRenderer(cell_size=20) + filepath = renderer.render(maze, 'test_render', temp_dir) + + assert Path(filepath).exists() + + # File should have content + file_size = Path(filepath).stat().st_size + assert file_size > 0 + + finally: + shutil.rmtree(temp_dir) + + def test_generate_solve_render_workflow(self): + """Test generating, solving, and rendering with solution.""" + temp_dir = tempfile.mkdtemp() + + try: + # Generate maze + generator = RecursiveBacktrackingGenerator() + maze = generator.generate(15, 15, seed=42) + + # Solve maze + solver = DFSSolver() + result = solver.solve(maze) + + # Render with solution + renderer = ImageRenderer(cell_size=20) + filepath = renderer.render( + maze, + 'maze_with_solution', + temp_dir, + solution_path=result['path'], + visited_cells=result['visited'] + ) + + assert Path(filepath).exists() + assert Path(filepath).stat().st_size > 0 + + finally: + shutil.rmtree(temp_dir) + + def test_multiple_algorithms_workflow(self): + """Test workflow with multiple algorithms.""" + from src.generators import ( + PrimGenerator, + SidewinderGenerator, + WilsonGenerator + ) + + generators = [ + PrimGenerator(), + SidewinderGenerator(), + WilsonGenerator() + ] + + for generator in generators: + # Generate + maze = generator.generate(10, 10, seed=42) + assert maze is not None + + # Solve with both solvers + for solver in [DFSSolver(), BFSSolver()]: + result = solver.solve(maze) + assert result['success'] + + # Analyze + analysis = MazeAnalyzer.analyze(maze) + assert analysis['total_cells'] == 100 + + +class TestFileOperations: + """Test file operation workflows.""" + + def test_save_list_delete_workflow(self): + """Test saving, listing, and deleting mazes.""" + temp_dir = tempfile.mkdtemp() + + try: + generator = RecursiveBacktrackingGenerator() + + # Save multiple mazes + maze1 = generator.generate(5, 5, seed=1) + maze2 = generator.generate(10, 10, seed=2) + + FileHandler.save_maze(maze1, 'maze1', temp_dir) + FileHandler.save_maze(maze2, 'maze2', temp_dir) + + # List mazes + files = FileHandler.list_saved_mazes(temp_dir) + assert len(files) == 2 + assert 'maze1.json' in files + assert 'maze2.json' in files + + # Delete one + deleted = FileHandler.delete_maze('maze1', temp_dir) + assert deleted + + # List again + files = FileHandler.list_saved_mazes(temp_dir) + assert len(files) == 1 + assert 'maze2.json' in files + + finally: + shutil.rmtree(temp_dir) + + def test_load_nonexistent_file(self): + """Test loading a file that doesn't exist.""" + temp_dir = tempfile.mkdtemp() + + try: + with pytest.raises(FileNotFoundError): + FileHandler.load_maze('nonexistent', temp_dir) + + finally: + shutil.rmtree(temp_dir) + + +class TestVisualization: + """Test visualization workflows.""" + + def test_render_different_sizes(self): + """Test rendering mazes of different sizes.""" + temp_dir = tempfile.mkdtemp() + + try: + generator = RecursiveBacktrackingGenerator() + renderer = ImageRenderer() + + for size in [5, 10, 15]: + maze = generator.generate(size, size, seed=42) + filepath = renderer.render(maze, f'maze_{size}', temp_dir) + + assert Path(filepath).exists() + + finally: + shutil.rmtree(temp_dir) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e0310a0 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/tests/unit/test_analysis.py b/tests/unit/test_analysis.py new file mode 100644 index 0000000..198d564 --- /dev/null +++ b/tests/unit/test_analysis.py @@ -0,0 +1,141 @@ +"""Tests for analysis and benchmarking tools.""" + +import pytest +from src.analysis.analyzer import MazeAnalyzer +from src.analysis.benchmark import Benchmark + + +class TestMazeAnalyzer: + """Test maze analysis functionality.""" + + def test_analyze_returns_complete_data(self, medium_maze): + """Test that analyze returns all required fields.""" + result = MazeAnalyzer.analyze(medium_maze) + + required_fields = [ + 'dimensions', 'total_cells', 'algorithm', 'generation_time_ms', + 'seed', 'dead_ends', 'dead_end_percentage', 'longest_path_length', + 'longest_path_start', 'longest_path_end', 'average_branching_factor' + ] + + for field in required_fields: + assert field in result + + def test_dead_ends_count(self, small_maze): + """Test dead ends counting.""" + dead_ends = MazeAnalyzer.count_dead_ends(small_maze) + + assert dead_ends >= 0 + assert dead_ends <= small_maze.rows * small_maze.cols + + def test_dead_end_percentage(self, medium_maze): + """Test dead end percentage calculation.""" + result = MazeAnalyzer.analyze(medium_maze) + + assert 0 <= result['dead_end_percentage'] <= 100 + + def test_longest_path(self, small_maze): + """Test longest path finding.""" + result = MazeAnalyzer.find_longest_path(small_maze) + + assert 'length' in result + assert 'start' in result + assert 'end' in result + assert result['length'] >= 0 + + def test_branching_factor(self, medium_maze): + """Test branching factor calculation.""" + branching_factor = MazeAnalyzer.calculate_branching_factor(medium_maze) + + # Branching factor should be between 1 and 4 + assert 1.0 <= branching_factor <= 4.0 + + def test_total_cells(self, medium_maze): + """Test total cells calculation.""" + result = MazeAnalyzer.analyze(medium_maze) + + assert result['total_cells'] == medium_maze.rows * medium_maze.cols + + +class TestBenchmark: + """Test benchmarking functionality.""" + + def test_benchmark_generators_runs(self): + """Test that generator benchmark runs successfully.""" + result = Benchmark.benchmark_generators( + sizes=[(5, 5), (10, 10)], + iterations=2, + seed=42 + ) + + assert 'benchmark_type' in result + assert result['benchmark_type'] == 'generators' + assert 'results' in result + assert len(result['results']) > 0 + + def test_benchmark_solvers_runs(self): + """Test that solver benchmark runs successfully.""" + result = Benchmark.benchmark_solvers( + sizes=[(5, 5), (10, 10)], + iterations=2, + seed=42 + ) + + assert 'benchmark_type' in result + assert result['benchmark_type'] == 'solvers' + assert 'results' in result + assert len(result['results']) > 0 + + def test_quick_benchmark(self): + """Test quick benchmark runs.""" + result = Benchmark.quick_benchmark() + + assert 'generators' in result + assert 'solvers' in result + + def test_benchmark_generator_results_structure(self): + """Test benchmark generator results have correct structure.""" + result = Benchmark.benchmark_generators( + sizes=[(5, 5)], + iterations=2, + seed=42 + ) + + for r in result['results']: + assert 'algorithm' in r + assert 'size' in r + assert 'avg_time_ms' in r + assert 'min_time_ms' in r + assert 'max_time_ms' in r + assert r['avg_time_ms'] >= 0 + + def test_benchmark_solver_results_structure(self): + """Test benchmark solver results have correct structure.""" + result = Benchmark.benchmark_solvers( + sizes=[(5, 5)], + iterations=2, + seed=42 + ) + + for r in result['results']: + assert 'algorithm' in r + assert 'size' in r + assert 'avg_time_ms' in r + assert 'avg_path_length' in r + assert r['avg_time_ms'] >= 0 + assert r['avg_path_length'] > 0 + + def test_benchmark_multiple_sizes(self): + """Test benchmark with multiple sizes.""" + sizes = [(5, 5), (10, 10)] + result = Benchmark.benchmark_generators( + sizes=sizes, + iterations=2, + seed=42 + ) + + # Should have results for each algorithm at each size + num_algorithms = len(Benchmark.GENERATORS) + expected_results = num_algorithms * len(sizes) + + assert len(result['results']) == expected_results diff --git a/tests/unit/test_generators.py b/tests/unit/test_generators.py new file mode 100644 index 0000000..498ea67 --- /dev/null +++ b/tests/unit/test_generators.py @@ -0,0 +1,148 @@ +"""Tests for maze generation algorithms.""" + +import pytest +from src.generators import ( + RecursiveBacktrackingGenerator, + KruskalGenerator, + PrimGenerator, + SidewinderGenerator, + HuntAndKillGenerator, + EllerGenerator, + WilsonGenerator, + AldousBroderGenerator +) + + +# All generators to test +GENERATORS = [ + RecursiveBacktrackingGenerator(), + KruskalGenerator(), + PrimGenerator(), + SidewinderGenerator(), + HuntAndKillGenerator(), + EllerGenerator(), + WilsonGenerator(), + AldousBroderGenerator() +] + + +class TestGenerators: + """Test all maze generation algorithms.""" + + @pytest.mark.parametrize("generator", GENERATORS) + def test_generator_creates_valid_maze(self, generator): + """Test that generator creates a valid maze.""" + maze = generator.generate(10, 10, seed=42) + + assert maze is not None + assert maze.rows == 10 + assert maze.cols == 10 + assert maze.algorithm_used == generator.name + assert maze.generation_time_ms >= 0 + + @pytest.mark.parametrize("generator", GENERATORS) + def test_generator_with_different_sizes(self, generator): + """Test generator with different maze sizes.""" + # Small maze + maze_small = generator.generate(5, 5, seed=42) + assert maze_small.rows == 5 + assert maze_small.cols == 5 + + # Large maze + maze_large = generator.generate(25, 25, seed=42) + assert maze_large.rows == 25 + assert maze_large.cols == 25 + + @pytest.mark.parametrize("generator", GENERATORS) + def test_generator_reproducibility(self, generator): + """Test that same seed produces same maze.""" + maze1 = generator.generate(10, 10, seed=42) + maze2 = generator.generate(10, 10, seed=42) + + # Compare wall structures + for row in range(10): + for col in range(10): + cell1 = maze1.get_cell(row, col) + cell2 = maze2.get_cell(row, col) + assert cell1.walls == cell2.walls + + @pytest.mark.parametrize("generator", GENERATORS) + def test_maze_is_fully_connected(self, generator): + """Test that all cells in maze are reachable.""" + maze = generator.generate(10, 10, seed=42) + + # Use BFS to check connectivity + start = maze.get_cell(0, 0) + visited = set() + queue = [start] + start.visited = True + + while queue: + current = queue.pop(0) + visited.add((current.row, current.col)) + + neighbors = maze.get_neighbors(current) + for neighbor, direction in neighbors: + if not neighbor.visited and not current.has_wall(direction): + neighbor.visited = True + queue.append(neighbor) + + # All cells should be reachable + assert len(visited) == maze.rows * maze.cols + + @pytest.mark.parametrize("generator", GENERATORS) + def test_maze_has_passages(self, generator): + """Test that maze has passages (some walls removed).""" + maze = generator.generate(10, 10, seed=42) + + total_walls = 0 + for row in maze.grid: + for cell in row: + total_walls += sum(1 for wall in cell.walls.values() if wall) + + # Should have fewer walls than a completely walled maze + max_walls = 10 * 10 * 4 + assert total_walls < max_walls + + @pytest.mark.parametrize("generator", GENERATORS) + def test_generator_performance(self, generator): + """Test generator meets performance targets.""" + # 10x10 should be very fast + maze = generator.generate(10, 10, seed=42) + assert maze.generation_time_ms < 1000 # Less than 1 second + + # Even 25x25 should be reasonable (except Aldous-Broder can be slow) + if generator.name != "Aldous-Broder Algorithm": + maze = generator.generate(25, 25, seed=42) + assert maze.generation_time_ms < 5000 # Less than 5 seconds + + +class TestSpecificGenerators: + """Test specific generator properties.""" + + def test_recursive_backtracking_name(self): + """Test recursive backtracking has correct name.""" + gen = RecursiveBacktrackingGenerator() + assert gen.name == "Recursive Backtracking" + + def test_kruskal_name(self): + """Test Kruskal's has correct name.""" + gen = KruskalGenerator() + assert gen.name == "Kruskal's Algorithm" + + def test_prim_name(self): + """Test Prim's has correct name.""" + gen = PrimGenerator() + assert gen.name == "Prim's Algorithm" + + def test_sidewinder_creates_valid_maze(self): + """Test Sidewinder algorithm.""" + gen = SidewinderGenerator() + maze = gen.generate(10, 10, seed=42) + + # Top row should have all east walls removed (characteristic of Sidewinder) + # Check that top row is mostly connected horizontally + top_row = maze.grid[0] + east_walls = sum(1 for cell in top_row if cell.has_wall('east')) + # Should have mostly removed east walls in top row + assert east_walls < len(top_row) diff --git a/tests/unit/test_maze.py b/tests/unit/test_maze.py new file mode 100644 index 0000000..08cc927 --- /dev/null +++ b/tests/unit/test_maze.py @@ -0,0 +1,173 @@ +"""Tests for core Maze and Cell classes.""" + +import pytest +from src.core.maze import Maze +from src.core.cell import Cell + + +class TestCell: + """Test Cell class functionality.""" + + def test_cell_initialization(self): + """Test cell is initialized with all walls.""" + cell = Cell(0, 0) + assert cell.row == 0 + assert cell.col == 0 + assert all(cell.walls.values()) + assert not cell.visited + + def test_remove_wall(self): + """Test removing walls from a cell.""" + cell = Cell(0, 0) + cell.remove_wall('north') + assert not cell.has_wall('north') + assert cell.has_wall('south') + + def test_cell_reset(self): + """Test resetting a cell.""" + cell = Cell(0, 0) + cell.remove_wall('north') + cell.visited = True + cell.reset() + assert cell.has_wall('north') + assert not cell.visited + + def test_cell_serialization(self): + """Test cell to_dict and from_dict.""" + cell = Cell(2, 3) + cell.remove_wall('east') + cell.visited = True + + data = cell.to_dict() + restored = Cell.from_dict(data) + + assert restored.row == cell.row + assert restored.col == cell.col + assert restored.walls == cell.walls + assert restored.visited == cell.visited + + def test_cell_equality(self): + """Test cell equality.""" + cell1 = Cell(0, 0) + cell2 = Cell(0, 0) + cell3 = Cell(1, 1) + + assert cell1 == cell2 + assert cell1 != cell3 + + +class TestMaze: + """Test Maze class functionality.""" + + def test_maze_initialization(self): + """Test maze is initialized correctly.""" + maze = Maze(10, 10, seed=42) + assert maze.rows == 10 + assert maze.cols == 10 + assert maze.seed == 42 + assert len(maze.grid) == 10 + assert len(maze.grid[0]) == 10 + + def test_maze_dimensions_validation(self): + """Test maze dimension validation.""" + with pytest.raises(ValueError): + Maze(3, 10) # Too small + with pytest.raises(ValueError): + Maze(10, 60) # Too large + + def test_get_cell(self): + """Test getting cells from maze.""" + maze = Maze(10, 10) + cell = maze.get_cell(5, 5) + assert cell is not None + assert cell.row == 5 + assert cell.col == 5 + + # Out of bounds + assert maze.get_cell(-1, 0) is None + assert maze.get_cell(0, 100) is None + + def test_get_neighbors(self): + """Test getting neighbors of a cell.""" + maze = Maze(10, 10) + + # Corner cell + cell = maze.get_cell(0, 0) + neighbors = maze.get_neighbors(cell) + assert len(neighbors) == 2 # Only south and east + + # Middle cell + cell = maze.get_cell(5, 5) + neighbors = maze.get_neighbors(cell) + assert len(neighbors) == 4 # All directions + + def test_remove_wall_between(self): + """Test removing walls between cells.""" + maze = Maze(10, 10) + cell1 = maze.get_cell(0, 0) + cell2 = maze.get_cell(0, 1) + + maze.remove_wall_between(cell1, cell2) + + assert not cell1.has_wall('east') + assert not cell2.has_wall('west') + + def test_reset_visited(self): + """Test resetting visited flags.""" + maze = Maze(10, 10) + + # Mark some cells as visited + for row in maze.grid[:5]: + for cell in row: + cell.visited = True + + maze.reset_visited() + + # Check all cells are unvisited + for row in maze.grid: + for cell in row: + assert not cell.visited + + def test_maze_serialization(self): + """Test maze to_dict and from_dict.""" + maze = Maze(5, 5, seed=42) + maze.algorithm_used = "Test Algorithm" + maze.generation_time_ms = 10.5 + + # Modify some walls + cell1 = maze.get_cell(0, 0) + cell2 = maze.get_cell(0, 1) + maze.remove_wall_between(cell1, cell2) + + # Serialize and deserialize + data = maze.to_dict() + restored = Maze.from_dict(data) + + assert restored.rows == maze.rows + assert restored.cols == maze.cols + assert restored.seed == maze.seed + assert restored.algorithm_used == maze.algorithm_used + + # Check walls are preserved + restored_cell1 = restored.get_cell(0, 0) + assert not restored_cell1.has_wall('east') + + def test_maze_json(self): + """Test JSON serialization.""" + maze = Maze(5, 5, seed=42) + json_str = maze.to_json() + + restored = Maze.from_json(json_str) + + assert restored.rows == maze.rows + assert restored.cols == maze.cols + assert restored.seed == maze.seed + + def test_is_valid_position(self): + """Test position validation.""" + maze = Maze(10, 10) + + assert maze.is_valid_position(0, 0) + assert maze.is_valid_position(9, 9) + assert not maze.is_valid_position(-1, 0) + assert not maze.is_valid_position(0, 10) diff --git a/tests/unit/test_solvers.py b/tests/unit/test_solvers.py new file mode 100644 index 0000000..860dcea --- /dev/null +++ b/tests/unit/test_solvers.py @@ -0,0 +1,143 @@ +"""Tests for maze solving algorithms.""" + +import pytest +from src.solvers import DFSSolver, BFSSolver +from src.generators import RecursiveBacktrackingGenerator + + +SOLVERS = [DFSSolver(), BFSSolver()] + + +class TestSolvers: + """Test maze solving algorithms.""" + + @pytest.mark.parametrize("solver", SOLVERS) + def test_solver_finds_solution(self, solver, small_maze): + """Test that solver finds a solution.""" + result = solver.solve(small_maze) + + assert result['success'] + assert result['path'] is not None + assert len(result['path']) > 0 + assert result['path_length'] > 0 + assert result['time_ms'] >= 0 + + @pytest.mark.parametrize("solver", SOLVERS) + def test_solution_path_validity(self, solver, medium_maze): + """Test that solution path is valid.""" + result = solver.solve(medium_maze) + + assert result['success'] + path = result['path'] + + # Path should start at maze start + assert path[0] == medium_maze.start + + # Path should end at maze end + assert path[-1] == medium_maze.end + + # Each step should be adjacent to previous + for i in range(len(path) - 1): + r1, c1 = path[i] + r2, c2 = path[i + 1] + + # Manhattan distance should be 1 + assert abs(r2 - r1) + abs(c2 - c1) == 1 + + @pytest.mark.parametrize("solver", SOLVERS) + def test_solver_visited_cells(self, solver, small_maze): + """Test that solver tracks visited cells.""" + result = solver.solve(small_maze) + + assert 'visited' in result + assert len(result['visited']) > 0 + + # Solution path should be subset of visited cells + path_set = set(result['path']) + visited_set = set(result['visited']) + assert path_set.issubset(visited_set) + + def test_bfs_finds_shortest_path(self): + """Test that BFS finds shortest path.""" + gen = RecursiveBacktrackingGenerator() + maze = gen.generate(10, 10, seed=42) + + bfs = BFSSolver() + dfs = DFSSolver() + + bfs_result = bfs.solve(maze) + dfs_result = dfs.solve(maze) + + # BFS should find shortest or equal path + assert bfs_result['path_length'] <= dfs_result['path_length'] + + def test_solver_performance(self): + """Test solver performance.""" + gen = RecursiveBacktrackingGenerator() + maze = gen.generate(25, 25, seed=42) + + for solver in SOLVERS: + result = solver.solve(maze) + # Should solve 25x25 maze quickly + assert result['time_ms'] < 1000 + + def test_solver_on_different_sizes(self): + """Test solvers on different maze sizes.""" + gen = RecursiveBacktrackingGenerator() + + for size in [5, 10, 15, 20]: + maze = gen.generate(size, size, seed=42) + + for solver in SOLVERS: + result = solver.solve(maze) + assert result['success'] + assert result['path_length'] > 0 + + +class TestDFSSolver: + """Test DFS-specific functionality.""" + + def test_dfs_name(self): + """Test DFS solver name.""" + solver = DFSSolver() + assert "DFS" in solver.name or "Depth-First" in solver.name + + def test_dfs_solves_maze(self, medium_maze): + """Test DFS solves maze correctly.""" + solver = DFSSolver() + result = solver.solve(medium_maze) + + assert result['success'] + assert result['algorithm'] == solver.name + + +class TestBFSSolver: + """Test BFS-specific functionality.""" + + def test_bfs_name(self): + """Test BFS solver name.""" + solver = BFSSolver() + assert "BFS" in solver.name or "Breadth-First" in solver.name + + def test_bfs_solves_maze(self, medium_maze): + """Test BFS solves maze correctly.""" + solver = BFSSolver() + result = solver.solve(medium_maze) + + assert result['success'] + assert result['algorithm'] == solver.name + + def test_bfs_optimal_path(self): + """Test BFS finds optimal path.""" + gen = RecursiveBacktrackingGenerator() + + # Test on multiple mazes + for seed in [42, 100, 200]: + maze = gen.generate(15, 15, seed=seed) + + bfs = BFSSolver() + result = bfs.solve(maze) + + # Verify path exists and is valid + assert result['success'] + assert result['path_length'] > 0 diff --git a/web/static/css/styles.css b/web/static/css/styles.css new file mode 100644 index 0000000..65c0797 --- /dev/null +++ b/web/static/css/styles.css @@ -0,0 +1,372 @@ +/* Neo-Brutalism Maze Generator Styles */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Neo-Brutalism Color Palette */ + --black: #000000; + --white: #FFFFFF; + --neon-yellow: #FFE500; + --neon-pink: #FF10F0; + --neon-cyan: #00F0FF; + --neon-green: #39FF14; + --gray-bg: #F5F5F5; + --gray-dark: #333333; + + /* Spacing */ + --border-thick: 6px; + --border-medium: 4px; + --shadow-offset: 8px; + --spacing-sm: 12px; + --spacing-md: 20px; + --spacing-lg: 32px; +} + +body { + font-family: 'Space Grotesk', monospace, sans-serif; + background-color: var(--gray-bg); + color: var(--black); + line-height: 1.4; + padding: var(--spacing-md); +} + +.container { + max-width: 1400px; + margin: 0 auto; +} + +/* Header */ +.header { + background-color: var(--neon-yellow); + border: var(--border-thick) solid var(--black); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-lg); + box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--black); + transform: rotate(-1deg); +} + +.title { + font-size: 4rem; + font-weight: 700; + letter-spacing: -2px; + text-transform: uppercase; + margin-bottom: var(--spacing-sm); +} + +.subtitle { + font-size: 1.2rem; + font-weight: 700; + letter-spacing: 2px; +} + +/* Main Grid Layout */ +.main-grid { + display: grid; + grid-template-columns: 350px 1fr 400px; + gap: var(--spacing-lg); +} + +@media (max-width: 1200px) { + .main-grid { + grid-template-columns: 1fr; + } +} + +/* Panels */ +.panel { + background-color: var(--white); + border: var(--border-thick) solid var(--black); + padding: var(--spacing-md); +} + +.control-panel { + background-color: var(--neon-cyan); + box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--black); +} + +.viz-panel { + background-color: var(--white); + box-shadow: calc(var(--shadow-offset) * -1) var(--shadow-offset) 0 var(--neon-pink); + min-height: 600px; +} + +.results-panel { + background-color: var(--neon-green); + box-shadow: var(--shadow-offset) calc(var(--shadow-offset) * -1) 0 var(--black); + max-height: 800px; + overflow-y: auto; +} + +.panel-title { + font-size: 1.5rem; + font-weight: 700; + text-transform: uppercase; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: var(--border-medium) solid var(--black); +} + +/* Form Controls */ +.control-group { + margin-bottom: var(--spacing-md); +} + +.control-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} + +.label { + display: block; + font-size: 0.9rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: var(--spacing-sm); +} + +.text-input, +.select-input { + width: 100%; + padding: 12px; + font-family: 'Space Grotesk', monospace, sans-serif; + font-size: 1rem; + font-weight: 700; + background-color: var(--white); + border: var(--border-medium) solid var(--black); + box-shadow: 4px 4px 0 var(--black); + transition: transform 0.1s, box-shadow 0.1s; +} + +.text-input:focus, +.select-input:focus { + outline: none; + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} + +/* Buttons */ +.btn { + width: 100%; + padding: 16px; + font-family: 'Space Grotesk', monospace, sans-serif; + font-size: 1rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + border: var(--border-medium) solid var(--black); + cursor: pointer; + transition: transform 0.1s, box-shadow 0.1s; + margin-bottom: var(--spacing-sm); +} + +.btn:hover { + transform: translate(4px, 4px); +} + +.btn:active { + transform: translate(6px, 6px); + box-shadow: none !important; +} + +.btn-primary { + background-color: var(--neon-pink); + box-shadow: 6px 6px 0 var(--black); + font-size: 1.2rem; +} + +.btn-secondary { + background-color: var(--neon-yellow); + box-shadow: 4px 4px 0 var(--black); +} + +.btn-accent { + background-color: var(--white); + box-shadow: 4px 4px 0 var(--black); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Canvas Container */ +.canvas-container { + background-color: var(--white); + border: var(--border-medium) solid var(--black); + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); + min-height: 400px; + display: flex; + align-items: center; + justify-content: center; +} + +#mazeCanvas { + border: 2px solid var(--black); +} + +/* Info Box */ +.info-box { + background-color: var(--neon-yellow); + border: var(--border-medium) solid var(--black); + padding: var(--spacing-md); + font-size: 0.9rem; + line-height: 1.8; +} + +.info-box strong { + font-weight: 700; +} + +/* Results Content */ +.results-content { + font-size: 0.9rem; + line-height: 1.8; +} + +.placeholder-text { + color: var(--gray-dark); + font-style: italic; +} + +.result-section { + background-color: var(--white); + border: var(--border-medium) solid var(--black); + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); +} + +.result-section h3 { + font-size: 1.2rem; + margin-bottom: var(--spacing-sm); + text-transform: uppercase; +} + +.result-item { + padding: 8px; + border-bottom: 2px solid var(--black); +} + +.result-item:last-child { + border-bottom: none; +} + +.result-table { + width: 100%; + border-collapse: collapse; + margin-top: var(--spacing-sm); +} + +.result-table th, +.result-table td { + padding: 8px; + text-align: left; + border: 2px solid var(--black); + font-size: 0.85rem; +} + +.result-table th { + background-color: var(--neon-yellow); + font-weight: 700; + text-transform: uppercase; +} + +.result-table tr:nth-child(even) { + background-color: var(--gray-bg); +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + align-items: center; + justify-content: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background-color: var(--white); + border: var(--border-thick) solid var(--black); + box-shadow: 12px 12px 0 var(--neon-pink); + padding: var(--spacing-lg); + max-width: 500px; + width: 90%; +} + +.modal-title { + font-size: 1.8rem; + font-weight: 700; + text-transform: uppercase; + margin-bottom: var(--spacing-md); +} + +.file-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: var(--spacing-md); +} + +.file-item { + padding: 12px; + background-color: var(--gray-bg); + border: var(--border-medium) solid var(--black); + margin-bottom: 8px; + cursor: pointer; + font-weight: 700; + transition: background-color 0.2s; +} + +.file-item:hover { + background-color: var(--neon-cyan); +} + +/* Status Messages */ +.status-message { + padding: var(--spacing-md); + border: var(--border-medium) solid var(--black); + margin-bottom: var(--spacing-md); + font-weight: 700; +} + +.status-success { + background-color: var(--neon-green); +} + +.status-error { + background-color: var(--neon-pink); +} + +.status-info { + background-color: var(--neon-cyan); +} + +/* Loading Spinner */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid var(--black); + border-radius: 0; + border-top-color: transparent; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..8fb47d9 --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,385 @@ +// Main application logic + +const API_BASE = '/api'; + +let currentMazeId = null; +let currentMazeData = null; + +// DOM Elements +const generateBtn = document.getElementById('generateBtn'); +const visualizeBtn = document.getElementById('visualizeBtn'); +const downloadBtn = document.getElementById('downloadBtn'); +const saveBtn = document.getElementById('saveBtn'); +const loadBtn = document.getElementById('loadBtn'); +const solveDfsBtn = document.getElementById('solveDfsBtn'); +const solveBfsBtn = document.getElementById('solveBfsBtn'); +const analyzeBtn = document.getElementById('analyzeBtn'); +const benchmarkBtn = document.getElementById('benchmarkBtn'); + +const algorithmSelect = document.getElementById('algorithm'); +const rowsInput = document.getElementById('rows'); +const colsInput = document.getElementById('cols'); +const seedInput = document.getElementById('seed'); +const solverSelect = document.getElementById('solver'); + +const resultsContent = document.getElementById('resultsContent'); +const mazeInfo = document.getElementById('mazeInfo'); + +// Disable buttons initially +function disableActionButtons() { + visualizeBtn.disabled = true; + downloadBtn.disabled = true; + saveBtn.disabled = true; + solveDfsBtn.disabled = true; + solveBfsBtn.disabled = true; + analyzeBtn.disabled = true; +} + +function enableActionButtons() { + visualizeBtn.disabled = false; + downloadBtn.disabled = false; + saveBtn.disabled = false; + solveDfsBtn.disabled = false; + solveBfsBtn.disabled = false; + analyzeBtn.disabled = false; +} + +disableActionButtons(); + +// Event Listeners +generateBtn.addEventListener('click', generateMaze); +visualizeBtn.addEventListener('click', visualizeMaze); +downloadBtn.addEventListener('click', downloadMaze); +saveBtn.addEventListener('click', saveMaze); +loadBtn.addEventListener('click', showLoadModal); +solveDfsBtn.addEventListener('click', () => solveMaze('dfs')); +solveBfsBtn.addEventListener('click', () => solveMaze('bfs')); +analyzeBtn.addEventListener('click', analyzeMaze); +benchmarkBtn.addEventListener('click', runBenchmark); + +// Generate Maze +async function generateMaze() { + const algorithm = algorithmSelect.value; + const rows = parseInt(rowsInput.value); + const cols = parseInt(colsInput.value); + const seed = seedInput.value ? parseInt(seedInput.value) : null; + + if (rows < 5 || rows > 50 || cols < 5 || cols > 50) { + showError('Dimensions must be between 5 and 50'); + return; + } + + setLoading(generateBtn, true); + + try { + const response = await fetch(`${API_BASE}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ algorithm, rows, cols, seed }) + }); + + const data = await response.json(); + + if (data.success) { + currentMazeId = data.id; + currentMazeData = data.maze; + enableActionButtons(); + showSuccess('Maze generated successfully!'); + displayMazeInfo(data.maze); + visualizeMaze(); + } else { + showError(data.error || 'Failed to generate maze'); + } + } catch (error) { + showError('Network error: ' + error.message); + } finally { + setLoading(generateBtn, false); + } +} + +// Visualize Maze +function visualizeMaze() { + if (!currentMazeData) { + showError('No maze to visualize'); + return; + } + + renderMaze(currentMazeData); + showInfo('Maze visualized'); +} + +// Download Maze +async function downloadMaze() { + if (currentMazeId === null) { + showError('No maze to download'); + return; + } + + try { + window.open(`${API_BASE}/download/${currentMazeId}?solution=false`, '_blank'); + showSuccess('Downloading maze image...'); + } catch (error) { + showError('Failed to download: ' + error.message); + } +} + +// Save Maze +async function saveMaze() { + if (currentMazeId === null) { + showError('No maze to save'); + return; + } + + const filename = prompt('Enter filename:', `maze_${currentMazeId}`); + if (!filename) return; + + try { + const response = await fetch(`${API_BASE}/save/${currentMazeId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }) + }); + + const data = await response.json(); + + if (data.success) { + showSuccess(`Maze saved to: ${data.filepath}`); + } else { + showError(data.error || 'Failed to save maze'); + } + } catch (error) { + showError('Network error: ' + error.message); + } +} + +// Load Maze Modal +async function showLoadModal() { + const modal = document.getElementById('loadModal'); + const fileList = document.getElementById('fileList'); + const closeBtn = document.getElementById('closeModal'); + + try { + const response = await fetch(`${API_BASE}/saved-mazes`); + const data = await response.json(); + + if (data.files && data.files.length > 0) { + fileList.innerHTML = data.files.map(file => + `
${file}
` + ).join(''); + } else { + fileList.innerHTML = '

No saved mazes found

'; + } + + modal.classList.add('active'); + + closeBtn.onclick = () => modal.classList.remove('active'); + modal.onclick = (e) => { + if (e.target === modal) modal.classList.remove('active'); + }; + } catch (error) { + showError('Failed to load file list: ' + error.message); + } +} + +async function loadMazeFile(filename) { + const modal = document.getElementById('loadModal'); + modal.classList.remove('active'); + + try { + const response = await fetch(`${API_BASE}/load`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }) + }); + + const data = await response.json(); + + if (data.success) { + currentMazeId = data.id; + currentMazeData = data.maze; + enableActionButtons(); + showSuccess(`Loaded: ${filename}`); + displayMazeInfo(data.maze); + visualizeMaze(); + } else { + showError(data.error || 'Failed to load maze'); + } + } catch (error) { + showError('Network error: ' + error.message); + } +} + +// Solve Maze +async function solveMaze(algorithm) { + if (currentMazeId === null) { + showError('No maze to solve'); + return; + } + + const btn = algorithm === 'dfs' ? solveDfsBtn : solveBfsBtn; + setLoading(btn, true); + + try { + const response = await fetch(`${API_BASE}/solve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ maze_id: currentMazeId, algorithm }) + }); + + const data = await response.json(); + + if (data.success) { + renderMaze(currentMazeData, data.visited, data.path); + displaySolutionInfo(data); + showSuccess(`Maze solved using ${data.algorithm}!`); + } else { + showError('Failed to solve maze'); + } + } catch (error) { + showError('Network error: ' + error.message); + } finally { + setLoading(btn, false); + } +} + +// Analyze Maze +async function analyzeMaze() { + if (currentMazeId === null) { + showError('No maze to analyze'); + return; + } + + setLoading(analyzeBtn, true); + + try { + const response = await fetch(`${API_BASE}/analyze/${currentMazeId}`); + const data = await response.json(); + + displayAnalysisResults(data); + showSuccess('Analysis complete!'); + } catch (error) { + showError('Network error: ' + error.message); + } finally { + setLoading(analyzeBtn, false); + } +} + +// Run Benchmark +async function runBenchmark() { + setLoading(benchmarkBtn, true); + showInfo('Running benchmarks... This may take a moment.'); + + try { + const response = await fetch(`${API_BASE}/benchmark`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'quick' }) + }); + + const data = await response.json(); + + displayBenchmarkResults(data); + showSuccess('Benchmark complete!'); + } catch (error) { + showError('Network error: ' + error.message); + } finally { + setLoading(benchmarkBtn, false); + } +} + +// Display Functions +function displayMazeInfo(maze) { + mazeInfo.innerHTML = ` + ALGORITHM: ${maze.algorithm}
+ SIZE: ${maze.rows} × ${maze.cols}
+ GENERATION TIME: ${maze.generationTime.toFixed(2)} ms + `; +} + +function displaySolutionInfo(solution) { + const section = document.createElement('div'); + section.className = 'result-section'; + section.innerHTML = ` +

SOLUTION: ${solution.algorithm}

+
Path Length: ${solution.path_length} cells
+
Cells Visited: ${solution.visited.length}
+
Solve Time: ${solution.time_ms.toFixed(2)} ms
+ `; + resultsContent.innerHTML = ''; + resultsContent.appendChild(section); +} + +function displayAnalysisResults(analysis) { + const section = document.createElement('div'); + section.className = 'result-section'; + section.innerHTML = ` +

MAZE ANALYSIS

+
Dimensions: ${analysis.dimensions}
+
Total Cells: ${analysis.total_cells}
+
Algorithm: ${analysis.algorithm}
+
Dead Ends: ${analysis.dead_ends} (${analysis.dead_end_percentage.toFixed(1)}%)
+
Longest Path: ${analysis.longest_path_length} cells
+
Avg Branching: ${analysis.average_branching_factor.toFixed(2)}
+ `; + resultsContent.innerHTML = ''; + resultsContent.appendChild(section); +} + +function displayBenchmarkResults(data) { + let html = '

GENERATOR BENCHMARK

'; + html += ''; + + data.generators.results.forEach(r => { + html += ``; + }); + + html += '
AlgorithmSizeAvg Time (ms)
${r.algorithm}${r.size}${r.avg_time_ms}
'; + + html += '

SOLVER BENCHMARK

'; + html += ''; + + data.solvers.results.forEach(r => { + html += ``; + }); + + html += '
AlgorithmSizeAvg Time (ms)Path Length
${r.algorithm}${r.size}${r.avg_time_ms}${r.avg_path_length}
'; + + resultsContent.innerHTML = html; +} + +// Utility Functions +function showSuccess(message) { + showStatus(message, 'success'); +} + +function showError(message) { + showStatus(message, 'error'); +} + +function showInfo(message) { + showStatus(message, 'info'); +} + +function showStatus(message, type) { + const existing = document.querySelector('.status-message'); + if (existing) existing.remove(); + + const div = document.createElement('div'); + div.className = `status-message status-${type}`; + div.textContent = message; + + resultsContent.insertBefore(div, resultsContent.firstChild); + + setTimeout(() => div.remove(), 5000); +} + +function setLoading(button, isLoading) { + if (isLoading) { + button.dataset.originalText = button.textContent; + button.textContent = 'LOADING...'; + button.disabled = true; + } else { + button.textContent = button.dataset.originalText || button.textContent; + button.disabled = false; + } +} diff --git a/web/static/js/visualizer.js b/web/static/js/visualizer.js new file mode 100644 index 0000000..b5555f7 --- /dev/null +++ b/web/static/js/visualizer.js @@ -0,0 +1,125 @@ +// Canvas visualization for mazes + +const canvas = document.getElementById('mazeCanvas'); +const ctx = canvas.getContext('2d'); + +const COLORS = { + wall: '#000000', + background: '#FFFFFF', + start: '#FFE500', // Neon yellow + end: '#FF10F0', // Neon pink + solution: '#39FF14', // Neon green + visited: '#E0E0E0' // Light gray +}; + +function renderMaze(mazeData, visitedCells = null, solutionPath = null) { + const rows = mazeData.rows; + const cols = mazeData.cols; + const cellSize = Math.min(Math.floor(600 / Math.max(rows, cols)), 40); + + const wallThickness = Math.max(2, Math.floor(cellSize / 10)); + + // Set canvas size + canvas.width = cols * cellSize + wallThickness; + canvas.height = rows * cellSize + wallThickness; + + // Clear canvas + ctx.fillStyle = COLORS.background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Create sets for quick lookup + const visitedSet = new Set(); + if (visitedCells) { + visitedCells.forEach(([r, c]) => visitedSet.add(`${r},${c}`)); + } + + const solutionSet = new Set(); + if (solutionPath) { + solutionPath.forEach(([r, c]) => solutionSet.add(`${r},${c}`)); + } + + // Draw cell backgrounds + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * cellSize + wallThickness; + const y = row * cellSize + wallThickness; + const key = `${row},${col}`; + + // Determine cell color + let color = COLORS.background; + + if (row === mazeData.start[0] && col === mazeData.start[1]) { + color = COLORS.start; + } else if (row === mazeData.end[0] && col === mazeData.end[1]) { + color = COLORS.end; + } else if (solutionSet.has(key)) { + color = COLORS.solution; + } else if (visitedSet.has(key)) { + color = COLORS.visited; + } + + ctx.fillStyle = color; + ctx.fillRect(x, y, cellSize - 1, cellSize - 1); + } + } + + // Draw walls + ctx.strokeStyle = COLORS.wall; + ctx.lineWidth = wallThickness; + ctx.lineCap = 'square'; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const cellWalls = mazeData.walls[row][col]; + const x = col * cellSize + wallThickness / 2; + const y = row * cellSize + wallThickness / 2; + + // Draw north wall + if (cellWalls.north) { + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + cellSize, y); + ctx.stroke(); + } + + // Draw south wall + if (cellWalls.south) { + ctx.beginPath(); + ctx.moveTo(x, y + cellSize); + ctx.lineTo(x + cellSize, y + cellSize); + ctx.stroke(); + } + + // Draw west wall + if (cellWalls.west) { + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x, y + cellSize); + ctx.stroke(); + } + + // Draw east wall + if (cellWalls.east) { + ctx.beginPath(); + ctx.moveTo(x + cellSize, y); + ctx.lineTo(x + cellSize, y + cellSize); + ctx.stroke(); + } + } + } + + // Draw start and end markers with text + drawMarker(mazeData.start[0], mazeData.start[1], 'S', cellSize, wallThickness); + drawMarker(mazeData.end[0], mazeData.end[1], 'E', cellSize, wallThickness); +} + +function drawMarker(row, col, text, cellSize, wallThickness) { + const x = col * cellSize + wallThickness + cellSize / 2; + const y = row * cellSize + wallThickness + cellSize / 2; + + ctx.fillStyle = '#000000'; + ctx.font = `bold ${Math.max(12, cellSize / 2)}px Space Grotesk, monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y); +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..cf98470 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,127 @@ + + + + + + MAZE GENERATOR + + + + + + +
+
+

MAZE GENERATOR

+

8 ALGORITHMS • INFINITE POSSIBILITIES

+
+ +
+ +
+

CONTROLS

+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + + +
+ + +
+ + + + + + + + + + + +
+ + +
+

VISUALIZATION

+
+ +
+
+
+ + +
+

RESULTS

+
+

Generate a maze to see results...

+
+
+
+
+ + + + + + + +