diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 9b6154a0..00000000 --- a/.coveragerc +++ /dev/null @@ -1,2 +0,0 @@ -[run] -omit = *_test.py diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index ffabc800..00000000 --- a/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -/classroom -/docs -Makefile \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b8cdbe2..9218517e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,17 +7,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: - python-version: "3.7" + python-version: | + 3.7 + 3.9 + - name: Install dependencies - run: | - pip install --upgrade pip - pip install -r requirements-dev.txt + run: make dev-init + - name: Lint - run: flake8 --show-source --statistics - - name: Blint - run: black --check --diff . + run: make lint + - name: Run tests - run: pytest + run: make test diff --git a/.gitignore b/.gitignore index 6a2f4b36..3a76c102 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Python +__pycache__ *.pyc +.pytest_cache # Editors .idea @@ -9,7 +11,8 @@ .coverage htmlcov/ -Pipfile.lock +rose_project.egg-info +dist # Classroom setup classroom/credentials.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9e1232ee..00000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: python - -python: - - "3.7" - -install: - - pip install pipenv - - pipenv install --dev --skip-lock - -script: - - flake8 - - black - - pytest --check-links --ignore=docs/course_materials/reveal diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a9136868..00000000 --- a/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# Generate a container that generates requirements.txt -ARG PY_VERSION=3.7 -FROM python:${PY_VERSION} as source - -ARG DEV - -ENV ENABLE_PIPENV=true - -# Install pipenv -RUN pip install --upgrade pipenv - -COPY Pipfile ./Pipfile - -# Generate requirements.txt file from Pipfile -RUN if [ -z ${DEV} ]; \ - then \ - pipenv lock -r > requirements.txt; \ - else \ - pipenv lock --dev -r > requirements.txt; \ - fi - -# Generate work image -ARG PY_VERSION -FROM python:${PY_VERSION} - -# Project maintainer -LABEL maintainer="frolland@redhat.com" - -# Copy pipfile to default WORKDIR -COPY --from=source requirements.txt ./requirements.txt - -# Install dependencies -RUN pip install -r requirements.txt - -# Copy application to default WORKDIR -COPY . ./ - -# Server port -EXPOSE 8880 - -# Server command -CMD [ "run", "python", "rose-server"] diff --git a/Makefile b/Makefile index b75cbfd7..6e2701b7 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,27 @@ -init: Pipfile - python -m pip install pipenv --user - pipenv install +init: + pip install -r rose/client/requirements.txt + pip install -r rose/server/requirements.txt -dev-init: Pipfile - python -m pip install pipenv --user - pipenv install --dev +dev-init: init + pip install -r rose/client/requirements-dev.txt + pip install -r rose/server/requirements-dev.txt -test: pytest.ini - pipenv run pytest +lint: + make -C rose/client lint + make -C rose/server lint -admin: rose-admin - pipenv run ./rose-admin +lint-fix: + make -C rose/client lint-fix + make -C rose/server lint-fix -server: rose-server - pipenv run ./rose-server +test: + make -C rose/client test + make -C rose/server test -client: rose-client - pipenv run ./rose-client +clean: + -find . -name '.coverage' -exec rm {} \; + -find . -name 'htmlcov' -exec rmdir {} \; + -find . -name '*.pyc' -exec rm {} \; + -find . -name '__pycache__' -exec rmdir {} \; + -find . -name '.pytest_cache' -exec rmdir {} \; -container-image: - podman build --build-arg DEV=True -t rose_dev . diff --git a/README.md b/README.md index 3fc613de..1e21e835 100644 --- a/README.md +++ b/README.md @@ -58,31 +58,28 @@ The following commands should be performed only once; after creating the environment you will be connecting to the same environment each time you open a new session. -Use venv to create a virtual environment and to install the rest of -the dependencies: +## Cloning the Repository - python3 -m venv ~/.venv/rose +First, clone the ROSE repository from GitHub: -After creating the environment, we want to activate and enter our -environment (make sure you're in the ROSE directory): +```bash +git clone https://github.com/RedHat-Israel/ROSE.git +``` - source ~/.venv/rose/bin/activate +Navigate to the cloned directory: -After entering the virtual enviornment we need to install the project dependencies: +```bash +cd ROSE +``` - pip install -r requirements.txt +Once you're in the ROSE directory, install the project dependencies: -Indication that you are inside the environment, the prompt line will -look like this: - - (rose) [username@hostname ROSE]$ +```bash +pip install -r requirements.txt +``` ## Running the server -If you are not in your virtual environment, please activate it: - - source ~/.venv/rose/bin/activate - Start the server on some machine: ./rose-server @@ -103,7 +100,7 @@ Build the Docker image: Run the Docker image on port 8880: - podman run -it --rm --name=rose_server -p 8880:8880 rose_server python ./rose-server + podman run -it --rm --name=rose_server -p 8880:8880 rose_server If you don't want to see the log of the run in the current window, replace `-it` with `-d`. @@ -128,15 +125,10 @@ server, and browse from a local machine in case port 8880 or 8888 are blocked by [firewalld](https://firewalld.org/): sudo firewall-cmd --add-port=8880/tcp --permanent - sudo firewall-cmd --add-port=8888/tcp --permanent sudo firewall-cmd --reload ## Running a driver -In a new window, open your virtual environment: - - source ~/.venv/rose/bin/activate - Create your driver file: cp examples/none.py mydriver.py @@ -146,16 +138,11 @@ name. Start up the client, using your driver file: - ./rose-client mydriver.py - -The server address can be specified that way (Replace '10.20.30.44' with -your server address): - - ./rose-client -s 10.20.30.44 mydriver.py + ./rose-client --driver mydriver.py For running the driver on the Docker container use: - docker exec -it rose_server python ./rose-client examples/random-driver.py + docker exec -it rose_server --driver examples/random-driver.py For driver modules, see the [examples](examples) directory. @@ -176,6 +163,14 @@ To stop a race, use the rose-admin tool on any machine: ./rose-admin {server-address} stop +To reset a race, use the rose-admin tool on any machine: + + ./rose-admin {server-address} reset + +To set drivers, use the rose-admin tool on any machine: + + ./rose-admin {server-address} set-drivers {URL of driver1} {URL of driver2} + To modify the game rate, you can use the "set-rate" command. The following command would change game rate to 10 frames per second: @@ -208,13 +203,15 @@ Example `tmux` commands: Should you want to contribute to the project, please read the [Code of Conduct](docs/code-of-conduct.md). -To create venv use: +Make sure you have python and make installed + + # On fedora + dnf install python make - python3 -m venv ~/.venv/rose - -To enter the venv: +Change directory to the application you want to work on: - source ~/.venv/rose/bin/activate + # For the client code + cd rose/client To install development requirements: @@ -222,12 +219,12 @@ To install development requirements: For development in docker, use: - docker build --build-arg DEV=True -t rose_dev . + make build-image Before submitting patches, please run the tests: - flake8 - pytest + make test + make lint Creating coverage report in html format: diff --git a/Pipfile b/docs/course_materials/exercises/test_exercises/Pipfile similarity index 100% rename from Pipfile rename to docs/course_materials/exercises/test_exercises/Pipfile diff --git a/examples/best_driver.pyc b/examples/best_driver.pyc deleted file mode 100644 index 443a0858..00000000 Binary files a/examples/best_driver.pyc and /dev/null differ diff --git a/examples/score.pyc b/examples/score.pyc deleted file mode 100644 index 537f31ce..00000000 Binary files a/examples/score.pyc and /dev/null differ diff --git a/examples/zigzag.pyc b/examples/zigzag.pyc deleted file mode 100644 index ac4b3013..00000000 Binary files a/examples/zigzag.pyc and /dev/null differ diff --git a/how_to_check_student_exercises.md b/how_to_check_student_exercises.md index 0df9728c..212b09d0 100644 --- a/how_to_check_student_exercises.md +++ b/how_to_check_student_exercises.md @@ -2,7 +2,11 @@ ## Running the checks -First, make sure you are using python 3.7 or later: +First, change directory to test folder before running the tests: + + `cd docs/course_materials/exercises/test_exercises + +Make sure you are using python 3.7 or later: `pipenv --python /usr/local/bin/python3.7 shell` @@ -10,10 +14,6 @@ Install all dependencies: `pipenv install --dev` -Second, change directory to test folder before running the tests: - - `cd docs/course_materials/exercises/test_exercises` - For getting the Help use: `python rose_check.py --help` diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index ecf07475..00000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = -vv -rxs --timeout 10 --cov rose --ignore=docs/course_materials/exercises/test_exercises diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 87f645a1..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,8 +0,0 @@ -autobahn -black -flake8 -pytest -pytest-check-links -pytest-coverage -pytest-timeout -twisted diff --git a/requirements.txt b/requirements.txt index a4462bf6..ca088be9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -autobahn -twisted +# requirements.txt diff --git a/rose-admin b/rose-admin index 3f03bfcd..f60e6a41 100755 --- a/rose-admin +++ b/rose-admin @@ -3,28 +3,25 @@ import sys import os import logging - -from xmlrpc import client as xmlrpclib - -from rose.common import config +import requests +import re log = logging.getLogger('admin') - class Client(object): def __init__(self, args): self.args = args - self.server = None + self.server_url = None def run(self): logging.basicConfig(level=logging.INFO) if len(self.args) < 2: - self.usage('server-address required') + self.usage('server-URL required') - server_address = self.args[1] - url = 'http://%s:%d/rpc2' % (server_address, config.web_port) - self.server = xmlrpclib.Server(url) + self.server_url = self.args[1] + if not self.is_valid_url(self.server_url): + self.usage('Invalid server URL format. Expected format: http://host:port') if len(self.args) < 3: self.usage('Verb required') @@ -41,20 +38,55 @@ class Client(object): log.error("Error running: %s, %s", verb, e) sys.exit(1) + def is_valid_url(self, url): + pattern = re.compile(r'http://\S+:\d+') + return bool(pattern.match(url)) + def do_start(self): """start""" - self.server.start() + response = requests.post(f"{self.server_url}/admin?running=1") + print(response.text) def do_stop(self): """stop""" - self.server.stop() + response = requests.post(f"{self.server_url}/admin?running=0") + print(response.text) + + def do_reset(self): + """reset""" + response = requests.post(f"{self.server_url}/admin?reset=1") + print(response.text) def do_set_rate(self): """set-rate RATE""" if len(self.args) < 4: self.usage("RATE required") rate = float(self.args[3]) - self.server.set_rate(rate) + if rate not in [1, 2, 5, 10]: + self.usage("Invalid rate value. Rate can only be 1, 2, 5, or 10.") + response = requests.post(f"{self.server_url}/admin?rate={rate}") + print(response.text) + + def do_set_drivers(self): + """set-drivers DRIVER1 [DRIVER2]""" + + if len(self.args) < 4: + self.usage("At least one DRIVER URL is required") + + driver1 = self.args[3] + if not self.is_valid_url(driver1): + self.usage("Invalid DRIVER1 URL format. Expected format: http://host:port") + + if len(self.args) >= 5: + driver2 = self.args[4] + if not self.is_valid_url(driver2): + self.usage("Invalid DRIVER2 URL format. Expected format: http://host:port") + drivers = f"{driver1},{driver2}" + else: + drivers = driver1 + + response = requests.post(f"{self.server_url}/admin?drivers={drivers}") + print(response.text) def commands(self): return sorted(getattr(self, name).__doc__ @@ -66,7 +98,7 @@ class Client(object): log.info(msg) basename = os.path.basename(self.args[0]) commands = '|'.join(self.commands()) - log.info('Usage: %s [%s]', basename, commands) + log.info('Usage: %s [%s]', basename, commands) sys.exit(2) if __name__ == '__main__': diff --git a/rose-client b/rose-client index 623d4c8c..bac6406e 100755 --- a/rose-client +++ b/rose-client @@ -1,4 +1,11 @@ #!/usr/bin/env python3 +import sys +import os + +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.join(script_dir, "rose", "client")) + + from rose.client import main main.main() diff --git a/rose-server b/rose-server index a361fa45..353d901b 100755 --- a/rose-server +++ b/rose-server @@ -1,4 +1,11 @@ #!/usr/bin/env python3 +import sys +import os + +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.join(script_dir, "rose", "server")) + + from rose.server import main main.main() diff --git a/rose/client/.coveragerc b/rose/client/.coveragerc new file mode 100644 index 00000000..75ee2926 --- /dev/null +++ b/rose/client/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = test_*.py diff --git a/.flake8 b/rose/client/.flake8 similarity index 100% rename from .flake8 rename to rose/client/.flake8 diff --git a/rose/client/Dockerfile b/rose/client/Dockerfile new file mode 100644 index 00000000..2a1630d0 --- /dev/null +++ b/rose/client/Dockerfile @@ -0,0 +1,19 @@ +# quay.io/rose/rose-common +# Use Red Hat Universal Base Image (UBI) with Python +# with: +# /app/rose/common package +# PYTHONPATH "${PYTHONPATH}:/app" +FROM quay.io/rose/rose-common + +# Set the working directory in the Docker container +WORKDIR /app/rose/client + +# Copy the local package files to the container's workspace +COPY . /app/rose/client + +# Install the Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Set the command to run the main.py file when the container launches +ENTRYPOINT ["python", "main.py", "--listen", "0.0.0.0"] +CMD ["--driver", "./mydriver.py", "--port", "8081"] diff --git a/rose/client/Makefile b/rose/client/Makefile new file mode 100644 index 00000000..e12d4a94 --- /dev/null +++ b/rose/client/Makefile @@ -0,0 +1,51 @@ +.PHONY: lint test lint-fix code-quality run build-image run-image clean + +ROSE_DIR = ../.. + +IMAGE_NAME ?= quay.io/rose/rose-client +DRIVER_PATH ?= $(ROSE_DIR)/examples/none.py +PORT ?= 8081 + +# By default, run both linting and tests +all: lint test + +lint: + @echo "Running flake8 linting..." + flake8 --show-source --statistics . + black --check --diff . + +lint-fix: + @echo "Running lint fixing..." + @black --verbose --color . + +code-quality: + @echo "Running static code quality checks..." + radon cc . + radon mi . + +test: + @echo "Running unittests..." + pytest + +run: + @echo "Running driver logic server ..." + PYTHONPATH=$(ROSE_DIR):$$PYTHONPATH python main.py --port $(PORT) --driver $(DRIVER_PATH) + +build-image: + @echo "Building container image ..." + podman build -t $(IMAGE_NAME) . + +run-image: + @echo "Running container image ..." + podman run --rm \ + --network host \ + -v ../../examples:/app/examples:z \ + -it $(IMAGE_NAME) \ + --port $(PORT) \ + --driver $(DRIVER_PATH) + +clean: + -rm -rf .coverage + -rm -rf htmlcov + -find . -name '*.pyc' -exec rm {} \; + -find . -name '__pycache__' -exec rmdir {} \; diff --git a/rose/client/README.rst b/rose/client/README.rst new file mode 100644 index 00000000..f7866d29 --- /dev/null +++ b/rose/client/README.rst @@ -0,0 +1,134 @@ +======================= +ROSE Driver Game Server +======================= + +Overview +======== +This server provides a simple HTTP endpoint for a driving game. The game receives JSON payloads containing information about car metadata and a game track filled with obstacles. +The server, using a driver's logic, returns the best action for the car to take next. + +Requirements +============ +* Python 3.8+ +* Podman (optional, for containerization) + +Installation +============ +1. Clone the repository: + + .. code-block:: bash + + git clone + cd + +2. Install the required Python packages: + + .. code-block:: bash + + pip install -r requirements.txt + pip install -r requirements-dev.txt + +Running the Server +================== +Run the server using: + +.. code-block:: bash + + python main.py --port 8081 --driver + +By default, the server will start on port 8081, default driver is ./mydriver.py . + +Driver Logic +============ +This server uses a default driver logic which chooses the next action randomly. For a custom driver logic, modify the `driver.py` file. + +Podman Usage +============ +1. Build the container image: + + .. code-block:: bash + + # Edit mydriver.py file. + podman build -t rose-driver . + +2. Build and run a custom container image: + + .. code-block:: bash + + # Customize the container using your driver. + # Copy a driver to current directory + cp ../../examples/none.py ./mydriver.py + + # Edit ./mydriver.py file, and create the best driver code, + # once ./mydriver.py is ready build the custom container. + + # Build the custom image: + # The driver ./mydriver.py will be baked into the custom container image. + podman build -t rose-driver . + + # Run the custom container + # Important: ./mydriver.py must be baked into the image during build, + # see bellow for instructions on running a local driver. + podman run -it --rm --network host rose-driver + +3. (Optional) To use your custom container image in an Openshift cluster you need to push it to a publich repository. + + .. code-block:: bash + + # Login (this example use quay.io) + podman login quay.io + + # Tag the image uging your name (for example 'jhon_doe') + podman tag rose-driver quay.io/john_doe/my_rose_driver:latest + + # Push the image to the public repository (use the name you like, for example 'my_rose_driver') + podman push quay.io/john_doe/my_rose_driver:latest + + # Now you can deploy your custom container image into an openshift cluster + +4. Run the container using local driver python file: + + .. code-block:: bash + + podman run -it --rm --network host -v :/mydriver.py:z rose-driver --driver /mydriver.py --port 8081 + +Kubernetes Deployment +===================== + +You can deploy the application on a Kubernetes cluster using the provided configuration. + +Instructions: +------------- +1. Apply both the Deployment and Service: + +.. code-block:: bash + + # Edit rose-driver.yaml and change the image to use your publically published image, image must be available from the registry, + # you can't use local images when running inside a cluster, image must be pushed to a registry reachable from the cluster. + # + # Note: By modifying the deployment and service names, you can run more then one driver. + kubectl apply -f rose-driver.yaml + +2. Check the status of the deployment: + +.. code-block:: bash + + kubectl get deployments rose-driver + +3. Forward a local port to your pod for accessing the service locally: + +.. code-block:: bash + + kubectl port-forward deployment/rose-driver-deployment 8081:8081 + +Now, the service will be accessible locally at http://localhost:8081. + +Note: For production deployments, consider exposing the service using an Ingress controller or cloud provider specific solutions. + +Contributing +============ +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +License +======= +GPL-v2 diff --git a/rose/client/car.py b/rose/client/car.py deleted file mode 100644 index c6e05863..00000000 --- a/rose/client/car.py +++ /dev/null @@ -1,14 +0,0 @@ -from . import component - - -class Car(component.Component): - def __init__(self, id): - self.id = id - self.x = None - self.y = None - self.name = None - - def update(self, info): - self.x = info["x"] - self.y = info["y"] - self.name = info["name"] diff --git a/rose/client/component.py b/rose/client/component.py deleted file mode 100644 index e24dab01..00000000 --- a/rose/client/component.py +++ /dev/null @@ -1,9 +0,0 @@ -class Component(object): - """Component intarface""" - - def update(self, info): - """ - Called when gate state changes - - info: dictionary with changes received from game server. - """ diff --git a/rose/client/conftest.py b/rose/client/conftest.py new file mode 100644 index 00000000..89b54641 --- /dev/null +++ b/rose/client/conftest.py @@ -0,0 +1,4 @@ +# conftest.py +import sys + +sys.path.append(".") diff --git a/rose/client/game.py b/rose/client/game.py deleted file mode 100644 index 5d54e64b..00000000 --- a/rose/client/game.py +++ /dev/null @@ -1,80 +0,0 @@ -import logging -import time - - -from twisted.internet import reactor - -from rose.common import message -from . import track -from . import car -from . import world -from . import component - -author = "gickowic" -log = logging.getLogger("game") - - -class Game(component.Component): - def __init__(self, client, name, drive_func): - self.client = client - self.drive_func = drive_func - self.name = name - self.track = track.Track() - self.players = {} - self.cars = [car.Car(1), car.Car(2), car.Car(3), car.Car(4)] - self.world = world.generate_world(self) - - # Component interface - - def update(self, info): - self.track.update(info) - self.players = {p["name"]: p for p in info["players"]} - for player in self.players.values(): - self.cars[player["car"]].update(player) - if info["started"]: - self.drive() - - # Driving - - def drive(self): - start = time.time() - try: - action = self.drive_func(self.world) - except Exception: - # Make it easy to detect and handle errors by crashing loudly. In - # the past we used to print a traceback and continue, and students - # had trouble detecting and handling errors. - reactor.stop() - raise - response_time = time.time() - start - msg = message.Message( - "drive", {"action": action, "response_time": response_time} - ) - self.client.send_message(msg) - - # Accessing - - @property - def car(self): - return self.cars[self.players[self.name]["car"]] - - # Handling client events - - def client_connected(self): - log.info("client connected: joining as %s", self.name) - msg = message.Message("join", {"name": self.name}) - self.client.send_message(msg) - - def client_disconnected(self, reason): - log.info("client disconnected: %s", reason.getErrorMessage()) - - def client_failed(self, reason): - log.info("client failed: %s", reason.getErrorMessage()) - - def client_error(self, error): - log.info("client error: %s", error.get("message")) - reactor.stop() - - def client_update(self, info): - # print 'client_update', info - self.update(info) diff --git a/rose/client/game/__init__.py b/rose/client/game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rose/client/game/actions.py b/rose/client/game/actions.py new file mode 100644 index 00000000..85bbffb1 --- /dev/null +++ b/rose/client/game/actions.py @@ -0,0 +1,10 @@ +""" Driving actions """ + +NONE = "none" # NOQA +RIGHT = "right" # NOQA +LEFT = "left" # NOQA +PICKUP = "pickup" # NOQA +JUMP = "jump" # NOQA +BRAKE = "brake" # NOQA + +ALL = (NONE, RIGHT, LEFT, PICKUP, JUMP, BRAKE) diff --git a/rose/client/game/car.py b/rose/client/game/car.py new file mode 100644 index 00000000..64b3bb92 --- /dev/null +++ b/rose/client/game/car.py @@ -0,0 +1,4 @@ +class Car: + def __init__(self, info): + self.x = info["x"] + self.y = info["y"] diff --git a/rose/client/game/obstacles.py b/rose/client/game/obstacles.py new file mode 100644 index 00000000..3b84f9ca --- /dev/null +++ b/rose/client/game/obstacles.py @@ -0,0 +1,17 @@ +""" Game obstacles """ + +import random + +NONE = "" # NOQA +CRACK = "crack" # NOQA +TRASH = "trash" # NOQA +PENGUIN = "penguin" # NOQA +BIKE = "bike" # NOQA +WATER = "water" # NOQA +BARRIER = "barrier" # NOQA + +ALL = (NONE, CRACK, TRASH, PENGUIN, BIKE, WATER, BARRIER) + + +def get_random_obstacle(): + return random.choice(ALL) diff --git a/rose/client/game/server.py b/rose/client/game/server.py new file mode 100644 index 00000000..d0d47a0e --- /dev/null +++ b/rose/client/game/server.py @@ -0,0 +1,131 @@ +import http.server +import json +import logging +import socket +import socketserver + +from game import world + +log = logging.getLogger("driver") + + +class MyTCPServer(socketserver.TCPServer): + # This ensures that the server will free-up the address and port when terminated + allow_reuse_address = True + + def shutdown(self): + # Explicitly shutting down the socket + self.socket.shutdown(socket.SHUT_RDWR) + socketserver.TCPServer.shutdown(self) + + +class MyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + """ + Custom HTTP request handler to handle logic driver POST requests. + + This handler is designed to process incoming POST requests containing + JSON data related to a car's metadata and game track. Based on this + information, it determines an action for the car using the `driver` + function from the driver logic module. + + Example: + curl -X POST -H "Content-Type: application/json" -d '{ + "info": { + "car": { + "x": 3, + "y": 8 + } + }, + "track": [ + ["", "", "bike", "", "", ""], + ["", "crack", "", "", "trash", ""], + ["", "", "penguin", "", "", "water"], + ["", "water", "", "trash", "", ""], + ["barrier", "", "", "", "bike", ""], + ["", "", "trash", "", "", ""], + ["", "crack", "", "", "", "bike"], + ["", "", "", "penguin", "water", ""], + ["", "", "bike", "", "", ""] + ] + }' http://localhost:8081/ -s | jq + """ + + drive = None # Set a default value for class attribute + driver_name = "Unknown" # Set a default value for class attribute + + def do_GET(self): + """ + Handle a GET request. + + Response data format: + { + "info": { "name": } + } + """ + response_data = { + "info": {"name": MyHTTPRequestHandler.driver_name}, + } + + # Send response back + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(response_data).encode("utf-8")) + + def do_POST(self): + """ + Handle a POST request. + + Expected POST data format: + { + "info": { "car": { "x": , "y": } }, + "track": <2D array> + } + + Response data format: + { + "info": { "name": , "action": } + } + + :raises json.JSONDecodeError: If the provided JSON data is not in the + expected format. + """ + + content_length = int(self.headers["Content-Length"]) + post_data = self.rfile.read(content_length).decode("utf-8") + + try: + # Decode the JSON payload + game_data = json.loads(post_data) + + # Extract metadata + game_world = world.create(game_data) + + # Determine the next action using the driver's logic + try: + action = MyHTTPRequestHandler.drive(game_world) + except Exception as e: + log.error(f"Error executing drive method: {e}") + + # Construct the response data + response_data = { + "info": {"name": MyHTTPRequestHandler.driver_name, "action": action}, + } + + # Send response back + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(response_data).encode("utf-8")) + + except json.JSONDecodeError: + self.send_response(400) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(b"Invalid JSON payload") + + except Exception as e: + self.send_response(500) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(str(e).encode("utf-8")) diff --git a/rose/client/game/test_actions.py b/rose/client/game/test_actions.py new file mode 100644 index 00000000..57ba7dee --- /dev/null +++ b/rose/client/game/test_actions.py @@ -0,0 +1,14 @@ +from game.actions import NONE, RIGHT, LEFT, PICKUP, JUMP, BRAKE, ALL + + +def test_constants(): + assert NONE == "none" + assert RIGHT == "right" + assert LEFT == "left" + assert PICKUP == "pickup" + assert JUMP == "jump" + assert BRAKE == "brake" + + +def test_all_constant(): + assert ALL == (NONE, RIGHT, LEFT, PICKUP, JUMP, BRAKE) diff --git a/rose/client/game/test_car.py b/rose/client/game/test_car.py new file mode 100644 index 00000000..dff7f14b --- /dev/null +++ b/rose/client/game/test_car.py @@ -0,0 +1,21 @@ +import pytest +from game.car import Car + + +def test_car_initialization(): + info = {"x": 5, "y": 10} + car = Car(info) + + assert car.x == 5 + assert car.y == 10 + + +def test_car_initialization_missing_key(): + info_missing_x = {"y": 10} + info_missing_y = {"x": 5} + + with pytest.raises(KeyError): + Car(info_missing_x) + + with pytest.raises(KeyError): + Car(info_missing_y) diff --git a/rose/client/game/test_obstacles.py b/rose/client/game/test_obstacles.py new file mode 100644 index 00000000..ef42b942 --- /dev/null +++ b/rose/client/game/test_obstacles.py @@ -0,0 +1,31 @@ +from game.obstacles import ( + NONE, + CRACK, + TRASH, + PENGUIN, + BIKE, + WATER, + BARRIER, + ALL, + get_random_obstacle, +) + + +def test_constants(): + assert NONE == "" + assert CRACK == "crack" + assert TRASH == "trash" + assert PENGUIN == "penguin" + assert BIKE == "bike" + assert WATER == "water" + assert BARRIER == "barrier" + + +def test_all_constant(): + assert ALL == (NONE, CRACK, TRASH, PENGUIN, BIKE, WATER, BARRIER) + + +def test_get_random_obstacle(): + # This test checks if the function returns a valid obstacle + obstacle = get_random_obstacle() + assert obstacle in ALL diff --git a/rose/client/game/test_server.py b/rose/client/game/test_server.py new file mode 100644 index 00000000..09aee498 --- /dev/null +++ b/rose/client/game/test_server.py @@ -0,0 +1,57 @@ +import pytest +import requests +import threading +from game.server import MyTCPServer, MyHTTPRequestHandler + + +def drive(world): + return "" + + +# Start the server in a separate thread for testing +@pytest.fixture(scope="module") +def start_server(): + server_address = ("", 8081) + MyHTTPRequestHandler.drive = drive + httpd = MyTCPServer(server_address, MyHTTPRequestHandler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + yield + httpd.shutdown() + thread.join() + + +def test_get_driver_name(start_server): + response = requests.get("http://localhost:8081/") + data = response.json() + assert data["info"]["name"] == "Unknown" # Default driver name + + +def test_post_valid_data(start_server): + payload = { + "info": {"car": {"x": 3, "y": 8}}, + "track": [ + ["", "", "bike"], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ], + } + response = requests.post("http://localhost:8081/", json=payload) + data = response.json() + assert "action" in data["info"] + + +def test_post_invalid_json(start_server): + response = requests.post("http://localhost:8081/", data="not a valid json") + assert response.status_code == 400 + + +def test_post_unexpected_data_structure(start_server): + payload = {"unexpected": "data"} + response = requests.post("http://localhost:8081/", json=payload) + assert response.status_code == 500 diff --git a/rose/client/game/test_track.py b/rose/client/game/test_track.py new file mode 100644 index 00000000..c12084da --- /dev/null +++ b/rose/client/game/test_track.py @@ -0,0 +1,46 @@ +import pytest +from game.track import Track + + +def test_track_initialization(): + t = Track() + assert t.max_x == 0 + assert t.max_y == 0 + + t2 = Track([["a", "b"], ["c", "d"]]) + assert t2.max_x == 2 + assert t2.max_y == 2 + + +def test_track_get(): + t = Track([["a", "b"], ["c", "d"]]) + assert t.get(0, 0) == "a" + assert t.get(1, 0) == "b" + assert t.get(0, 1) == "c" + assert t.get(1, 1) == "d" + + +def test_track_get_out_of_bounds(): + t = Track([["a", "b"], ["c", "d"]]) + + with pytest.raises(IndexError, match="x out of range: 0-1"): + t.get(2, 0) + + with pytest.raises(IndexError, match="y out of range: 0-1"): + t.get(0, 2) + + +def test_track_validate_pos(): + t = Track([["a", "b"], ["c", "d"]]) + + # These should not raise any errors + t._validate_pos(0, 0) + t._validate_pos(1, 0) + t._validate_pos(0, 1) + t._validate_pos(1, 1) + + with pytest.raises(IndexError, match="x out of range: 0-1"): + t._validate_pos(2, 0) + + with pytest.raises(IndexError, match="y out of range: 0-1"): + t._validate_pos(0, 2) diff --git a/rose/client/game/track.py b/rose/client/game/track.py new file mode 100644 index 00000000..90ee2f91 --- /dev/null +++ b/rose/client/game/track.py @@ -0,0 +1,39 @@ +class Track: + def __init__(self, initial_track=None): + """ + Initialize the track with the provided 2D array. + + :param initial_track: 2D array representing the initial state of the + track. + """ + if initial_track is None: + initial_track = [] + self._track = initial_track + + self.max_x = len(self._track[0]) if self._track else 0 + self.max_y = len(self._track) + + # Track interface + + def get(self, x, y): + """ + Return the action in position x, y. + + :param x: x-coordinate of the position. + :param y: y-coordinate of the position. + :return: The action at the specified position. + """ + self._validate_pos(x, y) + return self._track[y][x] + + # Private + + def _validate_pos(self, x, y): + """ + Validate if the provided x, y coordinates are within the bounds of the + track. + """ + if x < 0 or x >= self.max_x: + raise IndexError(f"x out of range: 0-{self.max_x - 1}") + if y < 0 or y >= self.max_y: + raise IndexError(f"y out of range: 0-{self.max_y - 1}") diff --git a/rose/client/game/world.py b/rose/client/game/world.py new file mode 100644 index 00000000..e2e10a0d --- /dev/null +++ b/rose/client/game/world.py @@ -0,0 +1,44 @@ +from game.car import Car +from game.track import Track + + +def create(game_data): + """ + Creates a world object based on the provided game data. + + World allows read-only access to game data. + + Arguments: + game_data: A dictionary containing information about the car and track. + + Returns: + An instance of the World class. + """ + + # Extract car and track details from game data + car_data = game_data.get("info", {}).get("car", {}) + track_data = game_data.get("track", []) + + # Instantiate Car and Track objects + car = Car(car_data) + track = Track(track_data) + + class World(object): + @property + def car(self): + """Return my car""" + return car + + def get(self, pos): + """ + Return the obsticale at position pos + + Arguments: + pos: 2-tuple (x, y) using game logical units + + Accessing a position out of the world bounds will raise IndexError + exception. + """ + return track.get(pos[0], pos[1]) + + return World() diff --git a/rose/client/main.py b/rose/client/main.py index e44c9e36..b2c8e550 100644 --- a/rose/client/main.py +++ b/rose/client/main.py @@ -1,121 +1,90 @@ import argparse -import importlib +import importlib.util import logging -import sys -from twisted.internet import reactor, protocol -from twisted.protocols import basic +from game import server -from rose.common import config, message -from . import game -log = logging.getLogger("main") - - -class Client(basic.LineReceiver): - def connectionMade(self): - self.factory.connected(self) - - def connectionLost(self, reason): - self.factory.disconnected(reason) - - def connectionFailed(self, reason): - self.factory.failed(reason) - - def lineReceived(self, line): - msg = message.parse(line) - if msg.action == "update": - self.factory.update(msg.payload) - elif msg.action == "error": - self.factory.error(msg.payload) - else: - log.info("Unexpected message: %s %s", msg.action, msg.payload) - - -class ClientFactory(protocol.ReconnectingClientFactory): - protocol = Client - initialDelay = 2 - maxDelay = 2 - - def __init__(self, name, drive_func): - self.game = game.Game(self, name, drive_func) - self.client = None - - # Client events - - def connected(self, client): - self.resetDelay() - self.client = client - self.game.client_connected() - - def disconnected(self, reason): - self.client = None - self.game.client_disconnected(reason) - - def failed(self, reason): - self.client = None - self.game.client_failed(reason) - - def error(self, error): - self.game.client_error(error) - - def update(self, info): - self.game.client_update(info) - - # Client interface - - def send_message(self, msg): - self.client.sendLine(str(msg).encode("utf-8")) - - -def load_driver_module(file_path): +def load_driver_module(driver_path): """ Load the driver module from the specified path. Arguments: file_path (str): The path to the driver module - Returns: Driver module (module) - Raises: Exception if the module cannot be loaded """ - spec = importlib.util.spec_from_file_location("driver_module", file_path) + spec = importlib.util.spec_from_file_location("driver_module", driver_path) driver_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(driver_module) return driver_module def main(): - logging.basicConfig(level=logging.INFO, format=config.logger_format) - parser = argparse.ArgumentParser(description="ROSE Client") + """ + Main function to initialize and run the driver HTTP server. + """ + parser = argparse.ArgumentParser(description="Run a ROSE driver HTTP service.") + parser.add_argument( + "-p", + "--port", + type=int, + default=8081, + help="Specify the port number. Default is 8081.", + ) + parser.add_argument( + "--listen", + default="", + help="Specify the listen address. Default is all interfaces.", + ) parser.add_argument( - "--server-address", - "-s", - dest="server_address", - default="localhost", - help="The server address to connect to." - " For example: '10.20.30.44' or 'my-server.com'." - " If not specified, localhost will be used.", + "--name", + default="rose-driver", + help="Specify the server name for logging purposes. Default is 'rose-driver'.", + ) + parser.add_argument( + "-d", + "--driver", + default="", + help="Specify the path to the driver module.", + ) + parser.add_argument( + "--log", default="WARNING", help="Set the logging level. E.g. --log DEBUG" ) - parser.add_argument("driver_file", help="The path to the driver python module") args = parser.parse_args() - try: - driver_mod = load_driver_module(args.driver_file) - except Exception as e: - log.error("error loading driver module %r: %s", args.driver_file, e) - sys.exit(2) - - reactor.connectTCP( - args.server_address, - config.game_port, - ClientFactory(driver_mod.driver_name, driver_mod.drive), - ) + logging.basicConfig(level=getattr(logging, args.log.upper())) - url = "http://%s:%d" % (args.server_address, config.web_port) - log.info("Please open %s to watch the game." % url) + if args.driver == "": + print("Error: missing driver command line argument") + return - reactor.run() + try: + driver_module = load_driver_module(args.driver) + server.MyHTTPRequestHandler.server_name = args.name + server.MyHTTPRequestHandler.drive = driver_module.drive + server.MyHTTPRequestHandler.driver_name = driver_module.driver_name + + print(f"\nDriver module {args.driver} [driver: {driver_module.driver_name}]") + except ImportError as e: + print(e) + return # Exit the main function if the module loading fails + + with server.MyTCPServer( + (args.listen, args.port), server.MyHTTPRequestHandler + ) as httpd: + try: + print(f"Listen {args.listen}:{args.port}") + print(f"Server URL http://127.0.0.1:{args.port}") + httpd.serve_forever() + except KeyboardInterrupt: + print("\nShutting down the server...") + httpd.shutdown() + httpd.server_close() + + +if __name__ == "__main__": + main() diff --git a/rose/client/pytest.ini b/rose/client/pytest.ini new file mode 100644 index 00000000..e6da1935 --- /dev/null +++ b/rose/client/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -vv -rxs --timeout 10 --cov . diff --git a/rose/client/requirements-dev.txt b/rose/client/requirements-dev.txt new file mode 100644 index 00000000..df92181f --- /dev/null +++ b/rose/client/requirements-dev.txt @@ -0,0 +1,10 @@ +# requirements.txt + +flake8>=3.9.0 +coverage>=7.3.0 +radon>=6.0.0 +black>=23.7.0 +pytest +pytest-check-links +pytest-coverage +pytest-timeout \ No newline at end of file diff --git a/rose/client/requirements.txt b/rose/client/requirements.txt new file mode 100644 index 00000000..ca088be9 --- /dev/null +++ b/rose/client/requirements.txt @@ -0,0 +1 @@ +# requirements.txt diff --git a/rose/client/rose-client.yaml b/rose/client/rose-client.yaml new file mode 100644 index 00000000..17517676 --- /dev/null +++ b/rose/client/rose-client.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rose-client-deployment + labels: + app: rose-client +spec: + replicas: 1 + selector: + matchLabels: + app: rose-client + template: + metadata: + labels: + app: rose-client + spec: + containers: + - name: rose-client-container + image: quay.io/rose/rose-client:latest # Modify with your Docker image name and tag. + ports: + - containerPort: 8081 + +--- + +apiVersion: v1 +kind: Service +metadata: + name: rose-client-service +spec: + selector: + app: rose-client + ports: + - protocol: TCP + port: 8081 + targetPort: 8081 + type: LoadBalancer diff --git a/rose/client/track.py b/rose/client/track.py deleted file mode 100644 index 9a7428d1..00000000 --- a/rose/client/track.py +++ /dev/null @@ -1,27 +0,0 @@ -from rose.common import config, obstacles -from . import component - - -class Track(component.Component): - def __init__(self): - self._track = {} - - # Component interface - - def update(self, info): - self._track = {(obs["x"], obs["y"]): obs["name"] for obs in info["track"]} - - # Track interface - - def get(self, x, y): - """Return the obstacle in position x, y""" - self._validate_pos(x, y) - return self._track.get((x, y), obstacles.NONE) - - # Private - - def _validate_pos(self, x, y): - if x < 0 or x > config.matrix_width - 1: - raise IndexError("x out of range: 0-%d", config.matrix_width - 1) - if y < 0 or y > config.matrix_height - 1: - raise IndexError("y out of range: 0-%d", config.matrix_height - 1) diff --git a/rose/client/world.py b/rose/client/world.py deleted file mode 100644 index 20a84f5b..00000000 --- a/rose/client/world.py +++ /dev/null @@ -1,42 +0,0 @@ -""" The world """ - - -def generate_world(game): - """ - Creates a world object - - World allows read-only access to game data. - """ - - class Car(object): - @property - def x(self): - """Returns car x position in game logical units""" - return game.car.x - - @property - def y(self): - """Returns car y position in game logical units""" - return game.car.y - - car = Car() - - class World(object): - @property - def car(self): - """Return my car""" - return car - - def get(self, pos): - """ - Return the obstacle at position pos - - Arguments: - pos: 2 tuple (x, y) using game logical units - - Accessing a position out of the world bounds will raise IndexError - exception. - """ - return game.track.get(pos[0], pos[1]) - - return World() diff --git a/rose/common/Dockerfile b/rose/common/Dockerfile new file mode 100644 index 00000000..587fcc36 --- /dev/null +++ b/rose/common/Dockerfile @@ -0,0 +1,16 @@ +# Use Red Hat Universal Base Image (UBI) with Python +FROM registry.access.redhat.com/ubi8/python-38 + +# Set the working directory in the Docker container +WORKDIR /app/rose/common + +# Make app.rose a python package +COPY __init__.py /app/rose/ + +# Copy the local package files to rose.common +COPY __init__.py /app/rose/common/ +COPY actions.py /app/rose/common/ +COPY obstacles.py /app/rose/common/ + +# Add the rose client package to the python path +ENV PYTHONPATH "${PYTHONPATH}:/app" diff --git a/rose/common/Makefile b/rose/common/Makefile new file mode 100644 index 00000000..287892c2 --- /dev/null +++ b/rose/common/Makefile @@ -0,0 +1,19 @@ +.PHONY: lint lint-fix build-image + +IMAGE_NAME ?= quay.io/rose/rose-common + +# By default, run both linting and tests +all: lint + +lint: + @echo "Running flake8 linting..." + flake8 --show-source --statistics . + black --check --diff . + +lint-fix: + @echo "Running lint fixing..." + @black --verbose --color . + +build-image: + @echo "Building container image ..." + podman build -t $(IMAGE_NAME) . diff --git a/rose/common/README.rst b/rose/common/README.rst new file mode 100644 index 00000000..0a7a8617 --- /dev/null +++ b/rose/common/README.rst @@ -0,0 +1,10 @@ +=========== +ROSE Common +=========== + +A collection of classes useful for creating driver methods. + +Usage: + + from rose.common import obstacles, actions + diff --git a/rose/common/config.py b/rose/common/config.py deleted file mode 100644 index 613a2459..00000000 --- a/rose/common/config.py +++ /dev/null @@ -1,50 +0,0 @@ -import os - -# Networking - -game_port = 8888 -web_port = 8880 - -# Server - -game_rate = 1.0 -game_duration = 60 -number_of_cars = 4 -is_track_random = True - -# Matrix - -matrix_height = 9 -matrix_width = 6 -row_height = 65 -cell_width = 130 -left_margin = 95 -top_margin = 10 - -# Files - -install_dir = os.path.dirname(__file__) - -# Web interface - -web_root = os.path.join(install_dir, "../web") -res_root = os.path.join(install_dir, "../res") - -# Player - -max_players = 2 -cells_per_player = matrix_width // max_players - -# Score Points - -score_move_forward = 10 -score_move_backward = -10 -score_pickup = 10 -score_jump = 5 -score_brake = 4 - -# Logging - -logger_format = ( - "%(asctime)s %(levelname)-7s [%(name)s] %(message)s " "(%(module)s:%(lineno)d)" -) diff --git a/rose/common/error.py b/rose/common/error.py deleted file mode 100644 index 9402e9ea..00000000 --- a/rose/common/error.py +++ /dev/null @@ -1,45 +0,0 @@ -class Error(Exception): - """Base class for server errors""" - - def __str__(self): - return self.message % self.args - - -class PlayerExists(Error): - message = "Player exists: %s" - - def __init__(self, name): - self.args = (name,) - - -class TooManyPlayers(Error): - message = "Too many players" - - -class NoSuchPlayer(Error): - def __init__(self, name): - self.args = (name,) - - message = "No such player: %s" - - -class ActionForbidden(Error): - def __init__(self, action): - self.args = (action,) - - message = "You are not allowed to %s" - - -class InvalidMessage(Error): - def __init__(self, reason): - self.args = (reason,) - - message = "Invalid message: %s" - - -class GameAlreadyStarted(Error): - message = "Game already started" - - -class GameNotStarted(Error): - message = "Game not started yet" diff --git a/rose/common/message.py b/rose/common/message.py deleted file mode 100644 index 6a4634cc..00000000 --- a/rose/common/message.py +++ /dev/null @@ -1,22 +0,0 @@ -import json -from . import error - - -def parse(line): - try: - d = json.loads(line) - except ValueError as e: - raise error.InvalidMessage(str(e)) - if "action" not in d: - raise error.InvalidMessage("action required") - return Message(d["action"], d.get("payload")) - - -class Message(object): - def __init__(self, action, payload=None): - self.action = action - self.payload = payload - - def __str__(self): - d = {"action": self.action, "payload": self.payload} - return json.dumps(d) diff --git a/rose/res/dashboard/dashboard.png b/rose/res/dashboard/dashboard.png deleted file mode 100644 index d5721938..00000000 Binary files a/rose/res/dashboard/dashboard.png and /dev/null differ diff --git a/rose/res/splash/splash_screen.png b/rose/res/splash/splash_screen.png deleted file mode 100644 index 9a85ba3d..00000000 Binary files a/rose/res/splash/splash_screen.png and /dev/null differ diff --git a/rose/server/.coveragerc b/rose/server/.coveragerc new file mode 100644 index 00000000..75ee2926 --- /dev/null +++ b/rose/server/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = test_*.py diff --git a/rose/server/.flake8 b/rose/server/.flake8 new file mode 100644 index 00000000..46b65244 --- /dev/null +++ b/rose/server/.flake8 @@ -0,0 +1,8 @@ +[flake8] +show_source = True +statistics = True + +# E501: line to long. +# E203: whitespace before ':' to accept black code style +# W503: line break before binary operator +ignore = E501,E203,W503 diff --git a/rose/server/Dockerfile b/rose/server/Dockerfile new file mode 100644 index 00000000..cf6f4bd1 --- /dev/null +++ b/rose/server/Dockerfile @@ -0,0 +1,18 @@ +# --- Build Image --- +FROM registry.access.redhat.com/ubi8/python-38 AS build + +WORKDIR /build + +# Copy only the requirements file and install the Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# --- Runtime Image --- +FROM build + +WORKDIR /app +COPY . /app + +# Set the command to run the main.py file when the container launches +ENTRYPOINT ["python", "main.py", "--listen", "0.0.0.0"] +CMD [ "--track", "same" ] diff --git a/rose/server/Makefile b/rose/server/Makefile new file mode 100644 index 00000000..50d72fb3 --- /dev/null +++ b/rose/server/Makefile @@ -0,0 +1,46 @@ +.PHONY: lint test lint-fix code-quality run build-image run-image clean + +IMAGE_NAME ?= quay.io/rose/rose-server +PORT ?= 8880 + +# Default driver when running on localhost +DRIVERS ?= http://127.0.0.1:8081 + +# By default, run both linting and tests +all: lint test + +lint: + @echo "Running linting..." + flake8 --show-source --statistics . + black --check --diff . + +lint-fix: + @echo "Running lint fixing..." + black --verbose --color . + +code-quality: + @echo "Running static code quality checks..." + radon cc . + radon mi . + +test: + @echo "Running unittests..." + pytest + +run: + @echo "Running driver logic server ..." + python main.py --port $(PORT) --drivers $(DRIVERS) + +build-image: + @echo "Building container image ..." + podman build -t $(IMAGE_NAME) . + +run-image: + @echo "Running container image ..." + podman run --rm --network host -it $(IMAGE_NAME) + +clean: + -rm -rf .coverage + -rm -rf htmlcov + -find . -name '*.pyc' -exec rm {} \; + -find . -name '__pycache__' -exec rmdir {} \; diff --git a/rose/server/README.rst b/rose/server/README.rst new file mode 100644 index 00000000..0c52eba3 --- /dev/null +++ b/rose/server/README.rst @@ -0,0 +1,89 @@ +======================= +ROSE Engine Game Server +======================= + +Overview +======== +This server provides a simple HTTP endpoint for a driving game. + +Requirements +============ +* Python 3.8+ +* Podman (optional, for containerization) + +Installation +============ +1. Clone the repository: + + .. code-block:: bash + + git clone + cd + +2. Install the required Python packages: + + .. code-block:: bash + + pip install -r requirements.txt + pip install -r requirements-dev.txt + +Running the Server +================== +Run the server using: + +.. code-block:: bash + + python main.py --port 8080 + +By default, the server will start on port 8080. + +Podman Usage +============ +1. Build the Podman image: + + .. code-block:: bash + + podman build -t rose-engine . + +2. Run the container: + + .. code-block:: bash + + podman run -it --rm --network host rose-engine + +Kubernetes Deployment +===================== + +You can deploy the application on a Kubernetes cluster using the provided configuration. + +Instructions: +------------- +1. Apply both the Deployment and Service: + +.. code-block:: bash + + kubectl apply -f rose-engine.yaml + +2. Check the status of the deployment: + +.. code-block:: bash + + kubectl get deployments rose-engine + +3. Forward a local port to your pod for accessing the service locally: + +.. code-block:: bash + + kubectl port-forward deployment/rose-engine-deployment 8880:8880 + +Now, the service will be accessible locally at http://localhost:8880. + +Note: For production deployments, consider exposing the service using an Ingress controller or cloud provider specific solutions. + +Contributing +============ +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +License +======= +GPL-v2 diff --git a/rose/server/common/__init__.py b/rose/server/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rose/server/common/actions.py b/rose/server/common/actions.py new file mode 100644 index 00000000..85bbffb1 --- /dev/null +++ b/rose/server/common/actions.py @@ -0,0 +1,10 @@ +""" Driving actions """ + +NONE = "none" # NOQA +RIGHT = "right" # NOQA +LEFT = "left" # NOQA +PICKUP = "pickup" # NOQA +JUMP = "jump" # NOQA +BRAKE = "brake" # NOQA + +ALL = (NONE, RIGHT, LEFT, PICKUP, JUMP, BRAKE) diff --git a/rose/server/common/config.py b/rose/server/common/config.py new file mode 100644 index 00000000..38e343da --- /dev/null +++ b/rose/server/common/config.py @@ -0,0 +1,20 @@ +# config.py + +# Default Configuration +drivers = [] +run = "stop" + +game_rate = 1.0 +game_duration = 60 + +matrix_height = 9 +matrix_width = 6 + +max_players = 2 +cells_per_player = 3 + +score_move_forward = 10 +score_move_backward = -10 +score_pickup = 10 +score_jump = 5 +score_brake = 4 diff --git a/rose/server/common/obstacles.py b/rose/server/common/obstacles.py new file mode 100644 index 00000000..3b84f9ca --- /dev/null +++ b/rose/server/common/obstacles.py @@ -0,0 +1,17 @@ +""" Game obstacles """ + +import random + +NONE = "" # NOQA +CRACK = "crack" # NOQA +TRASH = "trash" # NOQA +PENGUIN = "penguin" # NOQA +BIKE = "bike" # NOQA +WATER = "water" # NOQA +BARRIER = "barrier" # NOQA + +ALL = (NONE, CRACK, TRASH, PENGUIN, BIKE, WATER, BARRIER) + + +def get_random_obstacle(): + return random.choice(ALL) diff --git a/rose/server/conftest.py b/rose/server/conftest.py new file mode 100644 index 00000000..89b54641 --- /dev/null +++ b/rose/server/conftest.py @@ -0,0 +1,4 @@ +# conftest.py +import sys + +sys.path.append(".") diff --git a/rose/server/game.py b/rose/server/game.py deleted file mode 100644 index 9c72daa1..00000000 --- a/rose/server/game.py +++ /dev/null @@ -1,132 +0,0 @@ -import random -import logging -import os - -from twisted.internet import reactor, task - -from rose.common import actions, config, error, message, obstacles # NOQA -from . import track -from . import player -from . import score - -log = logging.getLogger("game") - - -class Game(object): - """ - Implements the server for the car race - """ - - def __init__(self): - self.hub = None - self.track = track.Track() - self.looper = task.LoopingCall(self.loop) - self.players = {} - self.free_cars = set(range(config.number_of_cars)) - self.free_lanes = set(range(config.max_players)) - self._rate = config.game_rate - self.started = False - self.timeleft = config.game_duration - - @property - def rate(self): - return self._rate - - @rate.setter - def rate(self, value): - if value != self._rate: - log.info("change game rate to %d frames per second", value) - self._rate = value - if self.started: - self.looper.stop() - self.looper.start(1.0 / self._rate) - else: - self.update_clients() - - def start(self): - if self.started: - raise error.GameAlreadyStarted() - if not self.players: - raise error.ActionForbidden("start a game with no players.") - self.track.reset() - for p in self.players.values(): - p.reset() - self.timeleft = config.game_duration - self.started = True - self.looper.start(1.0 / self._rate) - - def stop(self): - if not self.started: - raise error.GameNotStarted() - self.looper.stop() - self.started = False - self.update_clients() - self.print_stats() - - def add_player(self, name): - if name in self.players: - raise error.PlayerExists(name) - if not self.free_cars: - raise error.TooManyPlayers() - car = random.choice(tuple(self.free_cars)) - self.free_cars.remove(car) - lane = random.choice(tuple(self.free_lanes)) - self.free_lanes.remove(lane) - log.info("add player: %r, lane: %r, car: %r", name, lane, car) - self.players[name] = player.Player(name, car, lane) - reactor.callLater(0, self.update_clients) - - def remove_player(self, name): - if name not in self.players: - raise error.NoSuchPlayer(name) - player = self.players.pop(name) - self.free_cars.add(player.car) - self.free_lanes.add(player.lane) - log.info("remove player: %r, lane: %r, car: %r", name, player.lane, player.car) - if not self.players and self.started: - log.info("Stopping game. No players connected.") - self.stop() - else: - reactor.callLater(0, self.update_clients) - - def drive_player(self, name, info): - log.info("drive_player: %r %r", name, info) - if name not in self.players: - raise error.NoSuchPlayer(name) - if "action" not in info: - raise error.InvalidMessage("action required") - action = info["action"] - if action not in actions.ALL: - raise error.InvalidMessage("invalid drive action %s" % action) - self.players[name].action = action - self.players[name].response_time = info.get("response_time", 1.0) - - def print_stats(self): - lines = ["Stats:"] - top_scorers = sorted(self.players.values(), reverse=True) - for i, p in enumerate(top_scorers): - line = "%d %10s row:%d score:%d" % (i + 1, p.name, p.y, p.score) - lines.append(line) - log.info("%s", os.linesep.join(lines)) - - def loop(self): - self.track.update() - score.process(self.players, self.track) - if self.timeleft > 0: - self.update_clients() - self.timeleft -= 1 - else: - self.stop() - - def update_clients(self): - msg = message.Message("update", self.state()) - self.hub.broadcast(msg) - - def state(self): - return { - "started": self.started, - "track": self.track.state(), - "players": [p.state() for p in self.players.values()], - "timeleft": self.timeleft, - "rate": self.rate, - } diff --git a/rose/server/game/__init__.py b/rose/server/game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rose/server/game/logic.py b/rose/server/game/logic.py new file mode 100644 index 00000000..d8dbabf3 --- /dev/null +++ b/rose/server/game/logic.py @@ -0,0 +1,152 @@ +import asyncio +import logging +import random +import aiohttp + +from common import config + +from game import score, net +from game.player import Player +from game.track import Track + +log = logging.getLogger("logic") + + +async def initialize_game(state): + """Reset game settings and return re-initialized track and players.""" + state["reset"] = None + state["running"] = 0 + state["timeleft"] = config.game_duration + track = initialize_track(state["track_type"] != "same") + players = await initialize_players(state["drivers"]) + return track, players + + +def initialize_track(is_track_random): + """ + Initialize and return a new track. + + Args: + is_track_random (bool): If False, the track will have the same obstacles for both players. + If True, obstacles will be randomized. + + Returns: + Track: An initialized track object. + """ + track = Track(is_track_random) + track.reset() + return track + + +async def initialize_players(drivers): + """ + Asynchronously initialize players from a list of driver URLs. + + Args: + drivers (list): List of driver URLs to initialize players from. + players (list): List of Player objects. + """ + + # Init players + players = [] + + if not drivers: + return players + + base_color = random.randint(0, 3) + + async with aiohttp.ClientSession() as session: + for index, driver in enumerate(drivers): + try: + async with session.get(driver) as info: + info_data = await info.json() + player_name = info_data.get("info", {}).get("name") + player_car = (base_color + index) % 3 + player_lane = index + player = Player(player_name, player_car, player_lane) + player.reset() + player.URL = driver + players.append(player) + except Exception as e: + log.error(f"error: {e}") + + return players + + +async def game_loop(state, active_websockets): + """ + Asynchronously execute the game loop, using provided state and active websockets. + + Args: + state (dict): Dictionary containing game state data (rate, running status, time left, etc.). + active_websockets (set): A set of active websocket connections for communication. + + Returns: + None + """ + + # Initialize or reset the game, set up track and players + track, players = await initialize_game(state) + + # Begin the main game loop + while True: + # Check if the game needs a reset (based on state) + if state["reset"] == 1: + track, players = await initialize_game(state) + + # Stop game if timeleft is zero + if state["timeleft"] < 1: + state["running"] = 0 + + # Check if the game is currently running and there's time left to play + if state["running"] == 1: + # Start executing a step in the game + task = asyncio.create_task( + game_step(state, players, track, active_websockets) + ) + + # Pause the game loop for a specified duration, based on the rate defined in the state + await asyncio.sleep(1 / state["rate"]) + + # If for some reason the game step hasn't finished executing, cancel it + if not task.done(): + task.cancel() + + # If the game is not running (e.g., paused or finished) + else: + # Update all connected clients (via websockets) with the current game state + await net.update_websockets(False, state, players, track, active_websockets) + + await asyncio.sleep(1) + + +async def game_step(state, players, track, active_websockets): + """ + Execute a game step: Update the track, fetch drivers' actions, process actions, and update websockets. + + Args: + state (dict): Dictionary containing game state data (rate, running status, etc.). + players (list): List of Player objects. + track (Track): the game track. + active_websockets (Any): Active websockets for communication (assuming a suitable data structure). + """ + + try: + # Fetch players actions using an asynchronous HTTP session + await net.fetch_drivers_actions(players, track.matrix()) + + # Update track + track.update() + + # Process the actions of the players + score.process(players, track) + + # Send data to all WebSocket connections + await net.update_websockets(True, state, players, track, active_websockets) + + # Progress the game's timer + state["timeleft"] -= 1 + + except asyncio.CancelledError: + log.info("Game step was canceled!") + raise diff --git a/rose/server/game/net.py b/rose/server/game/net.py new file mode 100644 index 00000000..64c65252 --- /dev/null +++ b/rose/server/game/net.py @@ -0,0 +1,113 @@ +import asyncio +import aiohttp +import json +import time +import logging + + +log = logging.getLogger("net") + + +async def fetch_drivers_actions(players, track_matrix): + """ + Asynchronously fetch actions for each player. + + Args: + players (list): List of Player objects. + track_matrix (list of list): The matrix representation a 2D array of the track. + + Returns: + list: List of player actions fetched. + """ + async with aiohttp.ClientSession() as session: + await asyncio.gather( + *(fetch_driver_action(session, player, track_matrix) for player in players), + return_exceptions=True, + ) + + +async def fetch_driver_action(session, player, track_matrix): + """ + Asynchronously fetch content from a URL using a POST request and return the parsed JSON + along with the time it took to get the response. + + Args: + session (aiohttp.ClientSession): An active ClientSession for making the request. + player (Player): The player object containing name, URL, and position. + track_matrix (list of list): The matrix representation a 2D array of the track. + + Returns: + tuple: A tuple containing: + - dict or None: The JSON-decoded response from the server or None if an error occurred. + - float: The time taken (in seconds) to get the response. + - str or None: The error message, if any. None if no error occurred. + """ + start_time = time.time() + + try: + response_data = await send_post_request(session, player, track_matrix) + return process_driver_response(player, response_data, start_time) + + except Exception as e: + elapsed_time = time.time() - start_time + player.response_time = elapsed_time + error_msg = f"Error during POST request {e}" + log.error(error_msg) + + player.action = None + player.httperror = "Error POST to driver" + + return None, elapsed_time + + +async def send_post_request(session, player, track_matrix): + data = {"info": {"car": {"x": player.x, "y": player.y}}, "track": track_matrix} + + async with session.post(player.URL, data=json.dumps(data).encode()) as response: + return await response.json() + + +def process_driver_response(player, response_data, start_time): + elapsed_time = time.time() - start_time + player.response_time = elapsed_time + + try: + player.name = response_data.get("info").get("name") + player.action = response_data.get("info").get("action") + player.httperror = None + return response_data, elapsed_time + + except Exception as e: + error_msg = f"Post to driver error {e}" + log.error(error_msg) + + player.action = None + player.httperror = error_msg + + return None, elapsed_time + + +async def update_websockets(started, state, players, track, active_websockets): + """Generate game state data and send it to all active websockets.""" + data = { + "action": "update", + "payload": { + "started": started, + "rate": state["rate"], + "timeleft": state["timeleft"], + "players": [player.state() for player in players], + "track": track.state(), + }, + } + + await send_to_all_websockets(data, active_websockets) + + +async def send_to_all_websockets(data, active_websockets): + """Send the given data to all active websocket connections.""" + json_encoded_data = json.dumps(data) + for ws in active_websockets: + try: + await ws.send_str(json_encoded_data) + except Exception as e: + log.error("Fail ws send", e) diff --git a/rose/server/player.py b/rose/server/game/player.py similarity index 73% rename from rose/server/player.py rename to rose/server/game/player.py index d3afd32e..b5459746 100644 --- a/rose/server/player.py +++ b/rose/server/game/player.py @@ -1,4 +1,4 @@ -from rose.common import actions, config +from common import config, actions class Player(object): @@ -24,29 +24,34 @@ def __init__(self, name, car, lane): """ self.name = name self.car = car + self.URL = "" self.lane = lane self.x = None self.y = None self.action = None + self.httperror = None self.response_time = None self.score = None + self.pickups = None + self.misses = None + self.hits = None + self.breaks = None + self.jumps = None + self.collisions = None self.reset() - # Game state interface - - def update(self): - """Go to the next game state""" - def reset(self): self.x = self.lane * config.cells_per_player + 1 # | |0| | |1 | | self.y = config.matrix_height // 3 * 2 # 1/3 of track self.action = actions.NONE self.response_time = None self.score = 0 - - def in_lane(self): - current_lane = self.x // config.cells_per_player - return current_lane == self.lane + self.pickups = 0 + self.misses = 0 + self.hits = 0 + self.breaks = 0 + self.collisions = 0 + self.jumps = 0 def __cmp__(self, other): x = self.score @@ -56,6 +61,10 @@ def __cmp__(self, other): def __lt__(self, other): return self.score < other.score + def in_lane(self): + current_lane = self.x // config.cells_per_player + return current_lane == self.lane + def state(self): """Return read only serialize-able state for sending to client""" return { @@ -63,6 +72,15 @@ def state(self): "car": self.car, "x": self.x, "y": self.y, + "action": self.action, + "response_time": self.response_time, + "error": self.httperror, "lane": self.lane, "score": self.score, + "pickups": self.pickups, + "misses": self.misses, + "hits": self.hits, + "breaks": self.breaks, + "jumps": self.jumps, + "collisions": self.collisions, } diff --git a/rose/server/score.py b/rose/server/game/score.py similarity index 94% rename from rose/server/score.py rename to rose/server/game/score.py index 136e4654..58358ca2 100644 --- a/rose/server/score.py +++ b/rose/server/game/score.py @@ -1,7 +1,7 @@ """ Score logic """ import logging -from rose.common import actions, config, obstacles +from common import config, actions, obstacles log = logging.getLogger("score") @@ -17,10 +17,11 @@ def process(players, track): # First handle right and left actions, since they may change in_lane # status, used for resolving collisions. - for player in players.values(): + for player in players: if player.action == actions.LEFT: if player.x > 0: player.x -= 1 + log.debug( "player %s moved left to %d,%d", player.name, @@ -30,6 +31,7 @@ def process(players, track): elif player.action == actions.RIGHT: if player.x < config.matrix_width - 1: player.x += 1 + log.debug( "player %s moved right to %d,%d", player.name, @@ -41,7 +43,7 @@ def process(players, track): # the ones out of lane, this ensure the car in lane will have # priority when picking pinguins and in case of collisions. - sorted_players = sorted(players.values(), key=lambda p: 0 if p.in_lane() else 1) + sorted_players = sorted(players, key=lambda p: 0 if p.in_lane() else 1) positions = set() # Now handle obstacles, preferring players in their own lane. @@ -52,6 +54,7 @@ def process(players, track): if obstacle == obstacles.NONE: # Move forward, leaving the obstacle on the track. player.score += config.score_move_forward + log.debug( "player %s hit no obstacle: got %d points", player.name, @@ -63,6 +66,8 @@ def process(players, track): track.clear(player.x, player.y) player.y += 1 player.score += config.score_move_backward + player.hits += 1 + log.debug( "player %s hit %s: lost %d points, moved back to %d,%d", player.name, @@ -77,6 +82,8 @@ def process(players, track): # Move forward leaving the obstacle on the track points = config.score_move_forward + config.score_jump player.score += points + player.jumps += 1 + log.debug( "player %s avoided %s: got %d points", player.name, @@ -88,6 +95,8 @@ def process(players, track): track.clear(player.x, player.y) player.y += 1 player.score += config.score_move_backward + player.hits += 1 + log.debug( "player %s hit %s: lost %d points, moved back to %d,%d", player.name, @@ -102,6 +111,8 @@ def process(players, track): # Move forward leaving the obstacle on the track points = config.score_move_forward + config.score_brake player.score += points + player.breaks += 1 + log.debug( "player %s avoided %s: got %d points", player.name, @@ -113,6 +124,8 @@ def process(players, track): track.clear(player.x, player.y) player.y += 1 player.score += config.score_move_backward + player.hits += 1 + log.debug( "player %s hit %s: lost %d points, moved back to %d,%d", player.name, @@ -128,6 +141,8 @@ def process(players, track): track.clear(player.x, player.y) points = config.score_move_forward + config.score_pickup player.score += points + player.pickups += 1 + log.debug( "player %s picked up %s: got %d points", player.name, @@ -137,6 +152,7 @@ def process(players, track): else: # Move forward leaving the obstacle on the track player.score += config.score_move_forward + log.debug("player %s missed %s", player.name, obstacle) # Here we can end the game when player gets out of @@ -155,6 +171,7 @@ def process(players, track): player.x -= 1 elif player.x < config.matrix_width - 1: player.x += 1 + log.debug( "player %s collision at %d,%d: lost %d points, " "moved to %d,%d", player.name, diff --git a/rose/server/game/server.py b/rose/server/game/server.py new file mode 100644 index 00000000..308f7fad --- /dev/null +++ b/rose/server/game/server.py @@ -0,0 +1,154 @@ +import json +import os + +import aiohttp +from aiohttp import web + +from common import config +from game import logic + +# Global active_websockets +# IMPORTANT - shared with game loop in game.py +active_websockets = set() + +# Global state +# IMPORTANT - shared with game loop in game.py +state = {"rate": None, "running": None, "reset": None, "drivers": [], "timeleft": None} + + +async def admin_handler(request): + """ + Handle admin requests to set the game rate. + + Args: + request (aiohttp.web.Request): The request object. + + Returns: + aiohttp.web.Response: A response indicating the new game rate or an error message. + """ + global state + + rate = request.rel_url.query.get("rate") + if rate: + try: + state["rate"] = float(rate) + except ValueError: + return web.Response(text="Invalid rate provided", status=400) + + running = request.rel_url.query.get("running") + if running: + try: + state["running"] = int(running) + except ValueError: + return web.Response(text="Invalid running provided", status=400) + + reset = request.rel_url.query.get("reset") + if reset: + try: + state["reset"] = int(reset) + except ValueError: + return web.Response(text="Invalid reset provided", status=400) + + # This expects the drivers to be passed as a comma-separated list in the query param + # e.g., ?drivers=http://localhost:8081/drv2,http://driver.com:8090/ + drivers = request.rel_url.query.get("drivers") + if drivers: + drivers_list = drivers.split(",") + state["drivers"] = drivers_list + state["running"] = 0 + state["reset"] = 1 + + return web.Response(text=json.dumps(state)) + + +async def websocket_handler(request): + """ + Handle WebSocket connections, echoing received messages with a prefix. + + Args: + request (aiohttp.web.Request): The request object. + + Returns: + aiohttp.web.WebSocketResponse: The WebSocket response object. + """ + ws = web.WebSocketResponse() + await ws.prepare(request) + + active_websockets.add(ws) + try: + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + response_message = f"Received: {msg.data}" + await ws.send_str(response_message) + elif msg.type == web.WSMsgType.ERROR: + print(f"WebSocket error: {ws.exception()}") + finally: + active_websockets.remove(ws) + await ws.close() + + return ws + + +async def run( + http_port, + listen_address, + initial_rate, + initial_running, + initial_drivers, + public, + theme, + track_type, +): + """ + Start the servers (HTTP and Websocket) and the game loop. + + Args: + http_port (int): The port to listen on for the HTTP server. + ws_port (int): The port to listen on for the Websocket server. + listen_address (str): The address for both servers to bind to. + initial_rate (float): The initial game rate in seconds. + running (bollean): The initial starting state of the game. + drivers (list of strings): list of driver URLs to use. + public (str): Path to the static files directory. + theme (str): Path to the static them resources directory. + track_type (str): Type of track can be "random" or "same". + """ + global state + + state["rate"] = initial_rate + state["running"] = 1 if initial_running else 0 + state["drivers"] = initial_drivers + state["timeleft"] = config.game_duration + state["track_type"] = track_type + + app = web.Application() + + # Add static files server using the 'public' arg + async def index_handler(request): + return web.FileResponse(os.path.join(public, "index.html")) + + # Add application routes + app.router.add_get("/", index_handler) + app.router.add_get("/ws", websocket_handler) + app.router.add_post("/admin", admin_handler) + + # Add public static routes + app.router.add_static("/res/", path=theme, name="theme") + app.router.add_static("/", path=public, name="static") + + runner = aiohttp.web.AppRunner(app) + await runner.setup() + site = aiohttp.web.TCPSite(runner, listen_address, http_port) + + # Start HTTP server + await site.start() + + print(f"Theme {theme}") + print(f"Track {track_type}") + print(f"Drivers {initial_drivers}") + print(f"Listen {listen_address}:{http_port}") + print(f"Server URL http://127.0.0.1:{http_port}") + + # Start game loop + # IMPORTANT: state and active_websockets are references, changes in this file will affect the game loop. + await logic.game_loop(state, active_websockets) diff --git a/rose/server/player_test.py b/rose/server/game/test_player.py similarity index 79% rename from rose/server/player_test.py rename to rose/server/game/test_player.py index 528af1e9..eb66d138 100644 --- a/rose/server/player_test.py +++ b/rose/server/game/test_player.py @@ -1,5 +1,5 @@ -from rose.common import actions, config -from . import player +from common import actions, config +from game import player def test_player_initialization(): @@ -66,15 +66,24 @@ def test_player_comparison(): def test_player_state(): - player1 = player.Player("John", 1, 1) + player1 = player.Player("John", 2, 1) expected_state = { - "name": player1.name, - "car": player1.car, - "x": player1.x, - "y": player1.y, - "lane": player1.lane, - "score": player1.score, + "name": "John", + "car": 2, + "x": 4, # 1 * config.cells_per_player + 1 + "y": config.matrix_height // 3 * 2, + "action": actions.NONE, + "response_time": None, + "error": None, + "lane": 1, + "score": 0, + "pickups": 0, + "misses": 0, + "hits": 0, + "breaks": 0, + "jumps": 0, + "collisions": 0, } assert player1.state() == expected_state diff --git a/rose/server/score_test.py b/rose/server/game/test_score.py similarity index 97% rename from rose/server/score_test.py rename to rose/server/game/test_score.py index 872e5646..e29648f4 100644 --- a/rose/server/score_test.py +++ b/rose/server/game/test_score.py @@ -1,7 +1,7 @@ -from rose.common import actions, config, obstacles -from . import track -from . import player -from . import score +from common import actions, config, obstacles +from game import track +from game import player +from game import score import pytest @@ -21,7 +21,7 @@ def setup_method(self, m): self.track.set(self.x, self.y, self.obstacle) def process(self): - score.process({self.player.name: self.player}, self.track) + score.process([self.player], self.track) def assert_score(self, score): assert self.player.x == self.x @@ -271,7 +271,7 @@ def setup_method(self, m): self.player2 = player.Player("B", car=0, lane=1) def process(self): - players = {self.player1.name: self.player1, self.player2.name: self.player2} + players = [self.player1, self.player2] score.process(players, self.track) def test_player_in_lane_wins(self): diff --git a/rose/server/track.py b/rose/server/game/track.py similarity index 75% rename from rose/server/track.py rename to rose/server/game/track.py index 308283db..33f2377d 100644 --- a/rose/server/track.py +++ b/rose/server/game/track.py @@ -1,10 +1,12 @@ import random -from rose.common import config, obstacles + +from common import config, obstacles class Track(object): - def __init__(self): + def __init__(self, is_track_random=False): self._matrix = None + self.is_track_random = is_track_random self.reset() # Game state interface @@ -23,6 +25,10 @@ def state(self): items.append({"name": obs, "x": x, "y": y}) return items + def matrix(self): + """Return the track matrix""" + return self._matrix + # Track interface def get(self, x, y): @@ -52,16 +58,24 @@ def _generate_row(self): obstacles, but in different cells if 'is_track_random' is True. Otherwise, the tracks will be identical. """ + + # Create initial empty row row = [obstacles.NONE] * config.matrix_width + + # Get a random obstacle obstacle = obstacles.get_random_obstacle() - if config.is_track_random: + + if self.is_track_random: for lane in range(config.max_players): - low = lane * config.cells_per_player - high = low + config.cells_per_player - cell = random.choice(range(low, high)) - row[cell] = obstacle + # Get a random cell for each player + cell = random.choice(range(0, config.cells_per_player)) + + row[cell + lane * config.cells_per_player] = obstacle else: + # Get a random cell, and use it for all players cell = random.choice(range(0, config.cells_per_player)) + for lane in range(config.max_players): row[cell + lane * config.cells_per_player] = obstacle + return row diff --git a/rose/server/main.py b/rose/server/main.py index 1f8774ba..e41ffcce 100644 --- a/rose/server/main.py +++ b/rose/server/main.py @@ -1,53 +1,71 @@ -import socket -import logging import argparse +import asyncio +import os +import logging -from twisted.internet import reactor -from twisted.web import server, static - -from autobahn.twisted.resource import WebSocketResource - -from rose.common import config -from . import game, net - -log = logging.getLogger("main") +from game import server def main(): - logging.basicConfig(level=logging.INFO, format=config.logger_format) - parser = argparse.ArgumentParser(description="ROSE Server") + script_directory = os.path.dirname(os.path.abspath(__file__)) + default_public_path = os.path.join(script_directory, "web") + default_theme_path = os.path.join(script_directory, "res") + + parser = argparse.ArgumentParser(description="Start the game engine.") + parser.add_argument( + "-p", "--port", type=int, default=8880, help="Port for HTTP server" + ) + parser.add_argument( + "--listen", default="127.0.0.1", help="Listening address for servers" + ) + parser.add_argument( + "--initial-rate", type=float, default=1.0, help="Initial game rate in seconds" + ) + parser.add_argument( + "-d", + "--drivers", + nargs="+", + help="List of driver URLs for the game engine to use", + ) + parser.add_argument( + "--running", + action="store_true", + help="Whether the game engine should start running immediately", + ) + parser.add_argument( + "--public", + default=default_public_path, + help="Path to the directory with static public files. If not provided, defaults to /public.", + ) parser.add_argument( - "--track_definition", "-t", - dest="track_definition", + "--track", + choices=["same", "random"], default="random", - choices=["random", "same"], - help="Definition of driver tracks: random or same." - "If not specified, random will be used.", + help="Choose the track type. Can be 'same' or 'random'.", + ) + parser.add_argument( + "--log", default="WARNING", help="Set the logging level. E.g. --log DEBUG" ) args = parser.parse_args() - """ - If the argument is 'same', the track will generate the obstacles in the - same place for both drivers, otherwise, the obstacles will be genrated in - random locations for each driver. - """ - if args.track_definition == "same": - config.is_track_random = False - else: - config.is_track_random = True - - log.info("starting server") - g = game.Game() - h = net.Hub(g) - reactor.listenTCP(config.game_port, net.PlayerFactory(h)) - root = static.File(config.web_root) - wsuri = "ws://%s:%s" % (socket.gethostname(), config.web_port) - watcher = net.WatcherFactory(wsuri, h) - root.putChild(b"ws", WebSocketResource(watcher)) - root.putChild(b"res", static.File(config.res_root)) - root.putChild(b"admin", net.WebAdmin(g)) - root.putChild(b"rpc2", net.CliAdmin(g)) - site = server.Site(root) - reactor.listenTCP(config.web_port, site) - reactor.run() + + logging.basicConfig(level=getattr(logging, args.log.upper())) + + loop = asyncio.get_event_loop() + loop.run_until_complete( + server.run( + args.port, + args.listen, + args.initial_rate, + args.running, + args.drivers, + args.public, + default_theme_path, + args.track, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/rose/server/net.py b/rose/server/net.py deleted file mode 100644 index c8e44b95..00000000 --- a/rose/server/net.py +++ /dev/null @@ -1,189 +0,0 @@ -import logging - -from twisted.internet import protocol -from twisted.protocols import basic -from twisted.web import http, resource, xmlrpc - -from autobahn.twisted.websocket import WebSocketServerFactory -from autobahn.twisted.websocket import WebSocketServerProtocol - -from rose.common import error, message - -log = logging.getLogger("net") - - -class Hub(object): - def __init__(self, game): - game.hub = self - self.game = game - self.clients = set() - - # PlayerProtocol hub interface - - def add_player(self, player): - # First add player, will raise if there are too many players or this - # name is already taken. - self.game.add_player(player.name) - self.clients.add(player) - - def remove_player(self, player): - if player in self.clients: - self.clients.remove(player) - self.game.remove_player(player.name) - - def drive_player(self, player, info): - self.game.drive_player(player.name, info) - - # WatcherProtocol hub interface - - def add_watcher(self, watcher): - self.clients.add(watcher) - msg = message.Message("update", self.game.state()) - watcher.send_message(str(msg)) - - def remove_watcher(self, watcher): - self.clients.discard(watcher) - - # Game hub interface - - def broadcast(self, msg): - data = str(msg) - for client in self.clients: - client.send_message(data) - - -class PlayerProtocol(basic.LineReceiver): - def __init__(self, hub): - self.hub = hub - self.name = None - - # LineReceiver interface - - def connectionLost(self, reason): - self.hub.remove_player(self) - - def lineReceived(self, line): - try: - msg = message.parse(line) - self.dispatch(msg) - except error.Error as e: - log.warning("Error handling message: %s", e) - msg = message.Message("error", {"message": str(e)}) - self.sendLine(str(msg).encode("utf-8")) - self.transport.loseConnection() - - # Hub client interface - - def send_message(self, data): - self.sendLine(data.encode("utf-8")) - - # Disaptching messages - - def dispatch(self, msg): - if self.name is None: - # New player - if msg.action != "join": - raise error.ActionForbidden(msg.action) - if "name" not in msg.payload: - raise error.InvalidMessage("name required") - self.name = msg.payload["name"] - self.hub.add_player(self) - else: - # Registered player - if msg.action == "drive": - self.hub.drive_player(self, msg.payload) - else: - raise error.ActionForbidden(msg.action) - - -class PlayerFactory(protocol.ServerFactory): - def __init__(self, hub): - self.hub = hub - - def buildProtocol(self, addr): - p = PlayerProtocol(self.hub) - p.factory = self - return p - - -class WatcherProtocol(WebSocketServerProtocol): - def __init__(self, hub): - self.hub = hub - WebSocketServerProtocol.__init__(self) - - # WebSocketServerProtocol interface - - def onConnect(self, request): - log.info("watcher connected from %s", request) - - def onOpen(self): - self.hub.add_watcher(self) - - def onClose(self, wasClean, code, reason): - log.info( - "watcher closed (wasClean=%s, code=%s, reason=%s)", wasClean, code, reason - ) - self.hub.remove_watcher(self) - - # Hub client interface - - def send_message(self, data): - self.sendMessage(data.encode("utf-8"), False) - - -class WatcherFactory(WebSocketServerFactory): - def __init__(self, url, hub): - self.hub = hub - WebSocketServerFactory.__init__(self, url) - - def buildProtocol(self, addr): - p = WatcherProtocol(self.hub) - p.factory = self - return p - - -class CliAdmin(xmlrpc.XMLRPC): - def __init__(self, game): - self.game = game - xmlrpc.XMLRPC.__init__(self, allowNone=True) - - def xmlrpc_start(self): - try: - self.game.start() - except error.GameAlreadyStarted as e: - raise xmlrpc.Fault(1, str(e)) - - def xmlrpc_stop(self): - try: - self.game.stop() - except error.GameNotStarted as e: - raise xmlrpc.Fault(1, str(e)) - - def xmlrpc_set_rate(self, rate): - self.game.rate = rate - - -class WebAdmin(resource.Resource): - def __init__(self, game): - self.game = game - resource.Resource.__init__(self) - - def render_POST(self, request): - if b"running" in request.args: - value = request.args[b"running"][0] - if value == b"1": - self.game.start() - elif value == b"0": - if self.game.started: - self.game.stop() - else: - request.setResponseCode(http.BAD_REQUEST) - return b"Invalid running value %r, expected (1, 0)" % value - if b"rate" in request.args: - value = request.args[b"rate"][0] - try: - self.game.rate = float(value) - except ValueError: - request.setResponseCode(http.BAD_REQUEST) - return b"Invalid rate value %r, expected number" % value - return b"" diff --git a/rose/server/pytest.ini b/rose/server/pytest.ini new file mode 100644 index 00000000..e6da1935 --- /dev/null +++ b/rose/server/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -vv -rxs --timeout 10 --cov . diff --git a/rose/server/requirements-dev.txt b/rose/server/requirements-dev.txt new file mode 100644 index 00000000..df92181f --- /dev/null +++ b/rose/server/requirements-dev.txt @@ -0,0 +1,10 @@ +# requirements.txt + +flake8>=3.9.0 +coverage>=7.3.0 +radon>=6.0.0 +black>=23.7.0 +pytest +pytest-check-links +pytest-coverage +pytest-timeout \ No newline at end of file diff --git a/rose/server/requirements.txt b/rose/server/requirements.txt new file mode 100644 index 00000000..ff404d44 --- /dev/null +++ b/rose/server/requirements.txt @@ -0,0 +1,4 @@ +# requirements.txt + +requests>=2.28.0 +aiohttp>=3.8.0 diff --git a/rose/res/bg/bg_1.png b/rose/server/res/bg/bg_1.png similarity index 100% rename from rose/res/bg/bg_1.png rename to rose/server/res/bg/bg_1.png diff --git a/rose/res/bg/bg_2.png b/rose/server/res/bg/bg_2.png similarity index 100% rename from rose/res/bg/bg_2.png rename to rose/server/res/bg/bg_2.png diff --git a/rose/res/bg/bg_3.png b/rose/server/res/bg/bg_3.png similarity index 100% rename from rose/res/bg/bg_3.png rename to rose/server/res/bg/bg_3.png diff --git a/rose/res/cars/car1.png b/rose/server/res/cars/car1.png similarity index 100% rename from rose/res/cars/car1.png rename to rose/server/res/cars/car1.png diff --git a/rose/res/cars/car2.png b/rose/server/res/cars/car2.png similarity index 100% rename from rose/res/cars/car2.png rename to rose/server/res/cars/car2.png diff --git a/rose/res/cars/car3.png b/rose/server/res/cars/car3.png similarity index 100% rename from rose/res/cars/car3.png rename to rose/server/res/cars/car3.png diff --git a/rose/res/cars/car4.png b/rose/server/res/cars/car4.png similarity index 100% rename from rose/res/cars/car4.png rename to rose/server/res/cars/car4.png diff --git a/rose/server/res/dashboard/dashboard.png b/rose/server/res/dashboard/dashboard.png new file mode 100644 index 00000000..828eea99 Binary files /dev/null and b/rose/server/res/dashboard/dashboard.png differ diff --git a/rose/res/end/final_flag.png b/rose/server/res/end/final_flag.png similarity index 100% rename from rose/res/end/final_flag.png rename to rose/server/res/end/final_flag.png diff --git a/rose/res/obstacles/barrier.png b/rose/server/res/obstacles/barrier.png similarity index 100% rename from rose/res/obstacles/barrier.png rename to rose/server/res/obstacles/barrier.png diff --git a/rose/res/obstacles/bike.png b/rose/server/res/obstacles/bike.png similarity index 100% rename from rose/res/obstacles/bike.png rename to rose/server/res/obstacles/bike.png diff --git a/rose/res/obstacles/crack.png b/rose/server/res/obstacles/crack.png similarity index 100% rename from rose/res/obstacles/crack.png rename to rose/server/res/obstacles/crack.png diff --git a/rose/res/obstacles/penguin.png b/rose/server/res/obstacles/penguin.png similarity index 100% rename from rose/res/obstacles/penguin.png rename to rose/server/res/obstacles/penguin.png diff --git a/rose/res/obstacles/trash.png b/rose/server/res/obstacles/trash.png similarity index 100% rename from rose/res/obstacles/trash.png rename to rose/server/res/obstacles/trash.png diff --git a/rose/res/obstacles/water.png b/rose/server/res/obstacles/water.png similarity index 100% rename from rose/res/obstacles/water.png rename to rose/server/res/obstacles/water.png diff --git a/rose/res/soundtrack/Nyan_Cat.ogg b/rose/server/res/soundtrack/Nyan_Cat.ogg similarity index 100% rename from rose/res/soundtrack/Nyan_Cat.ogg rename to rose/server/res/soundtrack/Nyan_Cat.ogg diff --git a/rose/server/rose-engine.yaml b/rose/server/rose-engine.yaml new file mode 100644 index 00000000..74d4a5ba --- /dev/null +++ b/rose/server/rose-engine.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rose-server-deployment + labels: + app: rose-server +spec: + replicas: 1 + selector: + matchLabels: + app: rose-server + template: + metadata: + labels: + app: rose-server + spec: + containers: + - name: rose-server-container + image: quay.io/rose/rose-server:latest # Modify with your Docker image name and tag. + ports: + - containerPort: 8880 + +--- + +apiVersion: v1 +kind: Service +metadata: + name: rose-server-service +spec: + selector: + app: rose-server + ports: + - name: http + protocol: TCP + port: 8880 + targetPort: 8880 + type: LoadBalancer # run outside the cluster: kubectl port-forward service/rose-server-service 8880:8880 . diff --git a/rose/server/web/game.css b/rose/server/web/game.css new file mode 100755 index 00000000..5159f0ab --- /dev/null +++ b/rose/server/web/game.css @@ -0,0 +1,198 @@ +body { + font-family: sans-serif; + background: #2c3e50; /* darker background */ + color: #ecf0f1; /* lighter text color for contrast */ + margin: 0; +} + +#content { + margin: 0 auto; + width: 910px; +} + +#control { + position: relative; + margin: 10px 0; + font-weight: bold; +} + +#control button { + font-family: helvetica, sans-serif; + font-size: 20px; + line-height: 24px; + font-weight: bold; + border: 1px solid #34495e; /* updated border color */ + color: #ecf0f1; /* lighter text */ + background-color: #3498db; /* blue tinted background */ + border-radius: 5px; /* rounded borders for modern look */ + transition: background-color 0.2s; +} + +#control button:disabled, #control button:disabled:hover { + color: #666; + background-color: #2980b9; /* slightly darker for disabled state */ +} + +#control button:hover { + background-color: #2980b9; /* darker hover effect */ +} + +/* other controls */ +#run, #stop, #music_ctl, #reset { + width: 120px; + height: 50px; + margin: 0; +} + +#rate_ctl { + position: absolute; + top: 0; + right: 0; +} + +#rate_ctl label { + margin: 0 10px; +} + +#rate_ctl .group { + display: inline-block; + font-size: 0; + border: 1px solid #34495e; /* border color to match theme */ +} + +#rate_ctl button { + height: 50px; + padding: 0; + margin: 0; +} + +#inc_rate, #dec_rate { + width: 50px; + border: 0; +} + +#cur_rate { + width: 120px; + border-top: 0; + border-bottom: 0; +} + +#players { + background-image: url('../res/dashboard/dashboard.png'); + width: 910px; + height: 150px; + font-weight: bold; + font-family: sans-serif; + color: rgb(153, 153, 153); + position: relative; +} + +.player { + width: 350px; + height: 100px; + top: 20px; + position: absolute; +} + +#left.player { + left: 10px; +} + +#right.player { + right: 10px; +} + +.player .name { + font-size: 30px; + width: 100%; + height: 50px; + text-align: center; + position: absolute; + overflow: hidden; + top: 7px; + color: #ecf0f1; /* text color to match theme */ +} + +.score { + width: 100%; + text-align: center; + position: absolute; + font-size: 40px; + top: 50px; + color: #ecf0f1; /* text color to match theme */ +} + +#time_left { + width: 105px; + font-size: 50px; + text-align: center; + position: absolute; + top: 40px; + left: 400px; + color: #ecf0f1; /* text color to match theme */ +} + +#game { + border: 1px #34495e solid; /* border color to match theme */ +} + +#settings-link { + display: block; + position: absolute; + top: 10px; + width: 70px; + right: 10px; + font-weight: bold; + color: #ecf0f1; + text-decoration: none; + border: 1px solid #34495e; + padding: 8px 15px; + border-radius: 5px; + background-color: #3498db; + transition: background-color 0.2s; +} + +#settings-link:hover { + background-color: #2980b9; /* darker hover effect */ +} + +#info-btn { + display: block; + position: absolute; + top: 55px; + width: 70px; + right: 10px; + font-weight: bold; + color: #ecf0f1; + text-decoration: none; + border: 1px solid #34495e; + padding: 8px 15px; + border-radius: 5px; + background-color: #3498db; + transition: background-color 0.2s; +} + +#info-btn:hover { + background-color: #2980b9; /* darker hover effect */ +} + +#info-panel { + position: absolute; + z-index: 1000; + left: 0; + top: 0; + width: 250px; /* Adjust as necessary */ + height: 100%; + background-color: rgba(0, 0, 0, 0.6); /* Semi-transparent black */ + color: white; + padding: 20px; + overflow-y: auto; /* For potential scrolling */ +} + +.hidden { + display: none; +} + +.error-text { + color: red; +} diff --git a/rose/web/game.js b/rose/server/web/game.js old mode 100644 new mode 100755 similarity index 71% rename from rose/web/game.js rename to rose/server/web/game.js index 9c1f2006..b10a3c70 --- a/rose/web/game.js +++ b/rose/server/web/game.js @@ -9,12 +9,19 @@ class App { obstacles = null; cars = null; finish_line = null; + infoUpdater = null; ready() { + // Start loading game images. + document.querySelector("#left.player .name").textContent = "Loading ..."; + this.controller = new Controller(); this.rate = new Rate([0.5, 1.0, 2.0, 5.0, 10.0]); const imageLoader = new ImageLoader(() => { this.client = new Client(this.onmessage.bind(this), 2000); + + // Finish loading game images. + document.querySelector("#left.player .name").textContent = ""; }); this.context = document.querySelector("#game").getContext("2d"); @@ -23,6 +30,7 @@ class App { this.obstacles = new Obstacles(imageLoader); this.cars = new Cars(imageLoader); this.finish_line = new FinishLine(imageLoader); + this.infoUpdater = new Information(); this.sound = new Sound("res/soundtrack/Nyan_Cat.ogg"); } @@ -37,12 +45,13 @@ class App { // Update this.controller.update(state); - this.rate.update(state.rate); + this.rate.update(state); this.dashboard.update(state); this.track.update(state); this.obstacles.update(state); this.cars.update(state); this.finish_line.update(state); + this.infoUpdater.update(state); // Draw this.dashboard.draw(this.context); @@ -62,7 +71,7 @@ class Client { } connect() { - var wsuri = "ws://" + window.location.hostname + ":8880/ws"; + var wsuri = `ws://${window.location.host}/ws`; console.log("Connecting to " + wsuri); this.socket = new WebSocket(wsuri); this.socket.onopen = (e) => { @@ -73,10 +82,9 @@ class Client { } onclose(e) { - console.log("Disconnected wasClean=" + e.wasClean + ", code=" + - e.code + ", reason='" + e.reason + "')"); + console.log(`Disconnected wasClean=${e.wasClean}, code=${e.code}, reason='${e.reason}')`); this.socket = null; - console.log("Reconnecting in " + this.reconnect_msec + " milliseconds"); + console.log(`Reconnecting in ${this.reconnect_msec} milliseconds`); setTimeout(this.connect.bind(this), this.reconnect_msec); } } @@ -87,61 +95,93 @@ class Controller { } initializeEvents() { - document.querySelector("#start").addEventListener("click", event => { + document.querySelector("#run").addEventListener("click", event => { event.preventDefault(); - this.start(); + this.run(); }); document.querySelector("#stop").addEventListener("click", event => { event.preventDefault(); this.stop(); }); + + document.querySelector("#reset").addEventListener("click", event => { + event.preventDefault(); + this.reset(); + }); + + document.getElementById('info-btn').addEventListener('click', function(e) { + e.preventDefault(); // Prevent default behavior of the anchor + + var infoPanel = document.getElementById('info-panel'); + + if (infoPanel.classList.contains('hidden')) { + infoPanel.classList.remove('hidden'); + } else { + infoPanel.classList.add('hidden'); + } + }); } - start() { - var self = this; - self.disable(); + run() { + this.disable(); fetch("admin?running=1", {method: 'POST'}) .then(() => { console.log("starting"); }) .catch((e) => { - console.log("Error starting: " + e.toString()); + console.log(`Error starting: ${e.toString()}`); }) } stop() { - var self = this; - self.disable(); + this.disable(); + fetch("admin?running=0", {method: 'POST'}) .then(() => { console.log("stopping"); }) .catch((e) => { - console.log("Error stopping: " + e.toString()); + console.log(`Error stopping: ${e.toString()}`); + }) + } + + reset() { + this.disable(); + + fetch("admin?reset=1", {method: 'POST'}) + .then(() => { + console.log("reset"); + }) + .catch((e) => { + console.log(`Error reset: ${e.toString()}`); }) } update(state) { - if(state.players.length == 0){ - document.querySelector("#info").textContent = ("No players connected") - document.querySelector("#start").setAttribute("disabled", "disabled"); + if(state.players.length == 0) { + document.querySelector("#run").setAttribute("disabled", "disabled"); document.querySelector("#stop").setAttribute("disabled", "disabled"); - } - else if (state.started) { + } else if (state.started) { document.querySelector("#info").textContent = ("") - document.querySelector("#start").setAttribute("disabled", "disabled"); + document.querySelector("#run").setAttribute("disabled", "disabled"); document.querySelector("#stop").removeAttribute("disabled"); + document.querySelector("#reset").setAttribute("disabled", "disabled"); } else { document.querySelector("#info").textContent = ("") - document.querySelector("#start").removeAttribute("disabled"); + document.querySelector("#run").removeAttribute("disabled"); document.querySelector("#stop").setAttribute("disabled", "disabled"); + document.querySelector("#reset").removeAttribute("disabled"); + } + + if (state.timeleft == 0) { + document.querySelector("#run").setAttribute("disabled", "disabled"); } } disable() { - document.querySelector("#start").setAttribute("disabled", "disabled"); + document.querySelector("#run").setAttribute("disabled", "disabled"); document.querySelector("#stop").setAttribute("disabled", "disabled"); } } @@ -153,7 +193,6 @@ class Rate { this.initializeEvents(); } - initializeEvents() { document.querySelector("#dec_rate").addEventListener("click", event => { event.preventDefault(); @@ -171,9 +210,9 @@ class Rate { }); } - update(rate) { - this.rate = rate; - document.querySelector("#cur_rate").textContent = (rate + " FPS"); + update(state) { + this.rate = state.rate; + document.querySelector("#cur_rate").textContent = (state.rate + " FPS"); this.validate(); } @@ -196,8 +235,7 @@ class Rate { } decrease() { - var i; - for (i = this.values.length - 1; i >= 0; i--) { + for (let i = this.values.length - 1; i >= 0; i--) { if (this.values[i] < this.rate) { this.post(this.values[i]); break; @@ -206,8 +244,7 @@ class Rate { } increase() { - var i; - for (i = 0; i < this.values.length; i++) { + for (let i = 0; i < this.values.length; i++) { if (this.values[i] > this.rate) { this.post(this.values[i]); break; @@ -216,14 +253,14 @@ class Rate { } post(value) { - var self = this; - self.disable(); + this.disable(); + fetch(`admin?rate=${value}`, {method: 'POST'}) .then(() => { - self.update(value); + this.update({rate: value}); }) .catch((e) => { - self.validate(); + this.validate(); console.log("Error changing rate: " + e.toString()); }) } @@ -381,6 +418,51 @@ class Track { } } +class Information { + constructor() { + this.infoElement = document.getElementById('info-text'); + } + + update(state) { + if (!state.players) { + return; + } + + let infoText = ""; + + if(state.players.length == 0) { + infoText += 'No players connected.
'; + } + + state.players.forEach(player => { + const formattedResponseTime = (player.response_time * 1000.0).toFixed(2); + + infoText += `Name: ${player.name}
`; + infoText += `Response time: ${formattedResponseTime}ms
`; + + // Check if error is not empty and conditionally apply the CSS class + if (player.error && player.error.trim() !== "") { + infoText += `Response err: ${player.error}
`; + } else { + infoText += `Response err: ${player.error}
`; + } + + infoText += '
'; + infoText += `Pinguins: ${player.pickups}
`; + infoText += `Breaks: ${player.breaks}
`; + infoText += `Jumps: ${player.jumps}
`; + infoText += '
'; + infoText += `Missed: ${player.misses}
`; + infoText += `Crashes: ${player.hits}
`; + infoText += `Collisions: ${player.collisions}
`; + + infoText += '

'; + }); + + this.infoElement.innerHTML = infoText; + } +} + class ImageLoader { constructor(done) { this.loading = 0; diff --git a/rose/web/index.html b/rose/server/web/index.html old mode 100644 new mode 100755 similarity index 78% rename from rose/web/index.html rename to rose/server/web/index.html index d1e4b775..7f023aef --- a/rose/web/index.html +++ b/rose/server/web/index.html @@ -9,11 +9,20 @@
+ Settings + Info + + +
- + +
diff --git a/rose/server/web/settings/componentUtils.js b/rose/server/web/settings/componentUtils.js new file mode 100644 index 00000000..784a46d4 --- /dev/null +++ b/rose/server/web/settings/componentUtils.js @@ -0,0 +1,22 @@ +export async function loadTemplate(path) { + try { + const response = await fetch(path); + return await response.text(); + } catch (error) { + console.error('Error loading template:', error); + } +} + +export async function loadStylesheet(path) { + try { + const response = await fetch(path); + const cssText = await response.text(); + + const sheet = new CSSStyleSheet(); + sheet.replaceSync(cssText); + + return sheet; + } catch (error) { + console.error('Error loading stylesheet:', error); + } +} diff --git a/rose/server/web/settings/index.css b/rose/server/web/settings/index.css new file mode 100644 index 00000000..60024806 --- /dev/null +++ b/rose/server/web/settings/index.css @@ -0,0 +1,19 @@ +body.dark-theme { + background-color: #2c3e50; + font-family: 'Sans-serif'; + color: #ecf0f1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +} + +.heading { + font-size: 2em; + margin-bottom: 20px; +} + +.form { + width: 60%; +} diff --git a/rose/server/web/settings/index.html b/rose/server/web/settings/index.html new file mode 100644 index 00000000..cd29f0ef --- /dev/null +++ b/rose/server/web/settings/index.html @@ -0,0 +1,16 @@ + + + + + + Set Drivers + + + +

Set Game Drivers

+ + + + + + diff --git a/rose/server/web/settings/settings-form.css b/rose/server/web/settings/settings-form.css new file mode 100644 index 00000000..a6fdfb60 --- /dev/null +++ b/rose/server/web/settings/settings-form.css @@ -0,0 +1,72 @@ +.form { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +.form-group { + width: 100%; + display: flex; + justify-content: space-between; + margin-bottom: 15px; +} + +.label { + font-size: 1.2em; +} + +.input-field { + flex-grow: 1; + margin-left: 10px; + padding: 10px; + font-size: 1em; + border-radius: 5px; + border: 1px solid #34495e; + background-color: #ecf0f1; + color: #2c3e50; +} + +.submit-button { + background-color: #3498db; + color: #ecf0f1; + font-size: 1.2em; + padding: 15px 30px; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.2s; +} + +.submit-button:hover { + background-color: #2980b9; +} + +.cancel-button { + background-color: #e74c3c; + color: #fff; + font-size: 1.2em; + padding: 15px 30px; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.2s; +} + +.cancel-button:hover { + background-color: #c0392b; +} + +.form-group { + display: flex; + justify-content: center; +} + +.submit-button, .cancel-button { + margin: 0 5px; +} + +.error-message { + color: red; + margin-top: 10px; +} diff --git a/rose/server/web/settings/settings-form.html b/rose/server/web/settings/settings-form.html new file mode 100644 index 00000000..33484de8 --- /dev/null +++ b/rose/server/web/settings/settings-form.html @@ -0,0 +1,15 @@ + +
+ + +
+
+ + +
+
+ + +
+ +
diff --git a/rose/server/web/settings/settings-form.js b/rose/server/web/settings/settings-form.js new file mode 100644 index 00000000..5369e54f --- /dev/null +++ b/rose/server/web/settings/settings-form.js @@ -0,0 +1,70 @@ +import { loadTemplate, loadStylesheet } from './componentUtils.js'; + +const [innerHTML, sheet] = await Promise.all([ + loadTemplate('./settings-form.html'), + loadStylesheet('./settings-form.css') +]); + +class SettingsForm extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot.innerHTML = innerHTML; + this.shadowRoot.adoptedStyleSheets = [sheet]; + + this.loadDrivers(); + this.shadowRoot.querySelector('#submit-button').addEventListener('click', this.sendDrivers.bind(this)); + this.shadowRoot.querySelector('#cancel-button').addEventListener('click', this.cancelSetting.bind(this)); + } + + displayError(message) { + const errorMessageElement = this.shadowRoot.getElementById('error-message'); + errorMessageElement.textContent = message; + } + + async loadDrivers() { + try { + const response = await fetch('/admin', { method: 'POST' }); + const data = await response.json(); + + if (data.drivers && data.drivers.length > 0) { + this.shadowRoot.getElementById('driver1').value = data.drivers[0] ?? ""; + this.shadowRoot.getElementById('driver2').value = data.drivers[1] ?? ""; + } + } catch (error) { + this.displayError('Error loading drivers: ' + error.message); + } + } + + async sendDrivers(event) { + event.preventDefault(); + + const driver1 = this.shadowRoot.getElementById('driver1').value; + const driver2 = this.shadowRoot.getElementById('driver2').value; + + const params = new URLSearchParams(); + params.append('drivers', `${driver1},${driver2}`); + + try { + const response = await fetch(`/admin?${params}`, { method: 'POST' }); + + if (response.ok) { + window.location.href = "/index.html"; + } else { + this.displayError('Error sending drivers: ' + response.statusText); + } + } catch (error) { + this.displayError('Error sending drivers: ' + error.message); + } + } + + cancelSetting(event) { + event.preventDefault(); + window.location.href = "/index.html"; + } +} + +customElements.define('settings-form', SettingsForm); diff --git a/rose/web/game.css b/rose/web/game.css deleted file mode 100644 index 3d676dd0..00000000 --- a/rose/web/game.css +++ /dev/null @@ -1,131 +0,0 @@ -body { - font-family: sans-serif; - background: #444; - color: #ccc; - margin: 0; -} - -#content { - margin: 0 auto; - width: 910px; -} - -#control { - position: relative; - margin: 10px 0; - font-weight: bold; -} - -#control button { - font-family: helvetica, sans-serif; - font-size: 20px; - line-height: 24px; - font-weight: bold; - border: 1px solid #666; - color: #ccc; - background-color: #333; -} - -#control button:disabled, #control button:disabled:hover { - color: #666; - background-color: #333; -} - -#control button:hover { - background-color: #222; -} - -#start, #stop, #music_ctl { - width: 120px; - padding: 8px 24px; - margin: 0; -} - -#rate_ctl { - position: absolute; - top: 0; - right: 0; -} - -#rate_ctl label { - margin: 0 10px; -} - -#rate_ctl .group { - display: inline-block; - font-size: 0; - border: 1px solid #666; -} - -#rate_ctl button { - height: 40px; - padding: 0; - margin: 0; -} - -#inc_rate, #dec_rate { - width: 50px; - border: 0; -} - -#cur_rate { - width: 120px; - border-top: 0; - border-bottom: 0; -} - -#players { - background-image: url('../res/dashboard/dashboard.png'); - width: 910px; - height: 150px; - font-weight: bold; - font-family: sans-serif; - color: rgb(153, 153, 153); - position: relative; -} - -.player { - width: 350px; - height: 100px; - top: 20px; - position: absolute; -} - -#left.player { - left: 10px; -} - -#right.player { - right: 10px; -} - -.player .name { - font-size: 30px; - width: 100%; - height: 50px; - text-align: center; - position: absolute; - overflow: hidden; - top: 7px; -} - -.score { - width: 100%; - text-align:center; - position: absolute; - font-size: 40px; - top: 50px; -} - -#time_left { - width: 105px; - font-size: 50px; - text-align:center; - position: absolute; - top: 40px; - left: 400px; -} - -#game { - border: 1px #666 solid; -} diff --git a/setup.py b/setup.py index 17d6b979..35cdfff0 100644 --- a/setup.py +++ b/setup.py @@ -1,31 +1,13 @@ -import subprocess from distutils.core import setup -from distutils.command import sdist - -class rose_sdist(sdist.sdist): - def run(self): - self.generate_files() - sdist.sdist.run(self) - - def generate_files(self): - with open("requirements.txt", "w") as f: - f.write("# Generated automatically from Pipfile - do not edit!\n") - f.flush() - subprocess.check_call(["pipenv", "lock", "--requirements"], stdout=f) - - -setup( - name="rose-project", - version="0.1", - license="GNU GPLv2+", - description="game", - packages=["rose", "rose.server", "rose.client", "rose.common"], - package_data={"rose": ["res/*/*.png"]}, - author="Yaniv Bronhaim", - author_email="ybronhei@redhat.com", - url="https://github.com/emesika/RaananaTiraProject", - scripts=["rose-client", "rose-server", "rose-admin"], - data_files=[("requirements.txt", ["requirements.txt"])], - cmdclass={"sdist": rose_sdist}, -) +setup(name='rose-project', + version='0.1', + license="GNU GPLv2+", + description="game", + packages=['rose', 'rose.server', 'rose.client', "rose-admin"], + package_data={'rose': ['res/*/*.png']}, + author='Yaniv Bronhaim', + author_email='ybronhei@redhat.com', + url="https://github.com/emesika/RaananaTiraProject", + scripts=["rose-client", "rose-server"], + data_files=[('requirements.txt', ['requirements.txt'])])