diff --git a/.gitignore b/.gitignore index 5e8f6be..4bbd8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,138 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.vscode/ \ No newline at end of file +.vscode/ + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +**package-lock.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2816747 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Use an official Python runtime as a parent image +FROM python:3.11-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the requirements.txt file into the container +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code into the container +COPY . . + +# Expose the port the app runs on +EXPOSE 8000 + +# Run the FastAPI server using uvicorn +CMD ["uvicorn", "web_app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/README.md b/README.md index e111169..688af19 100755 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This project is our attempt at making an AI that can play Tetris. First of all w - Heuristic agent with set weights - Genetic algorithm to find the best weights for the heuristic agent -The game is playable/viable both in the terminal and in a GUI. The GUI is made with Pygame. +The game is playable/viable both in the terminal, in a GUI and Web. The GUI is made with Pygame. ## How to run and install @@ -38,6 +38,7 @@ pip install -r requirements.txt ## Usage +### GUI version To play the game yourself, run the following command: ```bash @@ -58,6 +59,14 @@ To train the genetic agent, run the following command: python main.py train ``` +### Web version +To run the web version of the game, run the following command: + +```bash +docker compose up --build +``` +Then go to `http://localhost:80` in your browser. + ## Testing To run the test suite, run the following command from the root directory of the project: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..276634a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3" + +services: + backend: + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + networks: + - app-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "80:80" + networks: + - app-network + +networks: + app-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ff54cdd --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,8 @@ +# Use the official Nginx image to serve the frontend +FROM nginx:alpine + +# Copy the frontend files into the appropriate Nginx directory +COPY . /usr/share/nginx/html + +# Expose port 80 for the frontend +EXPOSE 80 diff --git a/frontend/agentplayer.js b/frontend/agentplayer.js new file mode 100644 index 0000000..167b929 --- /dev/null +++ b/frontend/agentplayer.js @@ -0,0 +1,64 @@ +import { BASE_URL, WS_BASE_URL } from "./routes.js"; +import { drawBoard } from "./tetris-common.js"; + +// DOM elements +const agentSelect = document.getElementById("agent-select"); +const startDemoBtn = document.getElementById("start-demo"); +let agentWebSocket = null; + +const canvasAgentId = "agentplayer-canvas"; + +// Fetch available agents from the server and populate the dropdown +async function loadAgents() { + const response = await fetch(`${BASE_URL}/agents`); + const agents = await response.json(); + + agents.forEach((agent) => { + const option = document.createElement("option"); + option.value = agent; + option.textContent = agent; + agentSelect.appendChild(option); + }); +} + +// Start WebSocket connection for agent demo +function startDemo() { + const selectedAgent = agentSelect.value; + + // Close the existing WebSocket connection if any + if (agentWebSocket) { + agentWebSocket.close(); + } + + agentWebSocket = new WebSocket(`${WS_BASE_URL}/demo/${selectedAgent}`); + + agentWebSocket.onopen = () => { + console.log(`WebSocket connection established with ${selectedAgent} agent`); + }; + + agentWebSocket.onmessage = (event) => { + const gameState = JSON.parse(event.data); + console.log("Game state received from server:", gameState); + // Render the updated board using shared function + drawBoard(gameState.board, canvasAgentId); + }; + + agentWebSocket.onclose = () => { + console.log("WebSocket connection closed"); + }; + + agentWebSocket.onerror = (error) => { + console.error("WebSocket error:", error); + }; +} + +window.addEventListener("keydown", (e) => { + if (e.key === " ") { + e.preventDefault(); // Prevent default spacebar scrolling + } +}); +// Load the agents when the page is loaded +window.addEventListener("load", loadAgents); + +// Start the demo when the button is clicked +startDemoBtn.addEventListener("click", startDemo); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..311fdb3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + TetrisAI + + +

TetrisAI

+ + + + + + + + + + + + + + diff --git a/frontend/routes.js b/frontend/routes.js new file mode 100644 index 0000000..50a6ff6 --- /dev/null +++ b/frontend/routes.js @@ -0,0 +1,2 @@ +export const BASE_URL = "http://127.0.0.1:8000"; +export const WS_BASE_URL = "ws://127.0.0.1:8000/ws"; diff --git a/frontend/singleplayer.js b/frontend/singleplayer.js new file mode 100644 index 0000000..28ed13b --- /dev/null +++ b/frontend/singleplayer.js @@ -0,0 +1,40 @@ +import { WS_BASE_URL } from "./routes.js"; +import { drawBoard } from "./tetris-common.js"; + +const singleplayerWebSocket = new WebSocket(`${WS_BASE_URL}/game`); +console.log(WS_BASE_URL); + +const canvasSinglePlayerId = "singleplayer-canvas"; + +singleplayerWebSocket.onopen = () => { + console.log("Single-player WebSocket connection established"); +}; + +singleplayerWebSocket.onmessage = (event) => { + const gameState = JSON.parse(event.data); + console.log("Game state received from server:", gameState); + drawBoard(gameState.board, canvasSinglePlayerId); +}; + +singleplayerWebSocket.onclose = () => { + console.log("Single-player WebSocket connection closed"); +}; + +singleplayerWebSocket.onerror = (error) => { + console.error("WebSocket error:", error); +}; + +// Sending input events to the server +window.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown") { + singleplayerWebSocket.send("SOFT_DROP"); + } else if (e.key === "ArrowLeft") { + singleplayerWebSocket.send("MOVE_LEFT"); + } else if (e.key === "ArrowRight") { + singleplayerWebSocket.send("MOVE_RIGHT"); + } else if (e.key === " ") { + singleplayerWebSocket.send("HARD_DROP"); + } else if (e.key === "ArrowUp") { + singleplayerWebSocket.send("ROTATE_CLOCKWISE"); + } +}); diff --git a/frontend/tetris-common.js b/frontend/tetris-common.js new file mode 100644 index 0000000..f07b9a2 --- /dev/null +++ b/frontend/tetris-common.js @@ -0,0 +1,40 @@ +/** Define block colors */ +const COLORS = [ + "rgba(0, 0, 0, 0)", // No color (transparent) + "rgb(0, 255, 255)", // I block (cyan) + "rgb(255, 0, 0)", // Z block (red) + "rgb(0, 255, 0)", // S block (green) + "rgb(255, 165, 0)", // L block (orange) + "rgb(0, 0, 255)", // J block (blue) + "rgb(128, 0, 128)", // T block (purple) + "rgb(255, 255, 0)", // O block (yellow) +]; + +/** + * Draw the Tetris board with the correct colors + * @param {number[][]} board - The Tetris board represented as a 2D array + * @param {string} canvasId - The ID of the canvas element to draw the board on + */ +function drawBoard(board, canvasId) { + const canvas = document.getElementById(canvasId); + const ctx = canvas.getContext("2d"); + const blockSize = 40; + + // Clear the canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (let y = 0; y < board.length; y++) { + for (let x = 0; x < board[y].length; x++) { + const blockType = board[y][x]; + const color = COLORS[blockType]; + + // Only draw the block if it's not an empty block + if (blockType !== 0) { + ctx.fillStyle = color; + ctx.fillRect(x * blockSize, y * blockSize, blockSize, blockSize); + } + } + } +} + +export { drawBoard }; diff --git a/requirements.txt b/requirements.txt index 9ee3940..8e5b835 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,24 @@ +annotated-types==0.7.0 +anyio==4.4.0 +click==8.1.7 +fastapi==0.114.2 +h11==0.14.0 +httptools==0.6.1 +idna==3.10 iniconfig==2.0.0 numpy==1.26.4 packaging==24.0 pluggy==1.5.0 +pydantic==2.9.1 +pydantic_core==2.23.3 pygame==2.5.2 pytest==8.2.0 +python-dotenv==1.0.1 +PyYAML==6.0.2 +sniffio==1.3.1 +starlette==0.38.5 +typing_extensions==4.12.2 +uvicorn==0.30.6 +uvloop==0.20.0 +watchfiles==0.24.0 +websockets==13.0.1 diff --git a/src/agents/agent_factory.py b/src/agents/agent_factory.py index e0f8622..8ce5e06 100644 --- a/src/agents/agent_factory.py +++ b/src/agents/agent_factory.py @@ -5,6 +5,8 @@ from src.agents.heuristic_agent import HeuristicAgent from src.agents.geneticAlgAgentJon import GeneticAlgAgentJM +AVAILABLE_AGENTS = ["random", "heuristic", "genetic"] + def create_agent(agent_type: str) -> Agent: """Create an agent of the specified type.""" diff --git a/src/agents/geneticAlgAgentJon.py b/src/agents/geneticAlgAgentJon.py index 2dbef3c..825f0f2 100644 --- a/src/agents/geneticAlgAgentJon.py +++ b/src/agents/geneticAlgAgentJon.py @@ -1,9 +1,9 @@ import random import numpy as np from src.game.tetris import * -from src.agents.agent_factory import create_agent from src.agents.agent import Agent from src.agents.heuristic_with_parameters_agent import * + # From paper: https://codemyroad.wordpress.com/2013/04/14/tetris-ai-the-near-perfect-player/ # the weigts the author got: # a x (Aggregate Height) + b x (Complete Lines) + c x (Holes) + d x (Bumpiness) @@ -21,6 +21,7 @@ # TODO create method for fetching a random 10%, and finds the two with highest lines cleared, and makes a child (with 5% chance of mutation) # TODO create method that makes 30% new agents from existing agents (last method), replace worst 30% with the new agents + class GeneticAlgAgentJM: agents: list[list[list[float], float]] = [] @@ -34,40 +35,53 @@ def number_of_selection(self, number_of_selections: int): # Run new test for i in range(len(self.agents)): param_list = self.agents[i][0] - average_cleared = self.play_game(param_list[0], param_list[1], param_list[2], param_list[3], param_list[4]) + average_cleared = self.play_game( + param_list[0], + param_list[1], + param_list[2], + param_list[3], + param_list[4], + ) self.agents[i][1] = average_cleared - - print(self.getBestPop()) + print(self.getBestPop()) def initAgents(self) -> list[list[list[float], float]]: number_of_agents = 20 for _ in range(0, number_of_agents): - agg_height = random.randrange(-1000, 0)/1000 - max_height = random.randrange(-1000, 0)/1000 - lines_cleared = random.randrange(0, 1000)/1000 - bumpiness = random.randrange(-1000, 0)/1000 - holes = random.randrange(-1000, 0)/1000 - - average_cleared = self.play_game(agg_height, max_height, lines_cleared, bumpiness, holes) - self.agents.append([[agg_height, max_height, lines_cleared, bumpiness, holes], average_cleared]) + agg_height = random.randrange(-1000, 0) / 1000 + max_height = random.randrange(-1000, 0) / 1000 + lines_cleared = random.randrange(0, 1000) / 1000 + bumpiness = random.randrange(-1000, 0) / 1000 + holes = random.randrange(-1000, 0) / 1000 + + average_cleared = self.play_game( + agg_height, max_height, lines_cleared, bumpiness, holes + ) + self.agents.append( + [ + [agg_height, max_height, lines_cleared, bumpiness, holes], + average_cleared, + ] + ) print(_) - - + def play_game(self, agg_height, max_height, lines_cleared, bumpiness, holes): board = Tetris() - agent: Agent = HeuristicWithParametersAgent([agg_height, max_height, lines_cleared, bumpiness, holes]) + agent: Agent = HeuristicWithParametersAgent( + [agg_height, max_height, lines_cleared, bumpiness, holes] + ) total_cleared = 0 number_of_rounds = 20 for _ in range(0, number_of_rounds): - + max_moves = number_of_rounds move = 0 actions_per_drop = 7 - + while not board.isGameOver() and move < max_moves: - # Get the result of the agent's action + # Get the result of the agent's action for _ in range(actions_per_drop): result = agent.result(board) # Perform the action(s) on the board @@ -76,20 +90,21 @@ def play_game(self, agg_height, max_height, lines_cleared, bumpiness, holes): board.doAction(action) else: board.doAction(result) - + move += 1 - # Advance the game by one frame + # Advance the game by one frame board.doAction(Action.SOFT_DROP) if board.blockHasLanded: board.updateBoard() - #board.printBoard() + # board.printBoard() total_cleared += board.rowsRemoved - + return total_cleared / number_of_rounds - - def replace_30_percent(self, pop_list: list[list[list[float], float]]) -> list[list[float], float]: + def replace_30_percent( + self, pop_list: list[list[list[float], float]] + ) -> list[list[float], float]: # Number of pops needed for 30% of total number num_pops_needed = int(len(pop_list) * 0.3) @@ -100,10 +115,11 @@ def replace_30_percent(self, pop_list: list[list[list[float], float]]) -> list[l pop_list.extend(new_list) return pop_list - - # TODO create method for fetching a random 10%, and finds the two with highest lines cleared, and makes a child (with 5% chance of mutation) - def paring_pop(self, pop_list: list[list[list[float], float]]) -> list[list[float], float]: + # TODO create method for fetching a random 10%, and finds the two with highest lines cleared, and makes a child (with 5% chance of mutation) + def paring_pop( + self, pop_list: list[list[list[float], float]] + ) -> list[list[float], float]: # Gets the number of pops to select # num_pops_to_select = int(len(pop_list) * 0.1) num_pops_to_select = int(len(pop_list) * 0.5) @@ -112,7 +128,7 @@ def paring_pop(self, pop_list: list[list[list[float], float]]) -> list[list[floa random_pop_sample = random.sample(pop_list, num_pops_to_select) # Gets the two pops with the highest lines cleared - highest_values = sorted(random_pop_sample, key=lambda x: x[1], reverse=True)[:2] + highest_values = sorted(random_pop_sample, key=lambda x: x[1], reverse=True)[:2] # Gets the child pop of the two pops new_pop = self.fitness_crossover(highest_values[0], highest_values[1]) @@ -123,20 +139,22 @@ def paring_pop(self, pop_list: list[list[list[float], float]]) -> list[list[floa new_pop[0] = [i / norm for i in new_pop[0]] # Mutate 5% of children pops - if random.randrange(0,1000)/1000 < 0.05: - random_parameter = int(random.randint(0,4)) - new_pop[0][random_parameter] = (random.randrange(-200, 200)/1000) * new_pop[0][random_parameter] + if random.randrange(0, 1000) / 1000 < 0.05: + random_parameter = int(random.randint(0, 4)) + new_pop[0][random_parameter] = ( + random.randrange(-200, 200) / 1000 + ) * new_pop[0][random_parameter] - #new_pop[0] = (new_pop[0] / np.linalg.norm(new_pop[0])).tolist() + # new_pop[0] = (new_pop[0] / np.linalg.norm(new_pop[0])).tolist() return new_pop - - def fitness_crossover(self, pop1: list[list[float], float], pop2: list[list[float], float]) -> list[list[float], float]: + def fitness_crossover( + self, pop1: list[list[float], float], pop2: list[list[float], float] + ) -> list[list[float], float]: # Combines the two vectors proportionaly by how many lines they cleared child_pop = [h1 * pop1[1] + h2 * pop2[1] for h1, h2 in zip(pop1[0], pop2[0])] return [child_pop, 0.0] - def getBestPop(self) -> list[list[float], float]: pop_list = self.agents diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py new file mode 100644 index 0000000..601b82a --- /dev/null +++ b/src/game/TetrisWebGameManager.py @@ -0,0 +1,141 @@ +import time +import asyncio +import json +from fastapi import WebSocket + +from src.agents.agent import Agent, playGameDemoStepByStep +from src.game.tetris import Action, Tetris + + +class TetrisGameManager: + def __init__(self, board: Tetris, websocket: WebSocket): + """ + Initialize the game manager with a board of type Tetris and a WebSocket connection. + """ + self.board = board + self.websocket = websocket # WebSocket connection for real-time communication + self.current_time = int(round(time.time() * 1000)) + self.update_timer = 1 # Timer to control piece dropping + + self.start_fall_delay_in_seconds = 1 + self.current_fall_delay = self.start_fall_delay_in_seconds + self.last_fall_time = time.time() + self.fastest_fall_delay = 0.1 + + async def movePiece(self, direction: Action): + """Move the Tetris block in a given direction and send updated game state via WebSocket.""" + self.board.doAction(direction) + await self.send_game_state() + + def isGameOver(self): + """Check if the game is over.""" + return self.board.isGameOver() + + def update_fall_delay(self): + """Update the fall delay based on the score.""" + # Speed up the block falling speed based on the number of lines cleared + LINES_CLEAR_FOR_LEVEL = 10 + LEVEL_SPEEDUP_FACTOR = 0.1 + + self.current_fall_delay = max( + self.start_fall_delay_in_seconds + - (self.board.rowsRemoved // LINES_CLEAR_FOR_LEVEL) * LEVEL_SPEEDUP_FACTOR, + self.fastest_fall_delay, + ) + print(f"Updated fall delay: {self.current_fall_delay}") + + async def startGame(self): + """Start the game loop for a normal game, receiving inputs and sending game state via WebSocket.""" + # Send initial game state + await self.send_game_state() + + while not self.board.gameOver: + try: + # Track the time and automatically move the block down if enough time has passed + current_time = time.time() + if current_time - self.last_fall_time >= self.current_fall_delay: + await self.movePiece(Action.SOFT_DROP) + self.last_fall_time = current_time + + # Receive player input, but don't reset the fall delay + try: + input_action = await asyncio.wait_for( + self.websocket.receive_text(), timeout=0.1 + ) + await self.handle_input(input_action) + except asyncio.TimeoutError: + # No input received within 0.1 seconds, keep the block falling + pass + + # Update the board after block lands + if self.board.blockHasLanded: + self.board.updateBoard() + self.update_fall_delay() + + await self.send_game_state() + + except Exception as e: + print(f"Error in game loop: {e}") + break + + await self.stopGame() + + async def startDemo(self, agent: Agent): + """Start the game loop for a demo game with an agent, sending updates via WebSocket.""" + # Send game state to client + await self.send_game_state() + while not self.board.gameOver: + playGameDemoStepByStep(agent, self.board) + await asyncio.sleep(0.1) # Small delay to simulate gameplay + await self.send_game_state() + + await self.stopGame() + + async def handle_input(self, input_action): + """Handle input from the client received via WebSocket.""" + if input_action == "SOFT_DROP": + await self.movePiece(Action.SOFT_DROP) + elif input_action == "MOVE_LEFT": + await self.movePiece(Action.MOVE_LEFT) + elif input_action == "MOVE_RIGHT": + await self.movePiece(Action.MOVE_RIGHT) + elif input_action == "HARD_DROP": + await self.movePiece(Action.HARD_DROP) + elif input_action == "ROTATE_CLOCKWISE": + await self.movePiece(Action.ROTATE_CLOCKWISE) + + async def send_game_state(self): + """Send the current game state to the client via WebSocket.""" + + # Determine the landing position of the block + simulated_board = self.board.copy() + while simulated_board.isValidBlockPosition(simulated_board.block): + simulated_board.block.moveDown() + simulated_board.block.moveUp() + + # Mark the landing position of the block on the board + LANDING_BLOCK_COLOR = -1 + for i in range(4): + for j in range(4): + if i * 4 + j in simulated_board.block.image(): + simulated_board.board[i + simulated_board.block.y][ + j + simulated_board.block.x + ] = LANDING_BLOCK_COLOR + + # Skip the top hidden rows for the visible board + visible_board = simulated_board.board[3:] + + game_state = { + "nextBlock": self.board.nextBlock.type, + "board": visible_board, + "score": self.board.rowsRemoved, + "gameOver": self.isGameOver(), + } + + await self.websocket.send_text(json.dumps(game_state)) + + async def stopGame(self): + """Handle game over logic.""" + await self.websocket.close() + print("Game Over") + print(f"Final Score: {self.board.rowsRemoved}") diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..61d0c75 --- /dev/null +++ b/web_app.py @@ -0,0 +1,164 @@ +import os +import json +from dataclasses import asdict, dataclass + +from fastapi import FastAPI, WebSocket, HTTPException +from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder +from fastapi.middleware.cors import CORSMiddleware + +from src.game.tetris import Tetris +from src.game.TetrisWebGameManager import TetrisGameManager +from src.agents.agent_factory import create_agent, AVAILABLE_AGENTS + + +app = FastAPI( + title="Cogito TetrisAI API", + description="This API provides a WebSocket interface to play Tetris and watch a Tetris AI agent play the game.", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Adjust this to allow specific origins as needed + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/agents", response_model=list) +async def get_available_agents(): + """ + Returns a list of available agents for playing the game. + """ + return AVAILABLE_AGENTS + + +@dataclass +class Score: + name: str + score: int + date: str + + +HIGHSCORES_FILE = "highscores.json" + + +def initialize_file(): + """Ensure the highscores file exists and contains valid JSON.""" + if not os.path.isfile(HIGHSCORES_FILE): + with open(HIGHSCORES_FILE, "w") as file: + json.dump([], file) + else: + # Check if the file is empty or invalid + try: + with open(HIGHSCORES_FILE, "r") as file: + if not file.read().strip(): + # If the file is empty, write an empty list + with open(HIGHSCORES_FILE, "w") as file: + json.dump([], file) + except json.JSONDecodeError: + # If the file contains invalid JSON, reset it + with open(HIGHSCORES_FILE, "w") as file: + json.dump([], file) + + +@app.get("/highscores", response_model=list[Score]) +async def get_highscores(): + initialize_file() + + with open(HIGHSCORES_FILE, "r") as file: + scores_data = json.load(file) + scores = [Score(**score) for score in scores_data] + return scores + + +@app.post("/highscores", response_model=Score) +async def add_highscore(raw_score: dict): + initialize_file() + + # Read existing scores + with open(HIGHSCORES_FILE, "r") as file: + scores_data = json.load(file) + + # Create new score instance + score = Score(**raw_score) + scores_data.append(asdict(score)) + + # Write the updated scores back to the file + with open(HIGHSCORES_FILE, "w") as file: + json.dump(scores_data, file) + + return score + + +@app.websocket("/ws/game") +async def websocket_endpoint(websocket: WebSocket): + board = Tetris() # Initialize the game board + manager = TetrisGameManager(board, websocket) + await websocket.accept() + print("WebSocket connection established") + + try: + await manager.startGame() # Start the game loop with automatic block dropping + except Exception as e: + print(f"WebSocket error: {e}") + finally: + print("WebSocket connection closed") + await websocket.close() + + +@app.websocket("/ws/demo/{agent_type}") +async def websocket_demo_endpoint(websocket: WebSocket, agent_type: str): + agent = create_agent(agent_type) # Create agent for demo + board = Tetris() # Initialize the game board + manager = TetrisGameManager(board, websocket) + + await websocket.accept() + print("WebSocket demo connection established") + + try: + await manager.startDemo(agent) + except Exception as e: + print(f"WebSocket demo error: {e}") + finally: + print("WebSocket demo connection closed") + await websocket.close() + + +# Dummy route for documentation purposes +@app.get("/ws/game/info", include_in_schema=True) +async def websocket_game_info(): + """ + This endpoint provides information about the `/ws/game` WebSocket. + + ### WebSocket Connection: + - **URL:** `/ws/game` + - **Expected Messages:** + - `"MOVE_LEFT"`: Move the Tetris block to the left + - `"MOVE_RIGHT"`: Move the Tetris block to the right + - `"SOFT_DROP"`: Drop the Tetris block one row + - `"HARD_DROP"`: Drop the Tetris block to the bottom + - `"ROTATE_CLOCKWISE"`: Rotate the block clockwise + + ### Responses: + - The server will periodically send the updated game state via WebSocket. + - The game state is sent as a JSON object with the following fields: + - `"board"`: 2D array representing the Tetris board + - `"score"`: The current score of the player + - `"gameOver"`: Whether the game is over or not + + ### Example Game State Response: + ```json + { + "board": [[0, 0, 1, 1], [1, 1, 0, 0], ...], + "score": 10, + "gameOver": false + } + ``` + """ + + return JSONResponse( + {"info": "This is the documentation for the /ws/game WebSocket endpoint."} + )