├── tests ├── __init__.py ├── orders │ ├── __init__.py │ ├── test_ship.py │ ├── test_galaxy.py │ └── test_planet.py ├── game_modes.py ├── factories.py ├── conftest.py ├── test_galaxy.py └── test_planet.py ├── pythonium ├── bots │ ├── __init__.py │ ├── pacific_player.py │ ├── random_walk.py │ └── standard_player.py ├── orders │ ├── __init__.py │ ├── core.py │ ├── planet.py │ ├── ship.py │ └── galaxy.py ├── debugger.py ├── font │ ├── JMH Typewriter.ttf │ ├── JMH Typewriter-Bold.ttf │ ├── JMH Typewriter-Thin.ttf │ └── JMH Typewriter-Black.ttf ├── validators.py ├── helpers.py ├── core.py ├── __init__.py ├── explosion.py ├── cfg.py ├── ship_type.py ├── logger.py ├── player.py ├── main.py ├── ship.py ├── vectors.py ├── renderer.py ├── galaxy.py ├── planet.py ├── game_modes.py ├── game.py └── metrics_collector.py ├── setup.cfg ├── MANIFEST.in ├── requirements.build.txt ├── font └── jmh_typewriter.ttf ├── requirements.txt ├── requirements.docs.txt ├── .flake8 ├── requirements.test.txt ├── docker-compose.develop.yml ├── Dockerfile ├── docker-compose.yml ├── Dockerfile.develop ├── bin └── pythonium ├── .coveragerc ├── docs ├── source │ ├── tutorial.rst │ ├── index.rst │ ├── api.rst │ ├── conf.py │ ├── about.rst │ └── tutorial │ │ ├── 01_first_player.rst │ │ ├── 03_random_walker.rst │ │ └── 02_understanding_the_unknown.rst ├── Makefile └── make.bat ├── pyproject.toml ├── .pre-commit-config.yaml ├── Makefile ├── setup.py ├── LICENSE ├── README.md ├── .gitignore ├── .github └── workflows │ └── ci.yml └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonium/bots/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonium/orders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/orders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include pythonium *.ttf 2 | -------------------------------------------------------------------------------- /requirements.build.txt: -------------------------------------------------------------------------------- 1 | Pillow==8.4.0 2 | matplotlib==3.4.3 3 | attrs==21.2.0 4 | ipdb==0.13.9 5 | -------------------------------------------------------------------------------- /font/jmh_typewriter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bgeninatti/pythonium/HEAD/font/jmh_typewriter.ttf -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.build.txt 2 | -r requirements.docs.txt 3 | -r requirements.test.txt 4 | -------------------------------------------------------------------------------- /pythonium/debugger.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def terminate(): 5 | os.system("kill -9 %d" % os.getpid()) 6 | -------------------------------------------------------------------------------- /pythonium/font/JMH Typewriter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bgeninatti/pythonium/HEAD/pythonium/font/JMH Typewriter.ttf -------------------------------------------------------------------------------- /requirements.docs.txt: -------------------------------------------------------------------------------- 1 | sphinx-autodoc-typehints==1.11.1 2 | sphinx-rtd-theme==0.5.0 3 | autodoc==0.5.0 4 | Sphinx==3.3.1 5 | -------------------------------------------------------------------------------- /pythonium/font/JMH Typewriter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bgeninatti/pythonium/HEAD/pythonium/font/JMH Typewriter-Bold.ttf -------------------------------------------------------------------------------- /pythonium/font/JMH Typewriter-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bgeninatti/pythonium/HEAD/pythonium/font/JMH Typewriter-Thin.ttf -------------------------------------------------------------------------------- /pythonium/font/JMH Typewriter-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bgeninatti/pythonium/HEAD/pythonium/font/JMH Typewriter-Black.ttf -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F403, F401 3 | max-line-length = 79 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | Faker==6.4.1 2 | factory-boy==3.2.0 3 | mockito==1.2.2 4 | pytest-mockito==0.0.4 5 | pytest-mock==3.6.1 6 | isort 7 | black 8 | -------------------------------------------------------------------------------- /docker-compose.develop.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | pserver: 4 | build: 5 | dockerfile: Dockerfile.develop 6 | volumes: 7 | - .:/usr/src/pythonium 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | WORKDIR /usr/src 4 | 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | RUN pip install --upgrade pip 8 | COPY . . 9 | RUN python setup.py install 10 | WORKDIR /usr/src/games 11 | -------------------------------------------------------------------------------- /pythonium/validators.py: -------------------------------------------------------------------------------- 1 | def number_between_zero_100(instance, attribute, value): 2 | return 0 <= value <= 100 3 | 4 | 5 | def is_valid_ratio(instance, attribute, value): 6 | return 0.0 <= value <= 1.0 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | pserver: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | image: pythonium 8 | volumes: 9 | - ./games:/usr/src/games 10 | -------------------------------------------------------------------------------- /Dockerfile.develop: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | WORKDIR /usr/src/pythonium 4 | 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | RUN pip install --upgrade pip 8 | COPY . . 9 | RUN python setup.py install 10 | RUN pip install -r requirements.txt 11 | -------------------------------------------------------------------------------- /bin/pythonium: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """Script to run the 'pythonium' utility.""" 4 | 5 | import os 6 | import sys 7 | 8 | from pythonium import main 9 | 10 | sys.path.insert(0, os.getcwd()) 11 | 12 | rc = main.go() 13 | 14 | sys.exit(rc) 15 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = pythonium 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | ignore_errors = True 12 | omit = 13 | tests/* 14 | -------------------------------------------------------------------------------- /docs/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _tutorial: 2 | 3 | Tutorial 4 | ======== 5 | 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | :caption: Contents: 10 | 11 | tutorial/01_first_player 12 | tutorial/02_understanding_the_unknown 13 | tutorial/03_random_walker 14 | -------------------------------------------------------------------------------- /pythonium/bots/pacific_player.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import attr 4 | 5 | from ..player import AbstractPlayer 6 | from ..ship import Ship 7 | from .standard_player import Player as StandardPlayer 8 | 9 | 10 | class Player(StandardPlayer): 11 | """ 12 | Same as :class:`standard_player.Player` but always build carriers 13 | """ 14 | 15 | name = "Nofight Ink." 16 | tenacity = 0 17 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Pythonium documentation master file, created by 2 | sphinx-quickstart on Wed Sep 23 17:22:47 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Pythonium 7 | ========== 8 | 9 | 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | about 16 | tutorial 17 | api 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.commitizen] 2 | name = "cz_conventional_commits" 3 | version = "0.2.0b0" 4 | version_files = [ 5 | "pythonium/__init__.py", 6 | "setup.py:version", 7 | "pyproject.toml:version" 8 | ] 9 | [tool.black] 10 | line-length = 79 11 | include = '\.pyi?$' 12 | exclude = ''' 13 | /( 14 | \.git 15 | | \.hg 16 | | \.mypy_cache 17 | | \.tox 18 | | \.venv 19 | | _build 20 | | buck-out 21 | | build 22 | | dist 23 | )/ 24 | ''' 25 | [tool.isort] 26 | multi_line_output = 3 27 | include_trailing_comma = true 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/ambv/black 5 | rev: 5446a92f0161e398de765bf9532d8c76c5652333 6 | hooks: 7 | - id: black 8 | language_version: python3.9 9 | - repo: https://gitlab.com/pycqa/flake8 10 | rev: 3.9.0 11 | hooks: 12 | - id: flake8 13 | - repo: https://github.com/timothycrosley/isort 14 | rev: 9042488762e10137fd535601d9f433f1e3920dad 15 | hooks: 16 | - id: isort 17 | -------------------------------------------------------------------------------- /pythonium/bots/random_walk.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from ..player import AbstractPlayer 4 | from ..ship import Ship 5 | 6 | 7 | class Player(AbstractPlayer): 8 | """ 9 | Move the available ships randomly 10 | """ 11 | 12 | name = "Walkers" 13 | 14 | def run_turn(self, galaxy, context): 15 | 16 | my_ships = galaxy.get_player_ships(self.name) 17 | 18 | for ship in my_ships: 19 | nearby_planets = galaxy.nearby_planets(ship.position, ship.speed) 20 | destination = random.choice(nearby_planets) 21 | ship.target = destination.position 22 | 23 | return galaxy 24 | -------------------------------------------------------------------------------- /pythonium/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import string 4 | 5 | from PIL import ImageFont 6 | 7 | from . import cfg 8 | 9 | BASE_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 10 | FONT_PATH = os.path.join(BASE_PATH, cfg.font_path) 11 | 12 | 13 | def load_font(fontsize): 14 | # `layout_engine=ImageFont.LAYOUT_BASIC` fix an OSError while writintg text with Pillow 15 | return ImageFont.truetype( 16 | FONT_PATH, fontsize, layout_engine=ImageFont.LAYOUT_BASIC 17 | ) 18 | 19 | 20 | def random_name(length): 21 | characters = string.ascii_uppercase + string.digits 22 | return "".join([random.choice(characters) for _ in range(length)]) 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /pythonium/core.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from abc import ABC 3 | from typing import NewType, Tuple 4 | 5 | import attr 6 | 7 | Position = NewType("Position", Tuple[int, int]) 8 | 9 | 10 | @attr.s 11 | class StellarThing(ABC): 12 | position: Position = attr.ib(converter=Position) 13 | """ 14 | Position of the `StellarThing` in (x, y) coordinates. 15 | """ 16 | 17 | id: uuid = attr.ib(factory=uuid.uuid4) 18 | """ 19 | Unique identifier for the `StellarThing` 20 | """ 21 | 22 | player: str = attr.ib(default=None) 23 | """ 24 | The owner of the ``StellarThing`` or ``None`` if no one owns it. 25 | """ 26 | 27 | def move(self, position: Position) -> None: 28 | raise NotImplementedError 29 | -------------------------------------------------------------------------------- /pythonium/__init__.py: -------------------------------------------------------------------------------- 1 | from . import bots, core, debugger, logger 2 | from .explosion import Explosion 3 | from .galaxy import Galaxy 4 | from .game import Game 5 | from .game_modes import ClassicMode, GameMode 6 | from .planet import Planet 7 | from .player import AbstractPlayer 8 | from .renderer import GifRenderer 9 | from .ship import Ship 10 | from .ship_type import ShipType 11 | from .vectors import Transfer 12 | 13 | __all__ = [ 14 | "__version__", 15 | "core", 16 | "Planet", 17 | "Galaxy", 18 | "Ship", 19 | "ShipType", 20 | "Explosion", 21 | "ClassicMode", 22 | "GameMode", 23 | "Game", 24 | "GifRenderer", 25 | "AbstractPlayer", 26 | "Transfer", 27 | "bots", 28 | "logger", 29 | "debugger", 30 | ] 31 | 32 | __version__ = "0.2.0b0" 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | COMPOSE = docker compose -f docker-compose.yml 2 | DEV_COMPOSE = $(COMPOSE) -f docker-compose.develop.yml 3 | ARGS = $(filter-out $@,$(MAKECMDGOALS)) 4 | 5 | help: 6 | @echo "play -- Open shell to play" 7 | @echo "test -- Run tests" 8 | @echo "devshell -- Open shell with development dependencies" 9 | 10 | play: 11 | $(COMPOSE) run --rm pserver /bin/bash 12 | 13 | devshell: 14 | $(DEV_COMPOSE) run --rm pserver /bin/bash 15 | 16 | test: 17 | $(DEV_COMPOSE) run --rm pserver pytest $(ARGS) 18 | 19 | check-imports: 20 | $(DEV_COMPOSE) run --rm pserver isort **/*.py 21 | 22 | check-style: 23 | $(DEV_COMPOSE) run --rm pserver black **/*.py 24 | 25 | build: 26 | $(COMPOSE) build 27 | 28 | build-dev: 29 | $(DEV_COMPOSE) build 30 | 31 | .PHONY: help play test devshell 32 | -------------------------------------------------------------------------------- /pythonium/orders/core.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | import attr 4 | 5 | from ..galaxy import Galaxy 6 | from ..planet import Planet 7 | from ..ship import Ship 8 | 9 | 10 | @attr.s() 11 | class ShipOrder(ABC): 12 | 13 | name: str = attr.ib(init=False) 14 | ship: Ship = attr.ib() 15 | 16 | def execute(self, galaxy) -> None: 17 | raise NotImplementedError 18 | 19 | 20 | @attr.s() 21 | class PlanetOrder(ABC): 22 | 23 | name: str = attr.ib(init=False) 24 | planet: Planet = attr.ib() 25 | 26 | def execute(self, galaxy) -> None: 27 | raise NotImplementedError 28 | 29 | 30 | @attr.s() 31 | class GalaxyOrder(ABC): 32 | 33 | name: str = attr.ib(init=False) 34 | galaxy: Galaxy = attr.ib() 35 | 36 | def execute(self) -> None: 37 | raise NotImplementedError 38 | -------------------------------------------------------------------------------- /pythonium/explosion.py: -------------------------------------------------------------------------------- 1 | from .ship import Ship 2 | 3 | 4 | # TODO: Use attrs in this class 5 | class Explosion: 6 | """ 7 | A ship that has exploded because of a conflict 8 | 9 | Provides some insights about which ship exploded and where 10 | 11 | :param ship: Destroyed ship 12 | :param ships_involved: Amount of ships involved in the combat 13 | :param total_attack: Total attack involved in the combat 14 | """ 15 | 16 | def __init__(self, ship: Ship, ships_involved: int, total_attack: int): 17 | self.ship = ship 18 | """ 19 | :class:`Ship` that has been destroyed. 20 | """ 21 | 22 | self.ships_involved = ships_involved 23 | """ 24 | Amount of ships involved in the combat. 25 | """ 26 | 27 | self.total_attack = total_attack 28 | """ 29 | Total attack involved in the combat. 30 | """ 31 | -------------------------------------------------------------------------------- /pythonium/cfg.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import os 4 | 5 | logger = logging.getLogger("game") 6 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 7 | 8 | # Move this settings to config.ini 9 | tenacity = 25 10 | happypoints_tolerance = 40 11 | optimal_temperature = 50 12 | max_population_rate = 0.1 13 | tolerable_taxes = 10 14 | ship_speed = 80 15 | planet_max_mines = 500 16 | taxes_collection_factor = 0.2 17 | max_clans_in_planet = 10000 18 | font_path = "font/jmh_typewriter.ttf" 19 | 20 | 21 | def load_config(namespace="DEFAULT", base_dir=BASE_DIR): 22 | config_file = os.path.join(base_dir, "config.ini") 23 | # This is for local deploy with a config file 24 | config = configparser.ConfigParser() 25 | config.read(config_file) 26 | logger.info( 27 | "Configuration loaded from config file", extra={"file": config_file} 28 | ) 29 | return config[namespace] 30 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | with open("requirements.build.txt", "r") as rf: 7 | requirements = list(rf.readlines()) 8 | 9 | setuptools.setup( 10 | name="pythonium", 11 | version="0.2.0b0", 12 | author="Bruno Geninatti", 13 | author_email="brunogeninatti@gmail.com", 14 | description="A space strategy algorithmic-game build in python", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | url="https://github.com/Bgeninatti/pythonium", 18 | packages=setuptools.find_packages(), 19 | classifiers=[ 20 | "Programming Language :: Python :: 3", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | ], 24 | install_requires=requirements, 25 | python_requires=">=3.7", 26 | scripts=["bin/pythonium"], 27 | include_package_data=True, 28 | data_files=[('font', ['font/jmh_typewriter.ttf'])], 29 | ) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bruno 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pythonium/ship_type.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from .vectors import Transfer 4 | 5 | 6 | @attr.s 7 | class ShipType: 8 | """ 9 | Defines the attributes of a ship that the player can built. 10 | """ 11 | 12 | name: str = attr.ib() 13 | """ 14 | A descriptive name for the ship type. i.e: 'war', 'carrier' 15 | """ 16 | 17 | cost: Transfer = attr.ib() 18 | """ 19 | :class:`Transfer` instance that represents the cost of a ship of this type. 20 | """ 21 | 22 | max_cargo: int = attr.ib() 23 | """ 24 | Max cargo of clans and pythonium (together) for this ship. 25 | 26 | In other words, you should always expect this: 27 | 28 | >>> ship.megacredits + ship.clans <= ship.max_cargo 29 | True 30 | 31 | Megacredits do not take up physical space in the ship so are not considered 32 | for ``max_cargo`` limit. 33 | """ 34 | 35 | max_mc: int = attr.ib() 36 | """ 37 | Max amount of megacredits that can be loaded to the ship. 38 | 39 | In other words, you should always expect this: 40 | 41 | >>> ship.megacredits <= ship.max_mc 42 | True 43 | """ 44 | 45 | attack: int = attr.ib() 46 | """ 47 | Attack of the ship. It will be used to resolve conflicts. 48 | """ 49 | 50 | speed: int = attr.ib(converter=int) 51 | """ 52 | Ship's speed in ly per turn 53 | """ 54 | 55 | def __repr__(self): 56 | return self.name 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pythonium 2 | 3 | Pythonium is a space turn-based strategy game where each player leads an alien race that aims to conquer the galaxy. 4 | 5 | You must explore planets to search and extract a valuable mineral: the `pythonium`. 6 | This precious material allows you to build cargo and combat spaceships, or mines to get 7 | more pythonium. 8 | 9 | Manage the economy on your planets, and collect taxes on your people to finance your 10 | constructions, but be careful! Keep your clans happy if you want to avoid unrest in your planets. 11 | 12 | Put your space helmet on, set your virtualenv, and start coding. 13 | 14 | Battle for pythonium is waiting for you! 15 | 16 | ## About the game 17 | 18 | Pythonium is a [programming game](https://en.wikipedia.org/wiki/Programming_game), which means you need to code a player to play. 19 | 20 | You can choose by playing alone in single-player mode, in multi-player mode against some of the available bots, or your friend's bots. 21 | 22 | The game generates several outputs that will help you to evaluate the performance of your player and make improvements on those. 23 | 24 | If you want to know more and learn how to play, check out the [documentation](https://pythonium.readthedocs.io/en/latest/). 25 | 26 | ## Acknowledge 27 | 28 | This game is strongly inspired by [VGA Planets](https://en.wikipedia.org/wiki/VGA_Planets), a space strategy war game from 1992 created by Tim Wisseman. 29 | 30 | The modern version of VGA Planets is [Planets.nu](https://planets.nu/), and that project has also influenced the development of Pythonium. 31 | 32 | To all of them, thank you. 33 | -------------------------------------------------------------------------------- /pythonium/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | 4 | 5 | class ContextLogger(logging.Logger): 6 | def _log(self, level, msg, args, exc_info=None, extra=None): 7 | msg = f"{msg} - " 8 | if extra: 9 | msg = f"{msg}{'; '.join((f'{k}={v}' for k, v in extra.items()))}" 10 | super()._log(level, msg, args, exc_info, extra) 11 | 12 | 13 | logging.setLoggerClass(ContextLogger) 14 | 15 | 16 | def setup_logger(filename, lvl="info", verbose=False): 17 | 18 | LOGGING_CONFIG = { 19 | "version": 1, 20 | "disable_existing_loggers": True, 21 | "formatters": { 22 | "standard": { 23 | "format": "%(asctime)s [%(levelname)s] %(module)s:%(funcName)s %(message)s" 24 | }, 25 | }, 26 | "handlers": { 27 | "default": { 28 | "level": lvl.upper(), 29 | "formatter": "standard", 30 | "class": "logging.FileHandler", 31 | "filename": filename, 32 | }, 33 | "stream": { 34 | "level": lvl.upper(), 35 | "formatter": "standard", 36 | "class": "logging.StreamHandler", 37 | "stream": "ext://sys.stdout", 38 | }, 39 | }, 40 | "loggers": { 41 | "game": { 42 | "handlers": ["default", "file"] if verbose else ["default"], 43 | "level": lvl.upper(), 44 | "propagate": False, 45 | }, 46 | }, 47 | } 48 | 49 | logging.config.dictConfig(LOGGING_CONFIG) 50 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | Player API Reference 2 | ===================== 3 | 4 | All the classes, methods, and attributes described in this sections can be used in your ``next_turn`` method. 5 | 6 | Despite some of these classes have additional methods and attributes not listed here, those are not useful for you in any way. 7 | 8 | You shouldn't expect any useful information from those methods and attributes. Most of them are used by Pythonium internally. 9 | 10 | .. autoclass:: pythonium.AbstractPlayer 11 | :members: name, next_turn 12 | 13 | .. autoclass:: pythonium.Galaxy 14 | :members: known_races, compute_distance, distances_to_planets, nearby_planets, get_player_planets, get_player_ships, get_ships_in_deep_space, get_ships_in_position, search_ship, search_planet, get_ships_by_position, get_ships_in_planets, get_ships_conflicts, get_ocuped_planets, get_planets_conflicts 15 | 16 | .. autoclass:: pythonium.Planet 17 | :members: id, position, temperature, underground_pythonium, concentration, pythonium, mine_cost, player, megacredits, clans, mines, max_happypoints, happypoints, new_mines, new_ship, max_mines, taxes, rioting_index, dpythonium, dmegacredits, dhappypoints, dclans, can_build_mines, can_build_ship 18 | 19 | .. autoclass:: pythonium.Ship 20 | :members: id, player, type, position, max_cargo, max_mc, attack, megacredits, pythonium, clans, target, transfer 21 | 22 | .. autoclass:: pythonium.ShipType 23 | :members: name, cost, max_cargo, max_mc, attack 24 | 25 | .. autoclass:: pythonium.Transfer 26 | :members: 27 | 28 | .. autoclass:: pythonium.Explosion 29 | :members: 30 | 31 | .. autoclass:: pythonium.core.Position 32 | :members: 33 | 34 | .. autoclass:: pythonium.core.StellarThing 35 | :members: 36 | -------------------------------------------------------------------------------- /tests/game_modes.py: -------------------------------------------------------------------------------- 1 | from pythonium import Galaxy, GameMode, Planet, Ship, core 2 | 3 | 4 | class SandboxGameMode(GameMode): 5 | def __init__(self, max_ships=500, *args, **kwargs): 6 | super().__init__(*args, **kwargs) 7 | self.max_ships = max_ships 8 | 9 | def build_galaxy(self, name, players): 10 | 11 | if len(players) != 1: 12 | raise ValueError("SandboxGameMode only allows one player") 13 | 14 | player = players.pop() 15 | map_size = (244, 244) 16 | planets_positions = ( 17 | (10, 10), 18 | (66, 66), 19 | (122, 122), 20 | (178, 178), 21 | (234, 234), 22 | (10, 234), 23 | (66, 178), 24 | (178, 66), 25 | (234, 10), 26 | ) 27 | 28 | things = [] 29 | for position in planets_positions: 30 | planet = Planet( 31 | position=core.Position(position), 32 | temperature=50, 33 | underground_pythonium=1000, 34 | concentration=1, 35 | pythonium=100, 36 | mine_cost=self.mine_cost, 37 | ) 38 | things.append(planet) 39 | galaxy = Galaxy(name=name, size=map_size, things=things) 40 | 41 | homeworld = galaxy.planets[(10, 10)] 42 | homeworld.player = player.name 43 | homeworld.clans = 1000 44 | homeworld.pythonium = 1000 45 | homeworld.megacredits = 1000 46 | 47 | ship_type = self.ship_types.get("carrier") 48 | ship = Ship( 49 | player=player.name, 50 | type=ship_type, 51 | position=homeworld.position, 52 | max_cargo=ship_type.max_cargo, 53 | max_mc=ship_type.max_mc, 54 | attack=ship_type.attack, 55 | speed=ship_type.speed, 56 | ) 57 | galaxy.add_ship(ship) 58 | 59 | return galaxy 60 | -------------------------------------------------------------------------------- /pythonium/player.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from .galaxy import Galaxy 4 | 5 | 6 | @attr.s 7 | class AbstractPlayer: 8 | 9 | name: str = attr.ib(init=False) 10 | """ 11 | Player's name. Please make it short (less than 12 characters), or you will break the 12 | gif and reports. 13 | """ 14 | 15 | def next_turn(self, galaxy: Galaxy, context: dict) -> Galaxy: 16 | """ 17 | Compute the player strategy based on the available information in the ``galaxy`` 18 | and ``context``. 19 | 20 | :param galaxy: The state of the Galaxy known by the player. 21 | :param context: Aditional information about the game. 22 | 23 | Each player sees a different part of the galaxy, and the ``galaxy`` \ 24 | known by every player is different. 25 | 26 | A galaxy contains: 27 | 28 | * All his ships and planets, 29 | * All the enemy ships in any of his planets, 30 | * All the enemy ships located in the same position as any of his ships, 31 | * All the attributes of a planet that has no owner if a player's ship is \ 32 | on the planet, 33 | * The position of all the planets (not the rest of its attributes), 34 | * All the explosions that occur in the current turn. 35 | 36 | The player won't know the attributes of enemy ships or planets but the position. 37 | 38 | ``context`` has the following keys: 39 | 40 | * ``ship_types``: A dictionary with all the ship types that the player can \ 41 | build. See: :class:`ShipType` 42 | * ``tolerable_taxes``: The level of taxes from where ``happypoints`` start \ 43 | to decay. 44 | * ``happypoints_tolerance``: The level of happypoints from where \ 45 | * ``score``: A list with the score for each player. 46 | * ``turn``: Number of the current turn. 47 | """ 48 | raise NotImplementedError( 49 | "You must implement the ``next_turn`` method in your ``Player`` class" 50 | ) 51 | -------------------------------------------------------------------------------- /tests/orders/test_ship.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythonium.core import Position 5 | from pythonium.orders.ship import ShipMoveOrder 6 | 7 | 8 | class TestShipMoveOrder: 9 | @pytest.fixture 10 | def short_target(self, faker, random_ship, random_direction): 11 | magnitude = faker.pyint(max_value=random_ship.speed) 12 | return Position( 13 | ( 14 | random_ship.position[0] + int(random_direction[0] * magnitude), 15 | random_ship.position[1] + int(random_direction[1] * magnitude), 16 | ) 17 | ) 18 | 19 | @pytest.fixture 20 | def long_target(self, faker, random_ship, random_direction): 21 | magnitude = faker.pyint( 22 | min_value=random_ship.speed + 1, max_value=random_ship.speed * 2 23 | ) 24 | return Position( 25 | ( 26 | random_ship.position[0] + int(random_direction[0] * magnitude), 27 | random_ship.position[1] + int(random_direction[1] * magnitude), 28 | ) 29 | ) 30 | 31 | @pytest.fixture 32 | def long_movement_expected_stop( 33 | self, long_target, random_ship, random_direction 34 | ): 35 | return Position( 36 | ( 37 | random_ship.position[0] 38 | + int(random_direction[0] * random_ship.speed), 39 | random_ship.position[1] 40 | + int(random_direction[1] * random_ship.speed), 41 | ) 42 | ) 43 | 44 | def test_ship_short_move_order(self, galaxy, random_ship, short_target): 45 | order = ShipMoveOrder(ship=random_ship, target=short_target) 46 | order.execute(galaxy) 47 | assert random_ship.position == short_target 48 | assert random_ship.target is None 49 | 50 | def test_ship_long_move_order( 51 | self, galaxy, random_ship, long_target, long_movement_expected_stop 52 | ): 53 | order = ShipMoveOrder(ship=random_ship, target=long_target) 54 | order.execute(galaxy) 55 | assert random_ship.target == long_target 56 | assert np.all(np.isclose(random_ship.position, long_movement_expected_stop, atol=1)) 57 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | import sphinx_rtd_theme 17 | 18 | sys.path.insert(0, os.path.abspath("../..")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "Pythonium" 24 | copyright = "2020, Bruno Geninatti" 25 | author = "Bruno Geninatti" 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = "0.1" 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | "sphinx_rtd_theme", 38 | "sphinx.ext.autodoc", 39 | "sphinx_autodoc_typehints", 40 | "sphinx.ext.coverage", 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # List of patterns, relative to source directory, that match files and 47 | # directories to ignore when looking for source files. 48 | # This pattern also affects html_static_path and html_extra_path. 49 | exclude_patterns = [] 50 | 51 | 52 | # -- Options for HTML output ------------------------------------------------- 53 | 54 | # The theme to use for HTML and HTML Help pages. See the documentation for 55 | # a list of builtin themes. 56 | # 57 | html_theme = "sphinx_rtd_theme" 58 | 59 | # Add any paths that contain custom static files (such as style sheets) here, 60 | # relative to this directory. They are copied after the builtin static files, 61 | # so a file named "default.css" will overwrite the builtin "default.css". 62 | html_static_path = ["_static"] 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /pythonium/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import importlib 3 | import logging 4 | import sys 5 | import time 6 | from pathlib import Path 7 | 8 | from . import __version__ 9 | from .game import Game 10 | from .game_modes import ClassicMode 11 | from .helpers import random_name 12 | from .logger import setup_logger 13 | from .metrics_collector import MetricsCollector 14 | 15 | HELP_EPILOG = "A space strategy algorithmic-game build in python" 16 | 17 | 18 | def go(): 19 | parser = argparse.ArgumentParser(prog="pythonium", epilog=HELP_EPILOG) 20 | parser.add_argument( 21 | "-V", 22 | "--version", 23 | action="store_true", 24 | help="show version and info about the system, and exit", 25 | ) 26 | parser.add_argument("--players", action="extend", nargs="+", help="") 27 | parser.add_argument( 28 | "--metrics", 29 | action="store_true", 30 | default=False, 31 | help="Generate a report with metrics of the game", 32 | ) 33 | parser.add_argument( 34 | "--verbose", 35 | action="store_true", 36 | default=False, 37 | help="Show logs in stdout", 38 | ) 39 | parser.add_argument( 40 | "--raise-exceptions", 41 | action="store_true", 42 | default=False, 43 | help="If the commands computations (for any player) fails, raise " 44 | "the exception and stop.", 45 | ) 46 | parser.add_argument( 47 | "--galaxy-name", default="", help="An identification for the game" 48 | ) 49 | 50 | args = parser.parse_args() 51 | 52 | if args.version: 53 | print("Running 'pythonium' version", __version__) 54 | return 0 55 | 56 | game_mode = ClassicMode() 57 | galaxy_name = random_name(6) 58 | 59 | logfile = Path.cwd() / f"{galaxy_name}.log" 60 | setup_logger(logfile, verbose=args.verbose) 61 | 62 | players = [] 63 | for player_module in args.players: 64 | player_class = importlib.import_module(player_module) 65 | player = player_class.Player() 66 | players.append(player) 67 | 68 | game = Game( 69 | name=galaxy_name, 70 | players=players, 71 | gmode=game_mode, 72 | raise_exceptions=args.raise_exceptions, 73 | ) 74 | game.play() 75 | 76 | if args.metrics: 77 | sys.stdout.write("\n") 78 | sys.stdout.write("Building report...\n") 79 | with open(logfile) as logs: 80 | metrics = MetricsCollector(logs) 81 | metrics.build_report() 82 | sys.stdout.write("Done.\n") 83 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Pythonium CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | pull_request: 8 | branches: 9 | - main 10 | - dev 11 | 12 | jobs: 13 | play-singleplayer: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@master 17 | - name: Setup Python 18 | uses: actions/setup-python@master 19 | with: 20 | python-version: 3.9 21 | - name: Play singleplayer 22 | run: | 23 | python setup.py install 24 | pythonium --players pythonium.bots.standard_player --metrics 25 | play-multiplayer: 26 | runs-on: ubuntu-latest 27 | needs: play-singleplayer 28 | steps: 29 | - uses: actions/checkout@master 30 | - name: Setup Python 31 | uses: actions/setup-python@master 32 | with: 33 | python-version: 3.9 34 | - name: Play multiplayer 35 | run: | 36 | python setup.py install 37 | pythonium --players pythonium.bots.standard_player pythonium.bots.pacific_player --metrics 38 | tests: 39 | runs-on: ubuntu-latest 40 | needs: play-multiplayer 41 | steps: 42 | - uses: actions/checkout@master 43 | - name: Setup Python 44 | uses: actions/setup-python@master 45 | with: 46 | python-version: 3.9 47 | - name: Generate coverage report 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install pytest pytest-cov 51 | pip install -r requirements.txt 52 | pytest --cov=./pythonium --cov-report=xml 53 | - name: Upload Coverage to Codecov 54 | uses: codecov/codecov-action@v1 55 | with: 56 | name: codecov-pythonium 57 | fail_ci_if_error: true 58 | verbose: true 59 | stlye: 60 | runs-on: ubuntu-latest 61 | needs: play-multiplayer 62 | steps: 63 | - uses: actions/checkout@master 64 | - name: Setup Python 65 | uses: actions/setup-python@master 66 | with: 67 | python-version: 3.9 68 | - name: Install dependencies 69 | run: | 70 | python -m pip install --upgrade pip 71 | pip install flake8 72 | - name: Run flake8 73 | uses: julianwachholz/flake8-action@v1.1.0 74 | with: 75 | checkName: "Python Lint" 76 | path: pythonium 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | imports: 80 | runs-on: ubuntu-latest 81 | needs: play-multiplayer 82 | steps: 83 | - uses: actions/checkout@master 84 | - name: Setup Python 85 | uses: actions/setup-python@master 86 | with: 87 | python-version: 3.9 88 | - name: Install dependencies 89 | run: | 90 | python -m pip install --upgrade pip 91 | pip install isort 92 | - uses: jamescurtin/isort-action@master 93 | with: 94 | requirementsFiles: "requirements.txt" 95 | -------------------------------------------------------------------------------- /pythonium/ship.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from .core import Position, StellarThing 4 | from .ship_type import ShipType 5 | from .vectors import Transfer 6 | 7 | 8 | @attr.s(auto_attribs=True, repr=False) 9 | class Ship(StellarThing): 10 | """ 11 | A ship that belongs to a race. 12 | 13 | It can be moved from one point to another, it can be used to move any resource, \ 14 | and in some cases can be used to attack planets or ships. 15 | """ 16 | 17 | type: ShipType = attr.ib( 18 | validator=[attr.validators.instance_of((ShipType, type(None)))], 19 | kw_only=True, 20 | ) 21 | """ 22 | Name of the :class:`ShipType` 23 | """ 24 | 25 | max_cargo: int = attr.ib(converter=int, kw_only=True) 26 | """ 27 | Indicates how much pythonium and clans (together) can carry the ship. 28 | See :attr:`ShipType.max_cargo` 29 | """ 30 | 31 | max_mc: int = attr.ib(converter=int, kw_only=True) 32 | """ 33 | Indicates how much megacredits can carry the ship. 34 | See :attr:`ShipType.max_mc` 35 | """ 36 | 37 | attack: int = attr.ib(converter=int, kw_only=True) 38 | """ 39 | Indicates how much attack the ship has. 40 | See :attr:`ShipType.attack` 41 | """ 42 | 43 | speed: int = attr.ib(converter=int, kw_only=True) 44 | """ 45 | Ship's speed in ly per turn 46 | """ 47 | 48 | # State in turn 49 | megacredits: int = attr.ib(converter=int, default=0, init=False) 50 | """ 51 | Amount of megacredits on the ship 52 | """ 53 | 54 | pythonium: int = attr.ib(converter=int, default=0, init=False) 55 | """ 56 | Amount of pythonium on the ship 57 | """ 58 | 59 | clans: int = attr.ib(converter=int, default=0, init=False) 60 | """ 61 | Amount of clans on the ship 62 | """ 63 | 64 | # User controls 65 | target: Position = attr.ib(converter=Position, default=None, init=False) 66 | """ 67 | **Attribute that can be modified by the player** 68 | 69 | Indicates where the ship is going, or ``None`` if it is stoped. 70 | """ 71 | 72 | transfer: Transfer = attr.ib(default=Transfer(), init=False) 73 | """ 74 | **Attribute that can be modified by the player** 75 | 76 | Indicates what resources will be transferred by the ship in the current turn. 77 | 78 | If no transfer is made this is an empty transfer. 79 | 80 | See :class:`Transfer` bool conversion. 81 | """ 82 | 83 | def __str__(self): 84 | return f"Ship(id={self.id}, position={self.position}, player={self.player})" 85 | 86 | def __repr__(self): 87 | return self.__str__() 88 | 89 | def move(self, position: Position) -> None: 90 | self.target = position 91 | 92 | def get_orders(self): 93 | """ 94 | Compute orders based on player control attributes: ``transfer`` and ``target`` 95 | """ 96 | orders = [] 97 | if self.transfer: 98 | orders.append(("ship_transfer", self.id, self.transfer)) 99 | 100 | if self.target is not None: 101 | orders.append(("ship_move", self.id, self.target)) 102 | 103 | return orders 104 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.0b0 (2021-06-19) 2 | 3 | ### Fix 4 | 5 | - fixes version references 6 | - **ClassicMode**: get_galaxy_for_player copies turn from game galaxy (#36) 7 | - Removes obsolete bots (#35) 8 | - **reports**: fixes report after uuid implementation (#33) 9 | - **bin**: fixes pythonium executable 10 | 11 | ### Refactor 12 | 13 | - **repr**: a more pythonic repr 14 | - **galaxy**: make the turn number a galaxy attribute (#20) 15 | - **loggger**: refactor logger to be global variable in files. Uses dictconfig (#19) 16 | - **ship**: make speed a Ship and shipType attribute (#18) 17 | - Remove old pyproject.toml 18 | 19 | ### Feat 20 | 21 | - **debugger**: adds debugger.terminate to exit form ipdb infinite loop 22 | - human redeable __repr__ for Galaxy, Planet and Ship (#32) 23 | - Adds automatic version detection patterns 24 | - adds pyproject.toml 25 | - Makes the repo commitizen-friendly 26 | 27 | ## 0.2.0a0 (2020-11-24) 28 | 29 | ### Feat 30 | 31 | - Adds automatic version detection patterns 32 | - adds pyproject.toml 33 | - Makes the repo commitizen-friendly 34 | 35 | ### Refactor 36 | 37 | - Remove old pyproject.toml 38 | 39 | ## 0.1.0 (2020-11-24) 40 | 41 | ### Feat 42 | - Implements ``Planet`` class. Represents a planet in the galaxy that can or can not be owned by a player. 43 | - Implements ``Ship`` class. Represents a ship that is owned by a player and can move along space and planets and transfer resources. 44 | - Implements ``Galaxy`` class. Represents the map of the game, contains all the known states of things (planets, ships, and explosions). 45 | - Implements ``AbstractPlayer`` class. Is the class where all the players inherit from. 46 | - Implements ``Game`` class. The main iterator of the game. Where the magic happens. 47 | - Implements ``GameMode`` class. The interface that defines the game rules such as initial conditions for planets, initial conditions for players, game-ended logic. 48 | - Implements ``ClassicMode`` game mode. The default game mode: 49 | * 500 planets randomly located, 50 | * 1M of total pythonium in the galaxy, 51 | * 10% of pythonium is in planets surface, 52 | * Avg concentration: 50%, 53 | * Avg temperature: 50º, 54 | * Player starting ships: 2 carriers, 55 | * Player starting planets: 1 planet (a.k.a. homeworld), 56 | * Player starting resources in homeworld: 10k clans, 2k pythonium, 5k megacredits, 57 | * The Maximum number of turns: 150 turns. 58 | - Implements ``bot.standard_player`` bot. Explore and colonize nearby planets randomly. Builds 50% of warships and 50% of carriers. 59 | - Implements ``bot.pacific_player`` bot. Same as ``bot.standard_player``, but do not build warships. Carriers only. 60 | - Implements ``bot.random_walk`` bot. Just moves the available ships randomly. 61 | - Implements helper classes: 62 | * ``Explosion`` class. Represent the explosion of a ship and same some information about the conflict. 63 | * ``Transfer`` class. Represent a transfer between ships and planets, planets and ships or planets, and the game itself (a.k.a. cost vectors, such as ships or structures costs). 64 | - Implements ``MetricsCollector`` class. Builds a report with game metrics based on the log file. 65 | - Implements ``GifRenderer`` class. Renders a gif with the state of the game turn by turn. 66 | -------------------------------------------------------------------------------- /pythonium/bots/standard_player.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import attr 4 | 5 | from ..player import AbstractPlayer 6 | from ..ship import Ship 7 | from ..vectors import Transfer 8 | 9 | 10 | class Player(AbstractPlayer): 11 | 12 | name = "Solar Fed." 13 | colonization_transfer = Transfer(clans=50, megacredits=100) 14 | colonization_carrier_autonomy = 5 15 | colonization_refill_transfer = ( 16 | colonization_transfer * colonization_carrier_autonomy 17 | ) 18 | populated_planet_exigency = 20 19 | populated_planet_threshold = ( 20 | colonization_transfer * populated_planet_exigency 21 | ) 22 | tenacity = 0.5 23 | 24 | def next_turn(self, galaxy, context): 25 | 26 | my_ships = galaxy.get_player_ships(self.name) 27 | my_planets = galaxy.get_player_planets(self.name) 28 | 29 | visited_planets = [] 30 | 31 | populated_planets = list( 32 | filter( 33 | lambda p: p.clans > self.populated_planet_threshold.clans, 34 | galaxy.get_player_planets(self.name), 35 | ) 36 | ) 37 | 38 | for ship in my_ships: 39 | 40 | # La nave está en un planeta? 41 | planet = galaxy.planets.get(ship.position) 42 | 43 | # Si no es de nadie y tengo clanes lo colonizo 44 | if ship.clans and planet and not planet.player: 45 | ship.transfer = -self.colonization_transfer 46 | 47 | # Tengo clanes? 48 | if not ship.clans: 49 | # No. De donde busco? 50 | # Los puedo sacar del planeta en el que estoy? 51 | if planet in populated_planets: 52 | ship.transfer = self.colonization_refill_transfer 53 | elif populated_planets: 54 | # Voy a cualquier planeta del que pueda sacar recursos 55 | target_planet = populated_planets.pop() 56 | ship.target = target_planet.position 57 | else: 58 | pass 59 | 60 | # Si. 61 | # Voy a buscar un planeta que no sea mio para colonizar. 62 | if not ship.target: 63 | destination = None 64 | nearby_planets = galaxy.nearby_planets( 65 | ship.position, ship.speed 66 | ) 67 | unknown_nearby_planets = [ 68 | p 69 | for p in nearby_planets 70 | if not p.player and p.id not in visited_planets 71 | ] 72 | if unknown_nearby_planets: 73 | destination = random.choice(unknown_nearby_planets) 74 | else: 75 | destination = random.choice(nearby_planets) 76 | 77 | ship.target = destination.position 78 | 79 | for planet in my_planets: 80 | planet.taxes = context.get("tolerable_taxes") 81 | planet.new_mines = planet.can_build_mines() 82 | 83 | if random.random() > self.tenacity: 84 | next_ship = context["ship_types"]["carrier"] 85 | else: 86 | next_ship = context["ship_types"]["war"] 87 | 88 | if planet.can_build_ship(next_ship): 89 | planet.new_ship = next_ship 90 | 91 | return galaxy 92 | -------------------------------------------------------------------------------- /pythonium/vectors.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | 4 | @attr.s 5 | class Transfer: 6 | """ 7 | Represent the transfer of resources from a ship 8 | to a planet and vice-versa, or the cost of structures. 9 | This second case can be considered as a transfer between the player 10 | and the game. 11 | 12 | Some useful properties of this class are: 13 | 14 | **Negation** 15 | 16 | Changes the direction of the transfer. 17 | 18 | >>> t = Transfer(clans=100, megacredits=100, pythonium=100) 19 | >>> print(-t) 20 | Transfer(megacredits=-100, pythonium=-100, clans=-100) 21 | 22 | **Addition and Subtraction** 23 | 24 | Add/subtracts two transfers 25 | 26 | >>> t1 = Transfer(clans=100, megacredits=100, pythonium=100) 27 | >>> t2 = Transfer(clans=10, megacredits=10, pythonium=10) 28 | >>> print(t1 + t2) 29 | Transfer(megacredits=110, pythonium=110, clans=110) 30 | 31 | **Multiplication and Division** 32 | 33 | Multiplies/divides a transfer with an scalar 34 | 35 | >>> t = Transfer(clans=100, megacredits=100, pythonium=100) 36 | >>> print(t*1.5) 37 | Transfer(megacredits=150, pythonium=150, clans=150) 38 | >>> print(t/1.5) 39 | Transfer(megacredits=150, pythonium=150, clans=150) 40 | 41 | **Empty transfers** 42 | 43 | The conversion of a transfer to a boolean return ``False`` if the transfer is empty 44 | 45 | >>> t = Transfer() 46 | >>> print(bool(t)) 47 | False 48 | 49 | """ 50 | 51 | megacredits: int = attr.ib(converter=int, default=0) 52 | """ 53 | Amount of megacredits to transfer 54 | """ 55 | 56 | pythonium: int = attr.ib(converter=int, default=0) 57 | """ 58 | Amount of pythonium to transfer 59 | """ 60 | 61 | clans: int = attr.ib(converter=int, default=0) 62 | """ 63 | Amount of clans to transfer 64 | """ 65 | 66 | def __neg__(self): 67 | return self.__class__( 68 | megacredits=-self.megacredits, 69 | clans=-self.clans, 70 | pythonium=-self.pythonium, 71 | ) 72 | 73 | def __add__(self, obj): 74 | 75 | if not isinstance(obj, Transfer): 76 | raise ValueError( 77 | "Can not sum Transfer class and {}".format(type(obj)) 78 | ) 79 | 80 | return self.__class__( 81 | megacredits=self.megacredits + obj.megacredits, 82 | clans=self.clans + obj.clans, 83 | pythonium=self.pythonium + obj.pythonium, 84 | ) 85 | 86 | def __sub__(self, obj): 87 | self.__add__(-obj) 88 | 89 | def __mul__(self, factor): 90 | 91 | if not isinstance(factor, (int, float)): 92 | msg = "Can not apply multiplication to {}".format(type(factor)) 93 | raise ValueError(msg) 94 | 95 | return self.__class__( 96 | megacredits=self.megacredits * factor, 97 | clans=self.clans * factor, 98 | pythonium=self.pythonium * factor, 99 | ) 100 | 101 | def __truediv__(self, denom): 102 | 103 | if not isinstance(denom, (int, float)): 104 | msg = "Can not apply division to {}".format(type(denom)) 105 | raise ValueError(msg) 106 | 107 | return self.__class__( 108 | self.megacredits / denom, 109 | self.clans / denom, 110 | self.pythonium / denom, 111 | ) 112 | 113 | def __bool__(self): 114 | return any([self.clans, self.megacredits, self.pythonium]) 115 | 116 | def __repr__(self): 117 | return f"({self.clans}, {self.megacredits}, {self.pythonium})" 118 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import List, Optional, Set, Tuple 3 | 4 | from factory import Factory, Faker, SubFactory 5 | 6 | from pythonium import Explosion, Galaxy, Planet, Ship 7 | from pythonium.core import Position 8 | from pythonium.ship_type import ShipType 9 | from pythonium.vectors import Transfer 10 | 11 | fake = Faker._get_faker() 12 | MAP_SIZE = (fake.pyint(min_value=10), fake.pyint(min_value=10)) 13 | 14 | 15 | def fake_positions( 16 | map_size: Tuple[int, int], 17 | amount: int = 1, 18 | exclude: Optional[List[Position]] = None, 19 | ): 20 | exclude = exclude or [] 21 | 22 | def get_available_points(size: int, excluded: Set): 23 | all_points = set(range(size)) 24 | all_points.discard(excluded) 25 | return list(all_points) 26 | 27 | excluded_x = [p[0] for p in exclude] 28 | excluded_y = [p[1] for p in exclude] 29 | x_coordinates = get_available_points(map_size[0], set(excluded_x)) 30 | y_coordinates = get_available_points(map_size[1], set(excluded_y)) 31 | random.shuffle(x_coordinates) 32 | random.shuffle(y_coordinates) 33 | for _ in range(amount): 34 | yield Position((x_coordinates.pop(), y_coordinates.pop())) 35 | 36 | 37 | class TransferVectorFactory(Factory): 38 | class Meta: 39 | model = Transfer 40 | 41 | megacredits = Faker("pyint") 42 | pythonium = Faker("pyint") 43 | clans = Faker("pyint") 44 | 45 | 46 | class PositiveTransferVectorFactory(Factory): 47 | class Meta: 48 | model = Transfer 49 | 50 | megacredits = Faker("pyint", min_value=0) 51 | pythonium = Faker("pyint", min_value=0) 52 | clans = Faker("pyint", min_value=0) 53 | 54 | 55 | class NegativeTransferVectorFactory(Factory): 56 | class Meta: 57 | model = Transfer 58 | 59 | megacredits = Faker("pyint", max_value=0) 60 | pythonium = Faker("pyint", max_value=0) 61 | clans = Faker("pyint", max_value=0) 62 | 63 | 64 | class ShipTypeFactory(Factory): 65 | class Meta: 66 | model = ShipType 67 | 68 | name = Faker("word") 69 | cost = SubFactory(PositiveTransferVectorFactory, clans=0) 70 | max_cargo = Faker("pyint", min_value=0) 71 | max_mc = Faker("pyint", min_value=0) 72 | attack = Faker("pyint", min_value=0) 73 | speed = Faker("pyint", min_value=0) 74 | 75 | 76 | class ShipFactory(Factory): 77 | class Meta: 78 | model = Ship 79 | 80 | type = SubFactory(ShipTypeFactory) 81 | max_cargo = Faker("pyint", min_value=0) 82 | max_mc = Faker("pyint", min_value=0) 83 | attack = Faker("pyint", min_value=0) 84 | speed = Faker("pyint", min_value=0) 85 | 86 | 87 | class PlanetFactory(Factory): 88 | class Meta: 89 | model = Planet 90 | 91 | position = next(fake_positions(MAP_SIZE)) 92 | temperature = Faker("pyint", min_value=0, max_value=100) 93 | underground_pythonium = Faker("pyint", min_value=0) 94 | concentration = Faker("pyfloat", min_value=0, max_value=1) 95 | pythonium = Faker("pyint", min_value=0) 96 | mine_cost = SubFactory(PositiveTransferVectorFactory) 97 | 98 | 99 | class ExplosionFactory(Factory): 100 | class Meta: 101 | model = Explosion 102 | 103 | ship = SubFactory(ShipFactory) 104 | ships_involved = Faker("pyint", min_value=2) 105 | total_attack = Faker("pyint", min_value=100) 106 | 107 | 108 | class GalaxyFactory(Factory): 109 | class Meta: 110 | model = Galaxy 111 | 112 | name = Faker("word") 113 | size = MAP_SIZE 114 | things = [ 115 | PlanetFactory(position=next(fake_positions(MAP_SIZE))) 116 | for _ in range(10) 117 | ] 118 | -------------------------------------------------------------------------------- /pythonium/orders/planet.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import attr 4 | 5 | from ..ship import Ship 6 | from ..ship_type import ShipType 7 | from .core import PlanetOrder 8 | 9 | logger = logging.getLogger("game") 10 | 11 | 12 | @attr.s() 13 | class PlanetBuildMinesOrder(PlanetOrder): 14 | 15 | name = "build_mines" 16 | new_mines: int = attr.ib() 17 | 18 | def execute(self, galaxy): 19 | new_mines = int(min(self.new_mines, self.planet.can_build_mines())) 20 | 21 | if not new_mines: 22 | logger.warning( 23 | "No mines to build", 24 | extra={ 25 | "turn": galaxy.turn, 26 | "planet": self.planet.id, 27 | "pythonium": self.planet.pythonium, 28 | "megacredits": self.planet.megacredits, 29 | "mines": self.planet.mines, 30 | "max_mines": self.planet.max_mines, 31 | "new_mines": self.new_mines, 32 | }, 33 | ) 34 | return 35 | 36 | self.planet.mines += new_mines 37 | self.planet.megacredits -= ( 38 | new_mines * self.planet.mine_cost.megacredits 39 | ) 40 | self.planet.pythonium -= new_mines * self.planet.mine_cost.pythonium 41 | 42 | logger.info( 43 | "New mines", 44 | extra={ 45 | "turn": galaxy.turn, 46 | "player": self.planet.player, 47 | "planet": self.planet.id, 48 | "new_mines": new_mines, 49 | }, 50 | ) 51 | 52 | 53 | @attr.s() 54 | class PlanetBuildShipOrder(PlanetOrder): 55 | 56 | name = "build_ship" 57 | ship_type: ShipType = attr.ib() 58 | 59 | def execute(self, galaxy): 60 | 61 | if not self.planet.can_build_ship(self.ship_type): 62 | logger.warning( 63 | "Missing resources", 64 | extra={ 65 | "turn": galaxy.turn, 66 | "planet": self.planet.id, 67 | "ship_type": self.ship_type.name, 68 | "megacredits": self.planet.megacredits, 69 | "pythonium": self.planet.pythonium, 70 | }, 71 | ) 72 | return 73 | 74 | ship = Ship( 75 | player=self.planet.player, 76 | type=self.ship_type, 77 | position=self.planet.position, 78 | max_cargo=self.ship_type.max_cargo, 79 | max_mc=self.ship_type.max_mc, 80 | attack=self.ship_type.attack, 81 | speed=self.ship_type.speed, 82 | ) 83 | 84 | self.planet.megacredits -= self.ship_type.cost.megacredits 85 | self.planet.pythonium -= self.ship_type.cost.pythonium 86 | 87 | galaxy.add_ship(ship) 88 | 89 | logger.info( 90 | "New ship built", 91 | extra={ 92 | "turn": galaxy.turn, 93 | "player": self.planet.player, 94 | "planet": self.planet.id, 95 | "ship_type": self.ship_type.name, 96 | }, 97 | ) 98 | 99 | 100 | @attr.s() 101 | class PlanetSetTaxesOrder(PlanetOrder): 102 | 103 | name = "set_taxes" 104 | taxes: int = attr.ib() 105 | 106 | def execute(self, galaxy): 107 | if self.planet.taxes == self.taxes: 108 | return 109 | 110 | self.planet.taxes = min(max(0, self.taxes), 100) 111 | logger.info( 112 | "Taxes updated", 113 | extra={ 114 | "turn": galaxy.turn, 115 | "player": self.planet.player, 116 | "planet": self.planet.id, 117 | "taxes": self.taxes, 118 | }, 119 | ) 120 | -------------------------------------------------------------------------------- /docs/source/about.rst: -------------------------------------------------------------------------------- 1 | About Pythonium 2 | ================ 3 | 4 | Pythonium is a space turn-based strategy game where each player leads an alienrace that aims to conquer the galaxy. 5 | 6 | You must explore planets to search and extract a valuable mineral: the `pythonium`. 7 | This precious material allows you to build cargo and combat spaceships, or mines to get 8 | more pythonium. 9 | 10 | Manage the economy on your planets, and collect taxes on your people to finance your 11 | constructions, but be careful! Keep your clans happy if you want to avoid unrest in your planets. 12 | 13 | Put your space helmet on, set your virtualenv, and start coding. 14 | 15 | Battle for pythonium is waiting for you! 16 | 17 | Installation 18 | ================ 19 | 20 | First of all, clone `the pythonium repo `_ 21 | 22 | :: 23 | 24 | $ git clone https://github.com/Bgeninatti/pythonium.git 25 | $ cd pythonium 26 | 27 | The recommended way to use pythonium is with Docker. 28 | 29 | Once you have `docker `_ and `docker-compose `_ installed in your system you can run 30 | 31 | :: 32 | 33 | $ make play 34 | 35 | 36 | you will end up in a new prompt and the pythonium CLI will be available 37 | 38 | :: 39 | 40 | # pythonium --version 41 | Running 'pythonium' version x.y.z 42 | 43 | 44 | Manual installation 45 | ------------------- 46 | 47 | 48 | You can also install Pythonium with: 49 | 50 | :: 51 | 52 | $ python setup.py install 53 | 54 | and then test your installation doing 55 | 56 | :: 57 | 58 | $ pythonium --version 59 | Running 'pythonium' version x.y.z 60 | 61 | 62 | Single-player mode 63 | ================== 64 | 65 | Once you have Pythonium installed you can test it for a single-player mode with some of the available bots. 66 | For example, the ``standard_player`` bot. 67 | 68 | :: 69 | 70 | pythonium --players pythonium.bots.standard_player 71 | 72 | Once the command finishes you should have a ``.gif`` file and a ``.log``, 73 | where ```` is a unique code generated for the game. 74 | 75 | * ``.gif``: This is an animation showing how the galaxy ownership changed along with the game, 76 | which planets belong to each player, ships movements, combats, and the score on each turn. 77 | 78 | * ``.log``: Contain the logs with all the relevant events during the game. 79 | 80 | Here's an example of the gif 81 | 82 | .. image:: https://ik.imagekit.io/jmpdcmsvqee/single_player_kOfI32YJ6sW.gif 83 | :target: https://ik.imagekit.io/jmpdcmsvqee/single_player_kOfI32YJ6sW.gif 84 | :width: 300pt 85 | 86 | Multiplayer mode 87 | ================= 88 | 89 | Pythonium allows up to two players per game, and you can test it by providing two bots to the ``--players`` argument. 90 | 91 | :: 92 | 93 | pythonium --players pythonium.bots.standard_player pythonium.bots.pacific_player 94 | 95 | The output will be similar to the single player mode: one ``.log`` and one ``.gif`` file. 96 | 97 | 98 | .. image:: https://ik.imagekit.io/jmpdcmsvqee/multi_player_COZwjdq3nKB.gif 99 | :target: https://ik.imagekit.io/jmpdcmsvqee/multi_player_COZwjdq3nKB.gif 100 | :width: 300pt 101 | 102 | 103 | Metrics 104 | ======= 105 | 106 | Providing the ``--metrics`` arguments, pythonium creates a report with several metrics of the game. 107 | This is especially useful to evaluate the performance of your players, and know their strengths and weaknesses. 108 | 109 | :: 110 | 111 | pythonium --metrics --players pythonium.bots.standard_player pythonium.bots.pacific_player 112 | 113 | In addition to the ``.gif`` and ``.log`` now you will se a ``report_.png`` with several charts. 114 | 115 | 116 | .. image:: https://ik.imagekit.io/jmpdcmsvqee/sample_report_rm-fTWhSa.png 117 | :target: https://ik.imagekit.io/jmpdcmsvqee/sample_report_rm-fTWhSa.png 118 | :width: 300pt 119 | 120 | Acknowledge 121 | =========== 122 | 123 | This game is strongly inspired by `VGA Planets `_, a space strategy war game from 1992 created by Tim Wisseman. 124 | 125 | The modern version of VGA Planets is `Planets.nu `_, and that project has also influenced the development of Pythonium. 126 | 127 | To all of them, thank you. 128 | 129 | 130 | What next? 131 | ========== 132 | 133 | Now you probably wants to write your own bot, didn't you? 134 | 135 | Check out the :ref:`tutorial` to see how to do it. 136 | -------------------------------------------------------------------------------- /docs/source/tutorial/01_first_player.rst: -------------------------------------------------------------------------------- 1 | .. _Tutorial Chapter 01: 2 | 3 | Chapter 1 - The beginning of the road 4 | ====================================== 5 | 6 | Welcome player! 7 | 8 | Welcome to the hard path of stop being part of a selfish colony of humanoids, 9 | jailed in their lonely planet with the only purpose of destroying themselves; to start being an adventurer, 10 | a space explorer, and a strategist. All with the power of your terminal and text editor. 11 | 12 | In this section, you will learn how to create a player to play Pythonium. 13 | 14 | Yes, you read correctly. You will not play Pythonium. You will build a player that to play Pythonium 15 | for you, and all your strategy must be implemented on that player. 16 | 17 | This is a turn-based game, which means each player receives a set of information (or state of the game) 18 | at the beginning of turn `t`, and makes decisions based on that information to influence the state of 19 | the game at turn `t+1`. This sequence is repeated again and again in an iterative process until the 20 | game finishes. 21 | 22 | Your player then is not more than a `python class `_ implementing a 23 | `method `_ that is executed every turn. 24 | This method receives as parameters the state of the ``galaxy``, and some other ``context`` about the state of the game 25 | (i.e, the scoreboard and other useful data), and it must return the same ``galaxy`` instance with some changes reflecting 26 | the player's decisions. 27 | 28 | Let's stop divagating and start coding. 29 | 30 | Put this code inside a python file: 31 | 32 | .. code-block:: python 33 | 34 | from pyhtonium import AbstractPlayer 35 | 36 | class Player(AbstractPlayer): 37 | 38 | name = 'Han Solo' 39 | 40 | def next_turn(self, galaxy, context): 41 | return galaxy 42 | 43 | 44 | There are a few things to note from here. 45 | 46 | In the first place, the ``Player`` class inherits from an ``AbstractPlayer``. 47 | Second, there is one attribute and one method that needs to be defined in this class. 48 | 49 | * ``name``: The name of your player. Try to make it short or your reports and gif will look buggy. 50 | * ``next_turn``: A method that will be executed every turn. This is where your strategy is implemented. 51 | 52 | .. _Executing your player: 53 | 54 | Executing your player 55 | ---------------------- 56 | 57 | Let's save now this file as ``my_player.py`` (or whatever name you like) and execute the following command: 58 | 59 | .. code-block:: bash 60 | 61 | $ pyhtonium --player my_player 62 | ** Pythonium ** 63 | Running battle in Galaxy #PBA5V2 64 | Playing game..................................... 65 | Nobody won 66 | 67 | The output will show the name of the galaxy where the game occurs, and some other 68 | self-explained information. 69 | 70 | Once the command finishes, you will find in your working directory two files: 71 | 72 | * ``PBA5V2.gif``: A visual representation of the game. The closest thing to a UI that you will find in Pythonium. 73 | * ``PBA5V2.log``: A plain-text file containing all the information related to the game execution. Every change on the galaxy state (influenced by the player or not) is logged on this file. 74 | 75 | .. note:: 76 | 77 | Notice that the name of both files is the galaxy name. Each game is generated with a random (kinda unique) 78 | galaxy name. 79 | 80 | As a gif you will see something similar to this: 81 | 82 | .. image:: https://ik.imagekit.io/jmpdcmsvqee/chapter_01_pwIQAPgxD.gif 83 | :target: https://ik.imagekit.io/jmpdcmsvqee/chapter_01_pwIQAPgxD.gif 84 | :width: 300pt 85 | 86 | Reading from the top to the bottom: 87 | 88 | * You are in the galaxy `#PBA5V2` 89 | * You are Han Solo (your player's name) 90 | * The turn at each frame is displayed at the left of the player name 91 | * You have one planet and two ships 92 | * Your planet and ships are in the blue dot. The rest of the dots are the others 299 planets in the galaxy. 93 | 94 | .. note:: 95 | 96 | The blue dot is bigger than the white ones. The reason for this is that planets with any ship on their orbits are 97 | represented with bigger dots. This means your two ships are placed on your only planet. 98 | 99 | 100 | Do you see it? Nothing happens. You just stay on your planet and do nothing for all eternity. 101 | By reviewing the player's code closely you will notice that this is precisely what it does: returns the galaxy without 102 | changing anything. 103 | 104 | Congratulations! You just reproduced your miserable human life on earth, as a Pythonium player. 105 | 106 | Wanna see the cool stuff? Then keep moving, human. 107 | -------------------------------------------------------------------------------- /pythonium/orders/ship.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Tuple 3 | 4 | import attr 5 | 6 | from ..vectors import Transfer 7 | from .core import ShipOrder 8 | 9 | logger = logging.getLogger("game") 10 | 11 | 12 | @attr.s() 13 | class ShipMoveOrder(ShipOrder): 14 | 15 | name = "ship_move" 16 | target: Tuple[int, int] = attr.ib() 17 | 18 | def execute(self, galaxy): 19 | 20 | _from = self.ship.position 21 | distance_to_target = galaxy.compute_distance( 22 | self.ship.position, self.target 23 | ) 24 | 25 | if distance_to_target <= self.ship.speed: 26 | to = self.target 27 | new_target = None 28 | else: 29 | 30 | direction = ( 31 | (self.target[0] - self.ship.position[0]) / distance_to_target, 32 | (self.target[1] - self.ship.position[1]) / distance_to_target, 33 | ) 34 | to = ( 35 | int(self.ship.position[0] + direction[0] * self.ship.speed), 36 | int(self.ship.position[1] + direction[1] * self.ship.speed), 37 | ) 38 | new_target = self.target 39 | 40 | self.ship.position = to 41 | self.ship.target = new_target 42 | logger.info( 43 | "Ship moved", 44 | extra={ 45 | "turn": galaxy.turn, 46 | "player": self.ship.player, 47 | "ship": self.ship.id, 48 | "from": _from, 49 | "to": self.ship.position, 50 | "target": self.ship.target, 51 | }, 52 | ) 53 | 54 | 55 | @attr.s() 56 | class ShipTransferOrder(ShipOrder): 57 | 58 | name = "ship_transfer" 59 | transfer: Transfer = attr.ib() 60 | 61 | def execute(self, galaxy): 62 | planet = galaxy.planets.get(self.ship.position) 63 | 64 | logger.info( 65 | "Attempt to transfer", 66 | extra={ 67 | "ship": self.ship.id, 68 | "clans": self.transfer.clans, 69 | "pythonium": self.transfer.pythonium, 70 | "megacredits": self.transfer.megacredits, 71 | }, 72 | ) 73 | 74 | if not planet: 75 | logger.warning( 76 | "Can not transfer in deep space", 77 | extra={"turn": galaxy.turn, "ship": self.ship.id}, 78 | ) 79 | return 80 | 81 | if planet.player is not None and planet.player != self.ship.player: 82 | logger.warning( 83 | "Can not transfer to an enemy planet", 84 | extra={ 85 | "turn": galaxy.turn, 86 | "ship": self.ship.id, 87 | "planet": planet.id, 88 | }, 89 | ) 90 | return 91 | 92 | # Check if transfers + existences are grather than capacity on clans and pythonium 93 | available_cargo = self.ship.max_cargo - ( 94 | self.ship.pythonium + self.ship.clans 95 | ) 96 | 97 | # Adjust transfers to real availability in planet and ship 98 | self.transfer.clans = ( 99 | min(self.transfer.clans, planet.clans, available_cargo) 100 | if self.transfer.clans > 0 101 | else max(self.transfer.clans, -self.ship.clans) 102 | ) 103 | self.transfer.pythonium = ( 104 | min( 105 | self.transfer.pythonium, 106 | planet.pythonium, 107 | available_cargo - self.transfer.clans, 108 | ) 109 | if self.transfer.pythonium > 0 110 | else max(self.transfer.pythonium, -self.ship.pythonium) 111 | ) 112 | self.transfer.megacredits = ( 113 | min( 114 | self.transfer.megacredits, 115 | planet.megacredits, 116 | self.ship.max_mc - self.ship.megacredits, 117 | ) 118 | if self.transfer.megacredits > 0 119 | else max(self.transfer.megacredits, -self.ship.megacredits) 120 | ) 121 | 122 | # Do transfers 123 | self.ship.clans += self.transfer.clans 124 | self.ship.pythonium += self.transfer.pythonium 125 | self.ship.megacredits += self.transfer.megacredits 126 | 127 | logger.info( 128 | "Ship transfer to planet", 129 | extra={ 130 | "turn": galaxy.turn, 131 | "player": self.ship.player, 132 | "ship": self.ship.id, 133 | "clans": self.transfer.clans, 134 | "pythonium": self.transfer.pythonium, 135 | "megacredits": self.transfer.megacredits, 136 | }, 137 | ) 138 | 139 | planet.clans -= self.transfer.clans 140 | planet.pythonium -= self.transfer.pythonium 141 | planet.megacredits -= self.transfer.megacredits 142 | 143 | if not planet.clans: 144 | # If nobody stays in the planet the player doesn't own it anymore 145 | planet.player = None 146 | logger.info( 147 | "Planet abandoned", 148 | extra={ 149 | "turn": galaxy.turn, 150 | "player": self.ship.player, 151 | "planet": planet.id, 152 | }, 153 | ) 154 | elif planet.player is None and planet.clans > 0: 155 | # If nobody owns the planet and the ship download clans the player 156 | # conquer the planet 157 | planet.player = self.ship.player 158 | logger.info( 159 | "Planet conquered", 160 | extra={ 161 | "turn": galaxy.turn, 162 | "player": self.ship.player, 163 | "planet": planet.id, 164 | }, 165 | ) 166 | 167 | return 168 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | import uuid 4 | 5 | import pytest 6 | 7 | from pythonium import AbstractPlayer, Game, cfg 8 | from pythonium.logger import setup_logger 9 | from tests.factories import ( 10 | GalaxyFactory, 11 | PlanetFactory, 12 | ShipFactory, 13 | ShipTypeFactory, 14 | fake_positions, 15 | ) 16 | 17 | from .game_modes import SandboxGameMode 18 | 19 | 20 | @pytest.fixture 21 | def galaxy_size(faker): 22 | return 200, 200 23 | 24 | 25 | @pytest.fixture 26 | def expected_players(faker): 27 | return [faker.word(), faker.word()] 28 | 29 | 30 | @pytest.fixture 31 | def planets(faker, expected_players, galaxy_size): 32 | amount_of_planets = faker.pyint(min_value=2, max_value=20) 33 | planets = [] 34 | for position in fake_positions(galaxy_size, amount_of_planets): 35 | planet = PlanetFactory( 36 | position=position, 37 | player=random.choice(expected_players + [None]), 38 | ) 39 | planets.append(planet) 40 | return planets 41 | 42 | 43 | @pytest.fixture 44 | def ships(faker, expected_players, galaxy_size, planets): 45 | ships = [] 46 | amount_of_ships = faker.pyint(min_value=2, max_value=20) 47 | positions = [p.position for p in planets] 48 | for position in fake_positions(galaxy_size, amount_of_ships, positions): 49 | ship = ShipFactory( 50 | position=position, 51 | player=random.choice(expected_players), 52 | ) 53 | ships.append(ship) 54 | return ships 55 | 56 | 57 | @pytest.fixture 58 | def ships_in_planets(faker, galaxy_size, planets): 59 | ships = [] 60 | amount_of_ships = faker.pyint(min_value=2, max_value=20) 61 | positions = [p.position for p in planets] 62 | for _ in range(amount_of_ships): 63 | position = random.choice(positions) 64 | ship = ShipFactory( 65 | position=position, 66 | player=faker.word(), 67 | ) 68 | ships.append(ship) 69 | positions.append(position) 70 | return ships 71 | 72 | 73 | @pytest.fixture 74 | def ships_in_conflict(expected_players, galaxy_size): 75 | position = next(fake_positions(galaxy_size)) 76 | ships = [] 77 | for player in expected_players: 78 | ship = ShipFactory(position=position, player=player) 79 | ships.append(ship) 80 | return ships 81 | 82 | 83 | @pytest.fixture 84 | def planet_pasive_conflict_galaxy(expected_players, galaxy_size): 85 | position = next(fake_positions(galaxy_size)) 86 | planet = PlanetFactory(position=position, player=expected_players[0]) 87 | ship = ShipFactory(position=position, player=expected_players[1], attack=0) 88 | return GalaxyFactory(size=galaxy_size, things=[planet, ship]) 89 | 90 | 91 | @pytest.fixture 92 | def planet_conflict_galaxy(expected_players, galaxy_size): 93 | position = next(fake_positions(galaxy_size)) 94 | planet = PlanetFactory(position=position, player=expected_players[0]) 95 | no_attack_ship_type = ShipTypeFactory(attack=100) 96 | ship = ShipFactory( 97 | position=position, player=expected_players[1], type=no_attack_ship_type 98 | ) 99 | return GalaxyFactory(size=galaxy_size, things=[planet, ship]) 100 | 101 | 102 | @pytest.fixture 103 | def galaxy(planets, ships, galaxy_size): 104 | return GalaxyFactory( 105 | size=galaxy_size, 106 | things=planets + ships, 107 | ) 108 | 109 | 110 | @pytest.fixture 111 | def ships_in_planets_galaxy(planets, ships_in_planets, galaxy_size): 112 | return GalaxyFactory( 113 | size=galaxy_size, 114 | things=planets + ships_in_planets, 115 | ) 116 | 117 | 118 | @pytest.fixture 119 | def ships_in_conflict_galaxy(planets, ships_in_conflict, galaxy_size): 120 | return GalaxyFactory( 121 | size=galaxy_size, 122 | things=planets + ships_in_conflict, 123 | ) 124 | 125 | 126 | @pytest.fixture 127 | def random_position(galaxy_size): 128 | return next(fake_positions(galaxy_size)) 129 | 130 | 131 | @pytest.fixture 132 | def fake_id(): 133 | return uuid.uuid4() 134 | 135 | 136 | @pytest.fixture 137 | def random_player(expected_players): 138 | return random.choice(expected_players) 139 | 140 | 141 | @pytest.fixture 142 | def random_ship(ships): 143 | return random.choice(ships) 144 | 145 | 146 | @pytest.fixture 147 | def random_planet(planets): 148 | return random.choice(planets) 149 | 150 | 151 | @pytest.fixture() 152 | def colonized_planets(planets): 153 | return [p for p in planets if p.player is not None] 154 | 155 | 156 | @pytest.fixture() 157 | def colonized_planet(colonized_planets): 158 | return random.choice(colonized_planets) 159 | 160 | 161 | @pytest.fixture 162 | def random_direction(planets): 163 | angle = random.random() * math.pi 164 | yield math.cos(angle), math.sin(angle) 165 | 166 | 167 | @pytest.fixture 168 | def happypoints_tolerance(): 169 | return cfg.happypoints_tolerance 170 | 171 | 172 | @pytest.fixture 173 | def tenacity(): 174 | return cfg.tenacity 175 | 176 | 177 | # fixtures for test_run_turn 178 | 179 | 180 | class TestPlayer(AbstractPlayer): 181 | 182 | name = "Test Player" 183 | 184 | def next_turn(self, galaxy, context): 185 | return galaxy 186 | 187 | 188 | @pytest.fixture 189 | def logfile(tmp_path): 190 | return tmp_path / "tests.log" 191 | 192 | 193 | @pytest.fixture(autouse=True) 194 | def logger_setup(logfile): 195 | setup_logger(logfile) 196 | 197 | 198 | @pytest.fixture 199 | def test_player(): 200 | """ 201 | This don'tn return the same instance that is used in the game, but is useful to 202 | have a reference of `player.name` 203 | """ 204 | return TestPlayer() 205 | 206 | 207 | @pytest.fixture 208 | def game(): 209 | """ 210 | Return an instance of the game 211 | """ 212 | game_mode = SandboxGameMode() 213 | player = TestPlayer() 214 | return Game("test_sector", [player], game_mode) 215 | -------------------------------------------------------------------------------- /pythonium/renderer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from PIL import Image, ImageDraw, ImageFont 4 | 5 | from . import cfg 6 | from .helpers import load_font 7 | 8 | 9 | class GifRenderer: 10 | def __init__(self, galaxy, title=None, speed=200): 11 | self.galaxy = galaxy 12 | self.speed = speed 13 | self.title = title 14 | self.background_color = (40, 40, 40) 15 | self._frames = [] 16 | self.available_players_colors = [ 17 | (238, 48, 47, 256), 18 | (80, 168, 224, 256), 19 | ] 20 | self.players_colors = { 21 | p: self.available_players_colors.pop() for p in galaxy.known_races 22 | } 23 | self.planet_color = (256, 256, 256, 256) 24 | self.explosion_color = (200, 157, 78, 256) 25 | self.header = 200 26 | self.margin = 10 27 | self.footer = 30 28 | self._title_font = load_font(40) 29 | self._score_font = load_font(24) 30 | self._footer_font = load_font(10) 31 | self.frame_size = ( 32 | self.galaxy.size[0] + self.margin, 33 | self.galaxy.size[1] + self.header, 34 | ) 35 | self.plantet_radius = 3 36 | self.ships_radius = 5 37 | 38 | def galaxy2frame_coordinates(self, coordinates): 39 | return ( 40 | coordinates[0] + 10, 41 | coordinates[1] + self.header - self.margin, 42 | ) 43 | 44 | def render_frame(self, context): 45 | frame = Image.new("RGB", self.frame_size, self.background_color) 46 | draw = ImageDraw.Draw(frame, "RGBA") 47 | 48 | if self.title: 49 | self.render_title(draw) 50 | 51 | self.render_score(draw, context) 52 | 53 | # Plot explosions 54 | for explosion in self.galaxy.explosions: 55 | self.render_explosion(draw, explosion) 56 | 57 | # Plot planets 58 | for planet in self.galaxy.planets.values(): 59 | self.render_planet(draw, planet) 60 | 61 | # Plot ships 62 | for ship in self.galaxy.ships: 63 | self.render_ship(draw, ship) 64 | 65 | now = datetime.now().strftime("%Y-%m-%d %H:%M") 66 | draw.text( 67 | (self.margin, self.frame_size[1] - 10), 68 | f"github.com/Bgeninatti/pythonium - {now}", 69 | font=self._footer_font, 70 | ) 71 | 72 | self._frames.append(frame) 73 | 74 | def render_title(self, draw): 75 | draw.text( 76 | (self.margin, self.margin), self.title, font=self._title_font 77 | ) 78 | 79 | def render_score(self, draw, context): 80 | # TODO: Fix this insanity of column and rows coordinates for text 81 | draw.text( 82 | (self.margin, self.margin + 50), 83 | f"Turn {self.galaxy.turn}", 84 | font=self._score_font, 85 | ) 86 | 87 | draw.text( 88 | (self.margin, self.margin + 90), "Planets", font=self._score_font 89 | ) 90 | 91 | draw.text( 92 | (self.margin, self.margin + 130), "Ships", font=self._score_font 93 | ) 94 | 95 | score = context.get("score") 96 | 97 | for i, player_score in enumerate(score): 98 | color = self.players_colors.get(player_score["player"]) 99 | draw.text( 100 | (self.margin + (i + 1) * 170, self.margin + 50), 101 | player_score["player"], 102 | font=self._score_font, 103 | fill=color, 104 | ) 105 | draw.text( 106 | (self.margin + (i + 1) * 170, self.margin + 90), 107 | str(player_score["planets"]), 108 | font=self._score_font, 109 | ) 110 | draw.text( 111 | (self.margin + (i + 1) * 170, self.margin + 130), 112 | str(player_score["total_ships"]), 113 | font=self._score_font, 114 | ) 115 | 116 | def render_planet(self, draw, planet): 117 | color = self.players_colors.get(planet.player, self.planet_color) 118 | centroid = self.galaxy2frame_coordinates(planet.position) 119 | bounding_box = ( 120 | ( 121 | centroid[0] - self.plantet_radius, 122 | centroid[1] - self.plantet_radius, 123 | ), 124 | ( 125 | centroid[0] + self.plantet_radius, 126 | centroid[1] + self.plantet_radius, 127 | ), 128 | ) 129 | draw.ellipse(bounding_box, fill=color) 130 | 131 | def render_ship(self, draw, ship): 132 | color = self.players_colors.get(ship.player, self.planet_color) 133 | centroid = self.galaxy2frame_coordinates(ship.position) 134 | bounding_box = ( 135 | (centroid[0] - self.ships_radius, centroid[1] - self.ships_radius), 136 | (centroid[0] + self.ships_radius, centroid[1] + self.ships_radius), 137 | ) 138 | draw.ellipse(bounding_box, fill=(0, 0, 0, 0), outline=color, width=3) 139 | 140 | def render_explosion(self, draw, explosion): 141 | centroid = self.galaxy2frame_coordinates(explosion.ship.position) 142 | # Scale size based on ships amount 143 | radius = max( 144 | 50, self.plantet_radius + int(explosion.ships_involved * 0.5) 145 | ) 146 | draw.ellipse( 147 | ( 148 | (centroid[0] - radius, centroid[1] - radius), 149 | (centroid[0] + radius, centroid[1] + radius), 150 | ), 151 | fill=self.explosion_color, 152 | outline=self.explosion_color, 153 | width=2, 154 | ) 155 | 156 | def save_gif(self, path): 157 | if not path.lower().endswith(".gif"): 158 | raise ValueError("Destination file must be a gif") 159 | image = self._frames[0] 160 | image.save( 161 | path, 162 | save_all=True, 163 | duration=self.speed, 164 | append_images=self._frames, 165 | ) 166 | return 167 | -------------------------------------------------------------------------------- /tests/orders/test_galaxy.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | 5 | from pythonium.orders.galaxy import ( 6 | ProduceResources, 7 | ResolvePlanetsConflicts, 8 | ResolveShipsConflicts, 9 | ) 10 | 11 | 12 | class TestProduceResources: 13 | @pytest.fixture() 14 | def player_planets_count(self, galaxy, faker): 15 | return len(list(galaxy.get_ocuped_planets())) 16 | 17 | @pytest.fixture() 18 | def order(self, galaxy): 19 | return ProduceResources(galaxy=galaxy) 20 | 21 | def test_produce_in_occuped_planets( 22 | self, order, mocker, galaxy, player_planets_count 23 | ): 24 | spy = mocker.spy(order, "_produce_resources") 25 | order.execute() 26 | assert spy.call_count == player_planets_count 27 | 28 | def test_produce_happypoints( 29 | self, order, colonized_planet, happypoints_tolerance 30 | ): 31 | dhappypoints = colonized_planet.dhappypoints 32 | happypoints = colonized_planet.happypoints 33 | order._produce_resources(colonized_planet) 34 | assert colonized_planet.happypoints == happypoints + dhappypoints 35 | assert colonized_planet.happypoints > happypoints_tolerance 36 | 37 | def test_produce_megacredits( 38 | self, order, colonized_planet, happypoints_tolerance 39 | ): 40 | dmegacredits = colonized_planet.dmegacredits 41 | megacredits = colonized_planet.megacredits 42 | order._produce_resources(colonized_planet) 43 | assert colonized_planet.megacredits == megacredits + dmegacredits 44 | assert colonized_planet.happypoints > happypoints_tolerance 45 | 46 | def test_produce_pythonium( 47 | self, order, colonized_planet, happypoints_tolerance 48 | ): 49 | dpythonium = colonized_planet.dpythonium 50 | pythonium = colonized_planet.pythonium 51 | order._produce_resources(colonized_planet) 52 | assert colonized_planet.pythonium == pythonium + dpythonium 53 | assert colonized_planet.happypoints > happypoints_tolerance 54 | 55 | def test_produce_clans( 56 | self, order, colonized_planet, happypoints_tolerance 57 | ): 58 | dclans = colonized_planet.dclans 59 | clans = colonized_planet.clans 60 | order._produce_resources(colonized_planet) 61 | assert colonized_planet.clans == clans + dclans 62 | assert colonized_planet.happypoints > happypoints_tolerance 63 | 64 | 65 | class TestResolveShipsConflicts: 66 | @pytest.fixture() 67 | def winner_ships(self, ships_in_conflict, winner): 68 | return [s for s in ships_in_conflict if s.player == winner] 69 | 70 | @pytest.fixture() 71 | def spy_remove_destroyed_ships(self, mocker, ships_in_conflict_galaxy): 72 | return mocker.spy(ships_in_conflict_galaxy, "remove_destroyed_ships") 73 | 74 | @pytest.fixture 75 | def expected_destroyed_ships(self, winner, ships_in_conflict): 76 | return [s for s in ships_in_conflict if s.player != winner] 77 | 78 | @pytest.fixture(autouse=True) 79 | def execute_order( 80 | self, 81 | spy_remove_destroyed_ships, 82 | ships_in_conflict_galaxy, 83 | tenacity, 84 | winner, 85 | ): 86 | order = ResolveShipsConflicts(ships_in_conflict_galaxy, tenacity) 87 | assert not ships_in_conflict_galaxy.explosions 88 | order.execute() 89 | return order 90 | 91 | @pytest.fixture 92 | def winner(self, ships_in_conflict_galaxy, mocker): 93 | winner = random.choice(list(ships_in_conflict_galaxy.known_races)) 94 | mocker.patch( 95 | "pythonium.orders.galaxy.ResolveShipsConflicts._compute_winner", 96 | return_value=winner, 97 | ) 98 | return winner 99 | 100 | def test_destroyed_ships_for_loosers( 101 | self, ships_in_conflict_galaxy, expected_destroyed_ships 102 | ): 103 | exploded_ships = [e.ship for e in ships_in_conflict_galaxy.explosions] 104 | assert exploded_ships == expected_destroyed_ships 105 | 106 | def test_remove_destroyed_ships(self, spy_remove_destroyed_ships): 107 | assert spy_remove_destroyed_ships.call_count == 1 108 | 109 | def test_winner_ships_still_exist( 110 | self, winner_ships, ships_in_conflict_galaxy 111 | ): 112 | assert all(s in ships_in_conflict_galaxy.ships for s in winner_ships) 113 | 114 | 115 | class TestResolvePlanetsConflicts: 116 | @pytest.fixture() 117 | def winner(self, planet_conflict_galaxy): 118 | planet, ships = next(planet_conflict_galaxy.get_planets_conflicts()) 119 | return ships[0].player 120 | 121 | @pytest.fixture() 122 | def conquered_planet_id(self, planet_conflict_galaxy): 123 | planet, ships = next(planet_conflict_galaxy.get_planets_conflicts()) 124 | return planet.id 125 | 126 | def test_ship_without_attack_do_nothing( 127 | self, mocker, planet_pasive_conflict_galaxy 128 | ): 129 | order = ResolvePlanetsConflicts(planet_pasive_conflict_galaxy) 130 | spy_resolve_conflict = mocker.spy(order, "_resolve_planets_conflicts") 131 | order.execute() 132 | assert spy_resolve_conflict.call_count == 0 133 | 134 | def test_enemy_ships_conquer_planet( 135 | self, conquered_planet_id, winner, planet_conflict_galaxy 136 | ): 137 | order = ResolvePlanetsConflicts(planet_conflict_galaxy) 138 | order.execute() 139 | conquered_planet = planet_conflict_galaxy.search_planet( 140 | conquered_planet_id 141 | ) 142 | assert conquered_planet.player == winner 143 | 144 | def test_conquered_planet_state( 145 | self, planet_conflict_galaxy, conquered_planet_id 146 | ): 147 | order = ResolvePlanetsConflicts(planet_conflict_galaxy) 148 | order.execute() 149 | conquered_planet = planet_conflict_galaxy.search_planet( 150 | conquered_planet_id 151 | ) 152 | assert conquered_planet.clans == 1 153 | assert conquered_planet.mines == 0 154 | assert conquered_planet.taxes == 0 155 | -------------------------------------------------------------------------------- /tests/orders/test_planet.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | from mockito import verify 5 | 6 | from pythonium import Galaxy, cfg 7 | from pythonium.orders.planet import ( 8 | PlanetBuildMinesOrder, 9 | PlanetBuildShipOrder, 10 | PlanetSetTaxesOrder, 11 | ) 12 | from tests.factories import ( 13 | PlanetFactory, 14 | PositiveTransferVectorFactory, 15 | ShipTypeFactory, 16 | ) 17 | 18 | 19 | class TestPlanetBuildMinesOrder: 20 | @pytest.fixture(autouse=True) 21 | def setup(self, faker): 22 | self.mines_cost = PositiveTransferVectorFactory(clans=0) 23 | self.available_mines = faker.pyint( 24 | min_value=1, max_value=cfg.planet_max_mines 25 | ) 26 | 27 | @pytest.fixture 28 | def planet(self): 29 | return PlanetFactory( 30 | pythonium=self.mines_cost.pythonium * self.available_mines, 31 | megacredits=self.mines_cost.megacredits * self.available_mines, 32 | clans=self.available_mines, 33 | mines=0, 34 | mine_cost=self.mines_cost, 35 | ) 36 | 37 | def test_no_new_mines(self, galaxy, planet): 38 | """ 39 | If `new_mines` are zero or planet can not build more mines, planet mines do not change 40 | """ 41 | empty_order = PlanetBuildMinesOrder(new_mines=0, planet=planet) 42 | empty_order.execute(galaxy) 43 | assert not planet.mines 44 | 45 | def test_new_mines(self, galaxy, planet): 46 | """ 47 | Planet mines increases according `new_mines` 48 | """ 49 | order = PlanetBuildMinesOrder( 50 | new_mines=self.available_mines, planet=planet 51 | ) 52 | order.execute(galaxy) 53 | assert order.planet.mines == self.available_mines 54 | 55 | def test_mines_cost(self, galaxy, planet): 56 | """ 57 | Planet resources decrease according `mine_cost` 58 | """ 59 | initial_pythonium = planet.pythonium 60 | initial_megacredits = planet.megacredits 61 | order = PlanetBuildMinesOrder( 62 | new_mines=self.available_mines, planet=planet 63 | ) 64 | order.execute(galaxy) 65 | assert ( 66 | planet.pythonium 67 | == initial_pythonium 68 | - self.mines_cost.pythonium * self.available_mines 69 | ) 70 | assert ( 71 | planet.megacredits 72 | == initial_megacredits 73 | - self.mines_cost.megacredits * self.available_mines 74 | ) 75 | 76 | def test_build_with_available_resources(self, galaxy, planet): 77 | """ 78 | Planet adjust `new_mines` according available resources 79 | """ 80 | order = PlanetBuildMinesOrder( 81 | new_mines=self.available_mines * 2, planet=planet 82 | ) 83 | order.execute(galaxy) 84 | assert planet.mines == self.available_mines 85 | 86 | 87 | class TestplanetBuildShipOrder: 88 | @pytest.fixture(autouse=True) 89 | def setup(self, galaxy, random_planet): 90 | self.ship_type = ShipTypeFactory() 91 | random_planet.pythonium = self.ship_type.cost.pythonium 92 | random_planet.megacredits = self.ship_type.cost.megacredits 93 | self.planet = random_planet 94 | self.planet_without_resources = PlanetFactory( 95 | pythonium=0, 96 | megacredits=0, 97 | ) 98 | 99 | @pytest.fixture 100 | def build_ship_order(self, galaxy, when): 101 | order = PlanetBuildShipOrder( 102 | ship_type=self.ship_type, planet=self.planet 103 | ) 104 | return order 105 | 106 | @pytest.fixture 107 | def execute_build_ship_order(self, galaxy, build_ship_order): 108 | build_ship_order.execute(galaxy) 109 | 110 | @pytest.fixture 111 | @pytest.mark.usefixtures("execute_build_ship_order") 112 | def new_ship(self, galaxy): 113 | return galaxy.ships[-1] 114 | 115 | def test_cant_build_ship(self, galaxy, when): 116 | """ 117 | If planet can not build the ship, this does nothing 118 | """ 119 | when(Galaxy).add_ship(...) 120 | order = PlanetBuildShipOrder( 121 | ship_type=self.ship_type, planet=self.planet_without_resources 122 | ) 123 | order.execute(galaxy) 124 | verify(Galaxy, times=0).add_ship(...) 125 | 126 | def test_new_ship(self, galaxy, build_ship_order, when): 127 | """ 128 | Planet build the ship 129 | """ 130 | when(Galaxy).add_ship(...) 131 | build_ship_order.execute(galaxy) 132 | verify(Galaxy).add_ship(...) 133 | 134 | @pytest.mark.usefixtures("execute_build_ship_order") 135 | def test_ship_type_cost(self): 136 | """ 137 | Planet resources decrease according `ship_type` cost 138 | """ 139 | assert self.planet.pythonium == 0 140 | assert self.planet.megacredits == 0 141 | 142 | @pytest.mark.usefixtures("execute_build_ship_order") 143 | def test_ship_position_in_planet(self, galaxy, new_ship): 144 | """ 145 | Ship is placed in the planet 146 | """ 147 | assert new_ship.position == self.planet.position 148 | 149 | 150 | class TestPlanetSetTaxesOrder: 151 | @pytest.fixture(autouse=True) 152 | def setup(self, faker): 153 | self.current_taxes = faker.pyint(min_value=10, max_value=49) 154 | self.planet = PlanetFactory(taxes=self.current_taxes) 155 | 156 | def test_taxes_dont_change(self, galaxy): 157 | """ 158 | If set taxes are equal to current taxes, does nothing 159 | """ 160 | order = PlanetSetTaxesOrder( 161 | planet=self.planet, taxes=self.current_taxes 162 | ) 163 | order.execute(galaxy) 164 | assert self.planet.taxes == self.current_taxes 165 | 166 | def test_taxes(self, galaxy, faker): 167 | """ 168 | If set taxes are equal to current taxes, does nothing 169 | """ 170 | new_taxes = faker.pyint(min_value=50, max_value=100) 171 | order = PlanetSetTaxesOrder(planet=self.planet, taxes=new_taxes) 172 | order.execute(galaxy) 173 | assert self.planet.taxes == new_taxes 174 | 175 | def test_taxes_lower_than_zero(self, galaxy, faker): 176 | """ 177 | If set taxes are lower than zero, taxes are set to zero 178 | """ 179 | new_taxes = faker.pyint(max_value=0) 180 | order = PlanetSetTaxesOrder(planet=self.planet, taxes=new_taxes) 181 | order.execute(galaxy) 182 | assert self.planet.taxes == 0 183 | 184 | def test_taxes_grather_than_100(self, galaxy, faker): 185 | """ 186 | If set taxes are grather than 100, taxes are set to 100 187 | """ 188 | new_taxes = faker.pyint(min_value=100) 189 | order = PlanetSetTaxesOrder(planet=self.planet, taxes=new_taxes) 190 | order.execute(galaxy) 191 | assert self.planet.taxes == 100 192 | -------------------------------------------------------------------------------- /pythonium/orders/galaxy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from itertools import groupby 3 | 4 | import attr 5 | import numpy as np 6 | 7 | from ..explosion import Explosion 8 | from .core import GalaxyOrder 9 | 10 | logger = logging.getLogger("game") 11 | 12 | 13 | @attr.s() 14 | class ProduceResources(GalaxyOrder): 15 | 16 | name = "produce_resources" 17 | 18 | def execute(self) -> None: 19 | for planet in self.galaxy.get_ocuped_planets(): 20 | self._produce_resources(planet) 21 | 22 | def _produce_resources(self, planet): 23 | dhappypoints = planet.dhappypoints 24 | if dhappypoints: 25 | planet.happypoints += dhappypoints 26 | logger.info( 27 | "Happypoints change", 28 | extra={ 29 | "turn": self.galaxy.turn, 30 | "player": planet.player, 31 | "planet": planet.id, 32 | "dhappypoints": dhappypoints, 33 | "happypoints": planet.happypoints, 34 | }, 35 | ) 36 | 37 | dmegacredits = planet.dmegacredits 38 | if dmegacredits: 39 | planet.megacredits += dmegacredits 40 | logger.info( 41 | "Megacredits change", 42 | extra={ 43 | "turn": self.galaxy.turn, 44 | "player": planet.player, 45 | "planet": planet.id, 46 | "dmegacredits": dmegacredits, 47 | "megacredits": planet.megacredits, 48 | }, 49 | ) 50 | 51 | dpythonium = planet.dpythonium 52 | if dpythonium: 53 | planet.pythonium += dpythonium 54 | logger.info( 55 | "Pythonium change", 56 | extra={ 57 | "turn": self.galaxy.turn, 58 | "player": planet.player, 59 | "planet": planet.id, 60 | "dpythonium": dpythonium, 61 | "pythonium": planet.pythonium, 62 | }, 63 | ) 64 | 65 | dclans = planet.dclans 66 | if dclans: 67 | planet.clans += dclans 68 | logger.info( 69 | "Population change", 70 | extra={ 71 | "turn": self.galaxy.turn, 72 | "player": planet.player, 73 | "planet": planet.id, 74 | "dclans": dclans, 75 | "clans": planet.clans, 76 | }, 77 | ) 78 | 79 | 80 | @attr.s() 81 | class ResolveShipsConflicts(GalaxyOrder): 82 | 83 | name = "resolve_ships_conflicts" 84 | tenacity: float = attr.ib() 85 | 86 | def execute(self) -> None: 87 | ships_in_conflict = self.galaxy.get_ships_conflicts() 88 | for ships in ships_in_conflict: 89 | self._resolve_ships_conflicts(ships) 90 | self.galaxy.remove_destroyed_ships() 91 | 92 | def _compute_winner(self, ships, total_attack): 93 | """ 94 | Due to the randomness of the fighting process, this method is not tested 95 | """ 96 | groups = groupby(ships, lambda s: s.player) 97 | 98 | max_score = 0 99 | winner = None 100 | for player, player_ships in groups: 101 | player_attack = sum((s.attack for s in player_ships)) 102 | attack_fraction = player_attack / total_attack 103 | 104 | # Compute score probability distribution 105 | shape = 100 * attack_fraction 106 | score = np.random.normal(shape, self.tenacity) 107 | 108 | logger.info( 109 | "Score in conflict", 110 | extra={ 111 | "turn": self.galaxy.turn, 112 | "player": player, 113 | "player_attack": player_attack, 114 | "attack_fraction": attack_fraction, 115 | "score": score, 116 | }, 117 | ) 118 | if score > max_score: 119 | winner = player 120 | max_score = score 121 | 122 | logger.info( 123 | "Conflict resolved", 124 | extra={ 125 | "turn": self.galaxy.turn, 126 | "winner": winner, 127 | "max_score": max_score, 128 | "total_attack": total_attack, 129 | "total_ships": len(ships), 130 | }, 131 | ) 132 | return winner 133 | 134 | def _resolve_ships_conflicts(self, ships): 135 | total_attack = sum(s.attack for s in ships) 136 | winner = self._compute_winner(ships, total_attack) 137 | # Destroy defeated ships 138 | for ship in ships: 139 | if ship.player == winner: 140 | continue 141 | logger.info( 142 | "Explosion", 143 | extra={ 144 | "turn": self.galaxy.turn, 145 | "player": ship.player, 146 | "ship": ship.id, 147 | "ship_type": ship.type.name, 148 | "position": ship.position, 149 | }, 150 | ) 151 | self.galaxy.explosions.append( 152 | Explosion( 153 | ship=ship, 154 | ships_involved=len(ships), 155 | total_attack=total_attack, 156 | ) 157 | ) 158 | 159 | 160 | @attr.s() 161 | class ResolvePlanetsConflicts(GalaxyOrder): 162 | 163 | name = "resolve_planets_conflicts" 164 | 165 | def execute(self) -> None: 166 | planets_in_conflict = self.galaxy.get_planets_conflicts() 167 | for planet, ships in planets_in_conflict: 168 | if not sum((s.attack for s in ships)): 169 | continue 170 | self._resolve_planets_conflicts(planet, ships) 171 | 172 | def _resolve_planets_conflicts(self, planet, ships): 173 | enemies = {s.player for s in ships if s.player != planet.player} 174 | 175 | if not enemies: 176 | raise ValueError( 177 | "Ok, I don't know what's going on. This is not a conflict." 178 | ) 179 | 180 | if len(enemies) != 1: 181 | raise ValueError( 182 | "Run :meth:`resolve_ships_to_ship_conflict` first" 183 | ) 184 | 185 | winner = enemies.pop() 186 | 187 | # If is not of his own, the winner conquer the planet. 188 | if planet.player != winner: 189 | logger.info( 190 | "Planet conquered by force", 191 | extra={ 192 | "turn": self.galaxy.turn, 193 | "player": winner, 194 | "planet": planet.id, 195 | "clans": planet.clans, 196 | }, 197 | ) 198 | planet.player = winner 199 | planet.clans = 1 200 | planet.mines = 0 201 | planet.taxes = 0 202 | -------------------------------------------------------------------------------- /tests/test_galaxy.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | import pytest 5 | 6 | from pythonium import Galaxy 7 | from tests.factories import ExplosionFactory, ShipFactory, fake_positions 8 | 9 | 10 | class TestGalaxyBasics: 11 | @pytest.fixture 12 | def expected_repr(self, galaxy): 13 | return f"Galaxy(size={galaxy.size}, planets={len(galaxy.planets)})" 14 | 15 | @pytest.fixture 16 | def another_random_position(self, galaxy_size): 17 | return next(fake_positions(galaxy_size)) 18 | 19 | @pytest.fixture 20 | def expected_distance_between_positions( 21 | self, random_position, another_random_position 22 | ): 23 | a = random_position[0] - another_random_position[0] 24 | b = random_position[1] - another_random_position[1] 25 | return math.sqrt(a**2 + b**2) 26 | 27 | def test_repr(self, galaxy, expected_repr): 28 | assert str(galaxy) == expected_repr 29 | assert str(galaxy) == galaxy.__repr__() 30 | 31 | def test_planet_index(self, planets, galaxy): 32 | for planet in planets: 33 | assert galaxy.planets[planet.position] is planet 34 | 35 | def test_ship_index(self, ships, galaxy): 36 | assert galaxy.ships == ships 37 | 38 | def test_compute_distance( 39 | self, 40 | galaxy, 41 | random_position, 42 | another_random_position, 43 | expected_distance_between_positions, 44 | ): 45 | distance = galaxy.compute_distance( 46 | random_position, another_random_position 47 | ) 48 | assert distance == expected_distance_between_positions 49 | 50 | def test_known_races(self, expected_players, galaxy): 51 | assert all(player in galaxy.known_races for player in expected_players) 52 | 53 | 54 | class TestDistancesToPlanets: 55 | @pytest.fixture 56 | def expected_distances(self, faker, when, random_position, planets): 57 | expected_distances = {} 58 | for planet in planets: 59 | random_distance = faker.pyint(min_value=0) 60 | when(Galaxy).compute_distance( 61 | planet.position, 62 | random_position, 63 | ).thenReturn(random_distance) 64 | expected_distances[planet.position] = random_distance 65 | return expected_distances 66 | 67 | def test_distances_to_planets( 68 | self, expected_distances, random_position, galaxy 69 | ): 70 | assert expected_distances == galaxy.distances_to_planets( 71 | random_position 72 | ) 73 | 74 | 75 | class TestNearbyPlanets: 76 | @pytest.fixture 77 | def speed(self, faker): 78 | return faker.pyint(min_value=50, max_value=200) 79 | 80 | @pytest.mark.parametrize("turns", list(range(1, 6))) 81 | def test_nearby_planets(self, galaxy, random_position, turns, speed): 82 | nearby_planets = galaxy.nearby_planets( 83 | point=random_position, neighborhood=turns * speed 84 | ) 85 | for planet in nearby_planets: 86 | assert ( 87 | galaxy.compute_distance(planet.position, random_position) 88 | <= turns * speed 89 | ) 90 | 91 | 92 | class TestGetPlayerPlanets: 93 | @pytest.fixture 94 | def expected_planets(self, planets, random_player): 95 | return list(filter(lambda p: p.player == random_player, planets)) 96 | 97 | def test_get_player_planets(self, galaxy, expected_planets, random_player): 98 | assert ( 99 | list(galaxy.get_player_planets(random_player)) == expected_planets 100 | ) 101 | 102 | def test_get_player_planets_fake_player(self, galaxy, faker): 103 | assert not list(galaxy.get_player_planets(faker.word())) 104 | 105 | 106 | class TestSearchPlanet: 107 | def test_search_planet(self, galaxy, random_planet): 108 | assert galaxy.search_planet(random_planet.id) is random_planet 109 | 110 | def test_planet_not_found(self, galaxy, fake_id): 111 | assert galaxy.search_planet(fake_id) is None 112 | 113 | 114 | class TestOcupedPlanets: 115 | @pytest.fixture 116 | def expected_ocuped_planets(self, planets): 117 | return [planet for planet in planets if planet.player is not None] 118 | 119 | def test_ocuped_planets(self, galaxy, expected_ocuped_planets): 120 | assert list(galaxy.get_ocuped_planets()) == expected_ocuped_planets 121 | 122 | 123 | class TestAddShip: 124 | @pytest.fixture 125 | def new_ship(self, galaxy_size, expected_players, random_position): 126 | return ShipFactory( 127 | position=random_position, 128 | player=random.choice(expected_players), 129 | ) 130 | 131 | def test_add_ship(self, galaxy, new_ship): 132 | assert new_ship not in galaxy.ships 133 | galaxy.add_ship(new_ship) 134 | assert new_ship in galaxy.ships 135 | 136 | 137 | class TestGetPlayerShips: 138 | @pytest.fixture 139 | def expected_ships(self, ships, random_player): 140 | return [ship for ship in ships if ship.player == random_player] 141 | 142 | def test_get_player_ships(self, galaxy, expected_ships, random_player): 143 | assert list(galaxy.get_player_ships(random_player)) == expected_ships 144 | 145 | def test_get_player_ships_fake_player(self, galaxy, faker): 146 | assert not list(galaxy.get_player_ships(faker.word())) 147 | 148 | 149 | class TestSearchShip: 150 | def test_search_ship(self, galaxy, random_ship): 151 | assert galaxy.search_ship(random_ship.id) is random_ship 152 | 153 | def test_ship_not_found(self, galaxy, fake_id): 154 | assert galaxy.search_ship(fake_id) is None 155 | 156 | 157 | class TestRemoveDestroyedShips: 158 | @pytest.fixture 159 | def explosions(self, random_ship, faker): 160 | return [ExplosionFactory(ship=random_ship)] 161 | 162 | def test_remove_destroyed_ships(self, galaxy, random_ship, explosions): 163 | galaxy.explosions = explosions 164 | assert random_ship in galaxy.ships 165 | galaxy.remove_destroyed_ships() 166 | assert random_ship not in galaxy.ships 167 | galaxy.add_ship(random_ship) 168 | 169 | 170 | class TestGetPlanetsConflicts: 171 | @pytest.fixture 172 | def expected_ships_in_conflict(self, ships_in_planets_galaxy): 173 | return ships_in_planets_galaxy.get_ships_by_position() 174 | 175 | def test_planets_conflicts( 176 | self, expected_ships_in_conflict, ships_in_planets_galaxy 177 | ): 178 | conflicts = ships_in_planets_galaxy.get_planets_conflicts() 179 | for planet, ships in conflicts: 180 | assert ships == expected_ships_in_conflict[planet.position] 181 | 182 | 183 | class TestGetShipsConflicts: 184 | def test_ships_conflicts( 185 | self, ships_in_conflict_galaxy, ships_in_conflict 186 | ): 187 | conflicts = ships_in_conflict_galaxy.get_ships_conflicts() 188 | assert [ships_in_conflict] == list(conflicts) 189 | 190 | 191 | class TestGetShipsInPlanets: 192 | @pytest.fixture 193 | def expected_ships_in_planets(self, ships_in_planets_galaxy): 194 | return ships_in_planets_galaxy.get_ships_by_position() 195 | 196 | def test_ships_in_planets( 197 | self, ships_in_planets_galaxy, expected_ships_in_planets 198 | ): 199 | result = ships_in_planets_galaxy.get_ships_in_planets() 200 | for planet, ships in result: 201 | assert ships == expected_ships_in_planets[planet.position] 202 | 203 | 204 | class TestGetShipsByPosition: 205 | @pytest.fixture 206 | def ships_by_position(self, galaxy): 207 | return galaxy.get_ships_by_position() 208 | 209 | def test_all_ships_in_ships_by_position(self, ships_by_position, galaxy): 210 | all_ships = [ 211 | s 212 | for ships_group in ships_by_position.values() 213 | for s in ships_group 214 | ] 215 | assert all_ships == galaxy.ships 216 | 217 | def test_ships_by_position(self, ships_by_position): 218 | for position, ships in ships_by_position.items(): 219 | for ship in ships: 220 | assert ship.position == position 221 | 222 | 223 | class TestGetShipsInPosition: 224 | @pytest.fixture 225 | def position(self, random_ship): 226 | return random_ship.position 227 | 228 | @pytest.fixture 229 | def another_ship_in_position(self, position, galaxy): 230 | ship = ShipFactory(position=position) 231 | galaxy.add_ship(ship) 232 | return ship 233 | 234 | @pytest.fixture 235 | def ships_in_position(self, random_ship, another_ship_in_position): 236 | return [random_ship, another_ship_in_position] 237 | 238 | def test_ships_in_position(self, galaxy, position, ships_in_position): 239 | assert ( 240 | list(galaxy.get_ships_in_position(position)) == ships_in_position 241 | ) 242 | 243 | 244 | class TestGetShipsInDeepSpace: 245 | def test_ships_in_deep_space(self, galaxy, ships): 246 | assert list(galaxy.get_ships_in_deep_space()) == ships 247 | -------------------------------------------------------------------------------- /pythonium/galaxy.py: -------------------------------------------------------------------------------- 1 | from itertools import groupby 2 | from typing import Dict, Iterable, Iterator, List, Set, Tuple 3 | 4 | import numpy as np 5 | 6 | from . import cfg 7 | from .core import Position, StellarThing 8 | from .explosion import Explosion 9 | from .planet import Planet 10 | from .ship import Ship 11 | 12 | 13 | class Galaxy: 14 | """ 15 | Galaxy of planets that represents the map of the game, and all the \ 16 | known universe of things. 17 | 18 | :param size: Galaxy height and width 19 | :param things: Stellar things that compound the galaxy 20 | :param explosions: Known explosions in the galaxy 21 | :param turn: Time in galaxy 22 | """ 23 | 24 | def __init__( 25 | self, 26 | name: str, 27 | size: Position, 28 | things: List[StellarThing], 29 | explosions: List[Explosion] = None, 30 | turn: int = 0, 31 | ): 32 | self.name = name 33 | """ 34 | Galaxxy name. 35 | """ 36 | 37 | self.turn: int = turn 38 | """ 39 | Turn or actual time in the galaxy 40 | """ 41 | 42 | self.size: Position = size 43 | """ 44 | Width and height of the galaxy 45 | """ 46 | 47 | self.stellar_things = things 48 | """ 49 | All the things that compounds the galaxy 50 | """ 51 | 52 | self._planets: Dict[Position, Planet] = {} 53 | """ 54 | All the :class:`Planet` in the galaxy indexed by position 55 | """ 56 | 57 | self._ships: List[Ship] = [] 58 | """ 59 | A list with all the :class:`Ship` in the galaxy 60 | """ 61 | 62 | self.explosions: List[Explosion] = explosions or [] 63 | """ 64 | A list with all the recent :class:`Explosion`. 65 | """ 66 | 67 | @property 68 | def planets(self): 69 | if not self._planets: 70 | planets = filter( 71 | lambda t: isinstance(t, Planet), self.stellar_things 72 | ) 73 | self._planets = {p.position: p for p in planets} 74 | return self._planets 75 | 76 | @property 77 | def ships(self): 78 | if not self._ships: 79 | self._ships = list( 80 | filter(lambda t: isinstance(t, Ship), self.stellar_things) 81 | ) 82 | return self._ships 83 | 84 | def __str__(self): 85 | return f"Galaxy(size={self.size}, planets={len(self.planets)})" 86 | 87 | def __repr__(self): 88 | return self.__str__() 89 | 90 | @property 91 | def known_races(self) -> Set[str]: 92 | """ 93 | List all the known races that own at least one ship or one planet. 94 | """ 95 | return { 96 | thing.player 97 | for thing in self.stellar_things 98 | if thing.player is not None 99 | } 100 | 101 | @staticmethod 102 | def compute_distance(a: Position, b: Position) -> float: 103 | """ 104 | Compute the distance in ly between two points (usually two ``position`` attributes). 105 | 106 | :param a: Point of origin to compute the distance 107 | :param b: Point of destination to compute the distance 108 | """ 109 | return np.linalg.norm(np.array(a) - np.array(b)) 110 | 111 | def add_ship(self, ship: Ship): 112 | """ 113 | Add a new ship to the known ships in the galaxy and assign an Id to it. 114 | """ 115 | self.stellar_things.append(ship) 116 | self._ships = [] 117 | 118 | def distances_to_planets(self, point: Position) -> Dict[Position, float]: 119 | """ 120 | Compute the distance between the ``point`` and all the planets in the galaxy. 121 | """ 122 | return dict( 123 | ((p, self.compute_distance(p, point)) for p in self.planets.keys()) 124 | ) 125 | 126 | def nearby_planets( 127 | self, 128 | point: Position, 129 | neighborhood: int, 130 | ) -> List[Planet]: 131 | """ 132 | Return all the planets that are ``neighborhood`` light-years away or less. 133 | """ 134 | distances = self.distances_to_planets(point) 135 | return list( 136 | filter( 137 | lambda p: distances[p.position] <= neighborhood, 138 | self.planets.values(), 139 | ) 140 | ) 141 | 142 | def get_player_planets(self, player: str) -> Iterable[Planet]: 143 | """ 144 | Returns an iterable for all the known planets that belongs to``player`` 145 | """ 146 | for planet in filter( 147 | lambda p: p.player == player, self.planets.values() 148 | ): 149 | yield planet 150 | 151 | def get_player_ships(self, player: str) -> Iterable[Ship]: 152 | """ 153 | Returns an iterable for known ships that belong to ``player`` 154 | """ 155 | for ship in filter(lambda s: s.player == player, self.ships): 156 | yield ship 157 | 158 | def get_ships_in_deep_space(self) -> Iterable[Ship]: 159 | """ 160 | Returns an iterable for all the ships that are not located on a planet 161 | """ 162 | positions = {n.position for n in self.ships}.difference( 163 | set(self.planets.keys()) 164 | ) 165 | for ship in filter(lambda s: s.position in positions, self.ships): 166 | yield ship 167 | 168 | def get_ships_in_position(self, position: Position) -> Iterable[Ship]: 169 | """ 170 | Returns an iterable for all the known ships in the given position 171 | """ 172 | for ship in filter(lambda s: s.position == position, self.ships): 173 | yield ship 174 | 175 | def search_ship(self, _id: int) -> Ship: 176 | """ 177 | Return the ship with ID ``id`` if any and is known 178 | """ 179 | match = [ship for ship in self.ships if ship.id == _id] 180 | if match: 181 | return match.pop() 182 | 183 | def search_planet(self, _id: int) -> Planet: 184 | """ 185 | Return the planet with ID ``id`` if any 186 | """ 187 | match = [ 188 | planet for planet in self.planets.values() if planet.id == _id 189 | ] 190 | if match: 191 | return match.pop() 192 | 193 | def get_ships_by_position(self) -> Dict[Position, List[Ship]]: 194 | """ 195 | Returns a dict with ships ordered by position. 196 | Ships in the same positions are grouped in a list. 197 | """ 198 | return dict( 199 | ( 200 | (position, list(ships)) 201 | for position, ships in groupby( 202 | self.ships, lambda s: s.position 203 | ) 204 | ) 205 | ) 206 | 207 | def get_ships_in_planets(self) -> Iterable[Tuple[Planet, List[Ship]]]: 208 | """ 209 | Return a list of tuples ``(planet, ships)`` where ``planet`` is a :class:`Planet` 210 | instance and ``ships`` is a list with all the ships located on the planet 211 | """ 212 | ships_by_position = self.get_ships_by_position() 213 | for position, planet in self.planets.items(): 214 | ships = ships_by_position.get(position) 215 | if not ships: 216 | continue 217 | yield planet, ships 218 | 219 | def get_ships_conflicts(self) -> Iterable[List[Ship]]: 220 | """ 221 | Return all the ships in conflict: Ships with, at last, one enemy ship 222 | in the same position 223 | """ 224 | ships_by_position = [ 225 | list(ships) 226 | for pos, ships in groupby(self.ships, lambda s: s.position) 227 | ] 228 | # keep only the groups with more than one player 229 | for ships in filter( 230 | lambda ships: len({s.player for s in ships if s.player}) > 1 231 | and any((s.attack for s in ships)), 232 | ships_by_position, 233 | ): 234 | yield ships 235 | 236 | def get_ocuped_planets(self) -> Iterable[Planet]: 237 | """ 238 | Return all the planets colonized by any race 239 | """ 240 | for planet in filter( 241 | lambda p: p.player is not None, self.planets.values() 242 | ): 243 | yield planet 244 | 245 | def get_planets_conflicts(self) -> Iterable[Tuple[Planet, List[Ship]]]: 246 | """ 247 | Return all the planets in conflict: Planets with at least one enemy ship on it 248 | """ 249 | ships_by_position = self.get_ships_by_position() 250 | destroyed_ships = [e.ship for e in self.explosions] 251 | for planet in self.get_ocuped_planets(): 252 | ships = ships_by_position.get(planet.position) 253 | if not ships or not any( 254 | s 255 | for s in ships 256 | if s.player != planet.player and s not in destroyed_ships 257 | ): 258 | continue 259 | yield planet, ships 260 | 261 | def remove_destroyed_ships(self): 262 | """ 263 | Remove the destroyed ships from the list 264 | """ 265 | explosions_ids = [e.ship.id for e in self.explosions] 266 | self.stellar_things = list( 267 | filter( 268 | lambda things: things.id not in explosions_ids, 269 | self.stellar_things, 270 | ) 271 | ) 272 | self._ships = [] 273 | -------------------------------------------------------------------------------- /pythonium/planet.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from . import cfg, validators 4 | from .core import Position, StellarThing 5 | from .ship_type import ShipType 6 | from .vectors import Transfer 7 | 8 | 9 | @attr.s(auto_attribs=True, repr=False) 10 | class Planet(StellarThing): 11 | """ 12 | A planet that belongs to a :class:`Galaxy` 13 | 14 | Represent a planet that can be colonized. 15 | 16 | The ``temperature`` on the planet will determine how happy the ``clans`` can be 17 | (temperature defines the ``max_happypoints`` for the planet). 18 | 19 | Each planet has some amount of ``pythonium`` on the surface that can be used by the 20 | colonizer race, and some ``underground_pythonium`` (with a certain ``concentration``) 21 | that needs to be extracted with ``mines``. 22 | 23 | The higher the ``taxes`` are over ``cfg.tolerable_taxes``, the faster the 24 | ``happypoints`` decay. 25 | 26 | The lower the ``happypoints`` are over ``cfg.happypoints_tolerance``, the lower the 27 | planet production of ``clans``, ``pythonium``, and ``megacredits`` will be 28 | (in proportion of ``rioting_index``). 29 | """ 30 | 31 | temperature: int = attr.ib( 32 | validator=validators.number_between_zero_100, kw_only=True 33 | ) 34 | """ 35 | The temperature of the planet. It is always between zero and 100. 36 | """ 37 | 38 | underground_pythonium: int = attr.ib( 39 | validator=[attr.validators.instance_of(int)], kw_only=True 40 | ) 41 | """ 42 | Amount of pythonium under the surface of the planet, that needs to be extracted with 43 | mines. 44 | """ 45 | 46 | concentration: float = attr.ib( 47 | validator=[validators.is_valid_ratio], kw_only=True 48 | ) 49 | """ 50 | Indicates how much pythonium extract one mine. It is always between zero and 1. 51 | """ 52 | 53 | pythonium: int = attr.ib( 54 | validator=[attr.validators.instance_of(int)], kw_only=True 55 | ) 56 | """ 57 | Pythonium in the surface of the planet. The available resource to build things. 58 | """ 59 | 60 | mine_cost: Transfer = attr.ib( 61 | validator=[attr.validators.instance_of(Transfer)], kw_only=True 62 | ) 63 | """ 64 | Indicates the cost of building one mine. 65 | """ 66 | 67 | # State in turn 68 | player: str = attr.ib(default=None, kw_only=True) 69 | """ 70 | The owner of the planet or ``None`` if no one owns it. 71 | """ 72 | 73 | megacredits: int = attr.ib(converter=int, default=0, kw_only=True) 74 | """ 75 | Amount of megacredits on the planet 76 | """ 77 | 78 | clans: int = attr.ib(converter=int, default=0, kw_only=True) 79 | """ 80 | Amount of clans on the planet 81 | """ 82 | 83 | mines: int = attr.ib(converter=int, default=0, kw_only=True) 84 | """ 85 | Amount of mines on the planet 86 | """ 87 | 88 | max_happypoints: int = attr.ib(converter=int, init=False, kw_only=True) 89 | """ 90 | The maximum level of happypoints that the population of this planet can reach. 91 | 92 | It is based on the planet's temperature. 93 | 94 | Its maximum value (100) is reached when the planet's temperature is equal \ 95 | to ``cfg.optimal_temperature`` 96 | """ 97 | 98 | happypoints: int = attr.ib(converter=int, init=False, kw_only=True) 99 | """ 100 | The level of happyness on the planet. 101 | 102 | This has influence on ``rioting_index`` 103 | """ 104 | 105 | # User controls 106 | new_mines: int = attr.ib( 107 | validator=[attr.validators.instance_of(int)], default=0, kw_only=True 108 | ) 109 | """ 110 | **Attribute that can be modified by the player** 111 | 112 | New mines that the player order to build in the current turn. This value is set to 113 | zero when the player orders are executed. 114 | """ 115 | 116 | new_ship: ShipType = attr.ib( 117 | default=None, 118 | validator=[attr.validators.instance_of((ShipType, type(None)))], 119 | kw_only=True, 120 | ) 121 | """ 122 | **Attribute that can be modified by the player** 123 | 124 | The new ship that the player order to build in the current turn. 125 | 126 | This value is set to ``None`` when the player orders are executed 127 | """ 128 | 129 | taxes: int = attr.ib( 130 | converter=int, 131 | default=0, 132 | validator=[validators.is_valid_ratio], 133 | kw_only=True, 134 | ) 135 | """ 136 | **Attribute that can be modified by the player** 137 | 138 | Taxes set by the player. Must be between zero and 100. 139 | 140 | The level of taxes defines how much megacredits will be collected in the current turn. 141 | 142 | If the taxes are higher than ``cfg.tolerable_taxes`` the planet's happypoints will 143 | decay ``planet.taxes - cfg.tolerable_taxes`` per turn. 144 | 145 | See :attr:`dmegacredits` 146 | """ 147 | taxes_collection_factor: int = attr.ib( 148 | default=cfg.taxes_collection_factor, 149 | validator=[validators.is_valid_ratio], 150 | init=False, 151 | ) 152 | 153 | def __attrs_post_init__(self): 154 | # `max_happypoints` will decay as long as `temperature` differ from 155 | # `cfg.optimal_temperature`. `max_happypoints` can't be less than 156 | # `cfg.happypoints_tolerance` 157 | self.max_happypoints = max( 158 | 100 - abs(self.temperature - cfg.optimal_temperature), 159 | cfg.optimal_temperature, 160 | ) 161 | self.happypoints = self.max_happypoints 162 | 163 | def __str__(self): 164 | return f"Planet(id={self.id}, position={self.position}, player={self.player})" 165 | 166 | def __repr__(self): 167 | return self.__str__() 168 | 169 | @property 170 | def max_mines(self) -> int: 171 | """ 172 | The maximum number of mines that can be build in the planet 173 | """ 174 | return min(self.clans, cfg.planet_max_mines) 175 | 176 | @property 177 | def rioting_index(self) -> float: 178 | """ 179 | If the ``happypoints`` are less than ``cfg.happypoints_tolerance`` this \ 180 | property indicates how much this unhappiness will affect the planet's economy. 181 | 182 | i.e: if ``rioting_index`` is 0.5 pythonium production, and megacredits \ 183 | recollection will be %50 less than its standard level. 184 | """ 185 | return min(self.happypoints / cfg.happypoints_tolerance, 1) 186 | 187 | @property 188 | def dpythonium(self) -> int: 189 | """ 190 | Absolute change in pythonium for the next turn considering: 191 | 192 | * ``mines``: Positive influence, 193 | * ``concentration``: Positive influence, 194 | * ``rioting_index``: Positive influence. 195 | 196 | Do not consider ``new_mines`` and ``new_ship`` cost, or ship transfers. 197 | """ 198 | mines_extraction = self.mines * self.concentration * self.rioting_index 199 | 200 | return max(int(mines_extraction), -self.pythonium) 201 | 202 | @property 203 | def dmegacredits(self) -> int: 204 | """ 205 | Absolute change in megacredits for the next turn considering: 206 | 207 | * ``taxes``: Positive influence 208 | * ``rioting_index``: Negative influence, 209 | * ``clans``: Positive influence, 210 | * ``taxes_collection_factor``: Positive influence 211 | 212 | Do not consider ``new_mines`` and ``new_ship`` cost, or ship transfers. 213 | """ 214 | taxes = ( 215 | self.taxes_collection_factor 216 | * self.clans 217 | * self.taxes 218 | / 100 219 | * self.rioting_index 220 | ) 221 | 222 | return int(taxes) 223 | 224 | @property 225 | def dhappypoints(self) -> int: 226 | """ 227 | Absolute change on happypoints for the next turn 228 | """ 229 | unbounded_dhappypoints = cfg.tolerable_taxes - self.taxes 230 | if unbounded_dhappypoints < 0: 231 | return max(unbounded_dhappypoints, -self.happypoints) 232 | else: 233 | return min( 234 | unbounded_dhappypoints, self.max_happypoints - self.happypoints 235 | ) 236 | 237 | @property 238 | def dclans(self) -> int: 239 | """ 240 | Aditional clans for the next turn. The absolute change 241 | in the population considering: 242 | 243 | * ``rioting_index``: Negative influence, 244 | * ``cfg.max_population_rate``: Positive influence, 245 | * ``cfg.max_clans_in_planet``: Positive influence, 246 | * ``clans``: Positive influence 247 | """ 248 | growth_rate = cfg.max_population_rate * self.rioting_index 249 | return min( 250 | cfg.max_clans_in_planet - self.clans, 251 | max(int(self.clans * growth_rate), -self.clans), 252 | ) 253 | 254 | def get_orders(self): 255 | """ 256 | Compute orders based on player control attributes: ``new_mines``, \ 257 | ``new_ship`` and ``taxes`` 258 | """ 259 | orders = [("planet_set_taxes", self.id, self.taxes)] 260 | 261 | if self.new_ship is not None: 262 | orders.append(("planet_build_ship", self.id, self.new_ship)) 263 | 264 | if self.new_mines > 0: 265 | orders.append(("planet_build_mines", self.id, self.new_mines)) 266 | 267 | return orders 268 | 269 | def can_build_mines(self) -> int: 270 | """ 271 | Computes the number of mines that can be built on the planet based \ 272 | on available resources and ``max_mines`` 273 | """ 274 | return int( 275 | min( 276 | self.pythonium / self.mine_cost.pythonium, 277 | self.megacredits / self.mine_cost.megacredits, 278 | self.max_mines - self.mines, 279 | ) 280 | ) 281 | 282 | def can_build_ship(self, ship_type: ShipType) -> bool: 283 | """ 284 | Indicates whether ``ship_type`` can be built on this planet \ 285 | with the available resources 286 | """ 287 | return ( 288 | self.megacredits >= ship_type.cost.megacredits 289 | and self.pythonium >= ship_type.cost.pythonium 290 | ) 291 | 292 | def move(self, position: Position) -> None: 293 | return 294 | -------------------------------------------------------------------------------- /docs/source/tutorial/03_random_walker.rst: -------------------------------------------------------------------------------- 1 | .. _Tutorial Chapter 03: 2 | 3 | Chapter 3 - Han Solo: The Random Walker 4 | ======================================== 5 | 6 | Hi human. Glad to see you here. I thought you were lost in some capitalist leisure streaming service. 7 | 8 | In this chapter, you will learn how to move once for all from your primitive planet and explore the galaxy. Once completed 9 | this tutorial you will be a globetrotter on the galaxy. The Han Solo of the Pythonium universe. 10 | 11 | .. warning:: 12 | If you don't know who Han Solo is stop here and come back once you were watched the full `original trilogy of Star Wars `_. 13 | 14 | ``target``: Where do you want to go? 15 | ------------------------------------- 16 | 17 | Each ``ship`` has a ``target`` attribute indicating where the ship is going. This is one of the control variables 18 | for your ships. You can edit this parameter to order your ships to go to some specific point in the galaxy. 19 | 20 | Start the ``ipdb`` debugger as you learned in :ref:`Chapter 2`, and select some random ship to be 21 | your explorer: 22 | 23 | .. code-block:: python 24 | 25 | ipdb> my_ships = galaxy.get_player_ships(self.name) # Find all your ships 26 | ipdb> explorer_ship = next(my_ships) # Select the first ship 27 | 28 | 29 | Now let's see where the explorer ship is going: 30 | 31 | .. code-block:: python 32 | 33 | ipdb> print(explorer_ship.target) 34 | None 35 | 36 | This means the ship has no target. In the next turn, it will be in the same position. 37 | 38 | You can verify it easily. 39 | 40 | .. code-block:: python 41 | 42 | ipdb> galaxy.turn # Check the current turn 43 | 0 44 | ipdb> explorer_ship.position # Check the ship's position 45 | (43, 37) 46 | ipdb> c # Move one turn forward 47 | ... 48 | ipdb> galaxy.turn # Now you are in turn 1 49 | 1 50 | ipdb> my_ships = galaxy.get_player_ships(self.name) # Find the explorer ship again 51 | ipdb> explorer_ship = next(my_ships) 52 | ipdb> explorer_ship.position # The ship position is the same as previous turn 53 | (43, 37) 54 | 55 | The ship stays in the same position when time moves forward. It is not going anywhere. 56 | 57 | .. note:: 58 | Note that when you move one turn forward ``ipbb`` do not save the variables declared in the previous turn. 59 | That's why we need to search the ``explorer_ship`` again. 60 | 61 | Knowing the neighborhood 62 | ------------------------- 63 | 64 | The next step is to find a destination for the ``explorer_ship`` 65 | 66 | For sure you want to visit one of the many unknown planets around you (those with ``player=None``), and possibly you don't 67 | want to travel for all eternity. We need to find some unknown planet near yours to arrive fast. The ship should arrive 68 | by the next turn. 69 | 70 | But wait a minute, how fast the ``explorer_ship`` moves? 71 | 72 | Every ship has a ``speed`` attribute indicating how many light-years can travel in a single turn. 73 | 74 | .. code-block:: python 75 | 76 | ipdb> explorer_ship.speed 77 | 80 78 | 79 | Based on this we can say the ship can travel up to 80ly in a single turn. The next step is to find an unknown planet that is 80ly or 80 | less from your planet. 81 | 82 | The :func:`Galaxy.nearby_planets` method allows you to find all the planets that are 83 | up to a certain distance away (or less) from a specific position. This method takes a ``position`` and a distance 84 | (called ``neighborhood``) and returns a list with all the nearby planets around that position. 85 | 86 | In our case, the neighborhood will be 80ly, the distance the ship can travel in one turn, and the position will be the 87 | ship location. 88 | 89 | .. code-block:: python 90 | 91 | ipdb> neighborhood = galaxy.nearby_planets(explorer_ship.position, explorer_ship.speed) 92 | ipdb> pp neighborhood 93 | [Planet(id=7d9321ab-57cb-4a05-afaa-c2f4ef8e4627, position=(43, 37), player=Han Solo), 94 | Planet(id=a374a560-ba94-43b1-87b0-78eca8ca5b97, position=(25, 41), player=None), 95 | Planet(id=e3319ed0-24ec-491c-bb76-a418d9b8b508, position=(112, 50), player=None), 96 | Planet(id=1b7d714e-22d2-4ca2-826a-bf0656138793, position=(115, 9), player=None), 97 | Planet(id=70279963-541b-49c9-bb87-32cf6936f45f, position=(31, 42), player=None), 98 | Planet(id=73f25d86-44f1-4cfc-a8ac-44a96affa1d9, position=(9, 21), player=None), 99 | Planet(id=1c7ec1c3-7aea-44bf-b582-1f7e3cb3b7ec, position=(81, 27), player=None), 100 | Planet(id=1378a7ab-2120-46d3-ac93-fc50632141b0, position=(96, 62), player=None), 101 | Planet(id=fb0d019d-ca71-4353-a06c-d3b4898ffd82, position=(93, 44), player=None), 102 | Planet(id=02539d23-2911-4354-81f5-9a1f83ef0936, position=(21, 86), player=None), 103 | Planet(id=38ce324b-ce2a-4bf1-997c-bb8990ae7509, position=(67, 37), player=None), 104 | Planet(id=4e19fda6-ac81-4d85-bdde-bd7244430a2e, position=(70, 33), player=None), 105 | Planet(id=e2234771-dbeb-425f-9b0a-1e761f5cf3e1, position=(44, 18), player=None), 106 | Planet(id=b5b025dd-dfcf-4ca5-8b03-67bb3a04479f, position=(30, 92), player=None), 107 | Planet(id=4b29c3d8-3c2f-4b33-8ca7-f451eb269e21, position=(61, 110), player=None), 108 | Planet(id=72b77b24-0063-42f1-aeb0-259f04125cbd, position=(67, 71), player=None), 109 | Planet(id=bf00cfa3-aece-48e6-8d67-11b3797e2f2c, position=(42, 69), player=None), 110 | Planet(id=43bcb3bb-b788-46e9-b425-8539caeff03c, position=(89, 64), player=None), 111 | Planet(id=0a9f5a40-034e-4fe8-a6b1-83f3437e09c8, position=(109, 54), player=None), 112 | Planet(id=a51d8923-1003-4357-bb2b-f3efa7d5023e, position=(17, 35), player=None), 113 | Planet(id=da112184-1e01-41ee-b146-d073946ce41e, position=(32, 81), player=None), 114 | Planet(id=765a19df-2639-4efd-8aa6-30ff3926039c, position=(75, 40), player=None), 115 | Planet(id=40052c15-3ffa-4dfa-ad22-9afbd0a16091, position=(95, 57), player=None)] 116 | 117 | Cool, right? 118 | 119 | All those planets are one turn away the ``explorer_ship``. Notice that your planet is included in the neighborhood (because your ship is located in it and 120 | the distance to it is zero). 121 | 122 | Traveling 123 | ---------- 124 | 125 | Now let's select the target for the ship. For now, keep it simple: pic some random unknown planet from the list. 126 | 127 | .. code-block:: python 128 | 129 | ipdb> unknown_nearby_planets = [p for p in neighborhood if p.player is None] 130 | ipdb> import random 131 | ipdb> target_planet = random.choice(unknown_nearby_planets) 132 | ipdb> target_planet 133 | Planet(id=1b7d714e-22d2-4ca2-826a-bf0656138793, position=(115, 9), player=None) 134 | 135 | That's your ship first destination. An unknown planet one turn away from your ship's location. 136 | 137 | The next step is set the ship's ``target`` as the planet's ``position`` and move one turn forward. 138 | 139 | .. code-block:: python 140 | 141 | ipdb> galaxy.turn # Check the current turn 142 | 1 143 | ipdb> explorer_ship.position # Check the ship position 144 | (43, 37) 145 | ipdb> explorer_ship.target = target_planet.position # set the ship target 146 | ipdb> c # move one turn forward 147 | 148 | Where is the ship now? 149 | 150 | .. code-block:: python 151 | 152 | ipdb> galaxy.turn # you are one turn ahead 153 | 2 154 | ipdb> my_ships = galaxy.get_player_ships(self.name) # Find all your ships 155 | ipdb> explorer_ship = next(my_ships) # And keep the explorer ship 156 | ipdb> explorer_ship.position # Check the ship position 157 | (115, 9) 158 | ipdb> explored_planet = galaxy.planets.get(explorer_ship.position) # Find the planet in the ship's position 159 | ipdb> explored_planet 160 | Planet(id=1b7d714e-22d2-4ca2-826a-bf0656138793, position=(115, 9), player=None) 161 | 162 | Your explorer ship just arrived at the target planet. A new and unknown rock in the middle of the space with a lot of 163 | things to learn about and explore. 164 | 165 | Congratulations human. You did it. You left the pathetic rock where you spent your whole life, and now you are in a 166 | different one. Probably more pathetic, probably more boring, maybe you don't even have air to breathe or food to eat. 167 | But hey... you are a space traveler. 168 | 169 | 170 | Putting the pieces together 171 | ---------------------------- 172 | 173 | In this chapter, we explained how to move your ships. You learned the first, and most basic command: Ship movement. 174 | 175 | But we also developed a strategy. I call it "The Random Walker Strategy": A group of ships moving around, exploring 176 | planets without much more to do but travel around the galaxy. 177 | 178 | Let's :ref:`exit the debugger`, edit your player class, and apply the random walker strategy to all your ships. 179 | 180 | You will end up with something like this: 181 | 182 | .. code-block:: python 183 | 184 | import random 185 | from pythonium import AbstractPlayer 186 | 187 | class Player(AbstractPlayer): 188 | 189 | name = 'Han Solo' 190 | 191 | def next_turn(self, galaxy, context): 192 | # Get your ships 193 | my_ships = galaxy.get_player_ships(self.name) 194 | # For every of your ships... 195 | for ship in my_ships: 196 | # find the nearby planets... 197 | nearby_planets = galaxy.nearby_planets(ship.position, ship.speed) 198 | # pick any of them... 199 | target_planet = random.choice(nearby_planets) 200 | # an set the target to the selected planet 201 | ship.target = target_planet.position 202 | 203 | return galaxy 204 | 205 | After executing your player the generated gif should look similar to this one: 206 | 207 | 208 | .. image:: https://ik.imagekit.io/jmpdcmsvqee/chapter_03_-WCVHoMkz.gif 209 | :target: https://ik.imagekit.io/jmpdcmsvqee/chapter_03_-WCVHoMkz.gif 210 | :width: 300pt 211 | 212 | Can you see those ships moving around? That, my friend, is what I call freedom. 213 | 214 | Long travels 215 | ------------- 216 | 217 | The implemented random walker strategy moves ships to planets that are one turn away from the original position only. 218 | 219 | If you send a ship to a point that is furthest the distance the ship can travel in one turn (this is ``ship.speed``), 220 | it will take more than one turn to arrive at the destination. In the next turn, the ship will be at some point in the 221 | middle between the target and the original destination. 222 | 223 | Of course, you can change the ship's target at any time during travel. 224 | 225 | .. note:: 226 | 227 | **Challenge** 228 | Build a random walker player that travels to planets that are two turns away only (and not planets that are one turn away) 229 | 230 | 231 | Final thoughts 232 | -------------- 233 | 234 | In this chapter we introduced the :attr:`target` attribute, and how it can be used 235 | to set a movement command for a ship. 236 | 237 | We also explained how to find planets around certain position with the :func:`Galaxy.nearby_planets` 238 | method. 239 | 240 | Finally, this chapter is a first attempt to describe a player-building methodology in pythonium. Usually, you will make 241 | use of the debugger to test some commands, try a few movements and see how they work from one turn to another. This will help 242 | you to start a draft for your player strategy, and after that, you will need to code it in your player class. 243 | 244 | The debugger is a good tool for testing and see how things evolve in a rudimentary way. On more complex players it is hard 245 | to track all the changes and commands that happen in one turn. Imagine you having an empire of more than 246 | 100 planets and around 150 ships, it is impossible to check all the positions and movements with the ``ipdb`` debugger. 247 | 248 | For those cases, there are more advanced techniques of analysis that involve the generated logs and the report file. 249 | But that is a topic for future chapters. 250 | 251 | I hope to see you again, there's still a lot more to learn. 252 | -------------------------------------------------------------------------------- /docs/source/tutorial/02_understanding_the_unknown.rst: -------------------------------------------------------------------------------- 1 | .. _Tutorial Chapter 02: 2 | 3 | Chapter 2 - Understanding the unknown 4 | ====================================== 5 | 6 | Hello human. Good to see you again. 7 | 8 | I understand that having you reading this is an expression of the desire of knowing more about 9 | the universe around you, and eventually leave your planet. 10 | 11 | The next step is to know more about the galaxy. How many planets are? How far are they from you? 12 | Do you have starships? How can you use them? 13 | 14 | Keep reading, and all your questions will be answered. 15 | 16 | The galaxy, an introduction 17 | ---------------------------- 18 | 19 | In Pythonium, the ``galaxy`` is the source of all truth for you. It represents all your owned knowledge about 20 | the universe, and in most cases, all the information to develop your strategy will be extracted from the ``galaxy``. 21 | 22 | First, you need to learn what do you know about the galaxy. To do so we will use ``ipdb``, the ancient oracle of 23 | python code. 24 | 25 | This tool allows you to see what's going on with your python code at some point. In our case, we want to 26 | know what's going on at the beginning of each turn. 27 | 28 | .. note:: 29 | Don't you know `ipdb`? `Check it out `_. 30 | 31 | Open the player you built in :ref:`Chapter 1` and set a trace in your ``next_turn`` method: 32 | 33 | .. code-block:: python 34 | 35 | from pyhtonium import AbstractPlayer 36 | 37 | class Player(AbstractPlayer): 38 | 39 | name = 'Han Solo' 40 | 41 | def next_turn(self, galaxy, context): 42 | import ipdb; ipdb.set_trace() 43 | return galaxy 44 | 45 | Once executed you will see something similar to: 46 | 47 | .. code-block:: python 48 | 49 | 8 def next_turn(self, galaxy, context): 50 | 9 import ipdb; ipdb.set_trace() 51 | ---> 10 return galaxy 52 | 53 | ipdb> _ 54 | 55 | .. note:: 56 | If you don't remember how to do execute your player check on :ref:`Executing your player` 57 | 58 | Now we can start investigating the ``galaxy``. 59 | 60 | .. code-block:: python 61 | 62 | ipdb> galaxy 63 | Galaxy(size=(500, 500), planets=300) 64 | 65 | 66 | Ok then, this means you are in a galaxy of 500 light-years width and 500 ly height (``size=(500, 500)``) compounded by 67 | 300 planets (``planets=300``). 68 | 69 | There are three main galaxy attributes that you must know in deep. 70 | 71 | ``turn`` 72 | ~~~~~~~~ 73 | 74 | Your time reference. The turn that is being played. 75 | 76 | .. code-block:: python 77 | 78 | ipdb> galaxy.turn 79 | 0 80 | 81 | As expected, the game just began, and you are in turn 0. 82 | 83 | To move one turn forward, use the ``c`` command. 84 | 85 | .. code-block:: python 86 | 87 | ipdb> c 88 | 89 | 8 def next_turn(self, galaxy, context): 90 | 9 import ipdb; ipdb.set_trace() 91 | ---> 10 return galaxy 92 | 93 | ipdb> galaxy.turn 94 | 1 95 | 96 | .. note:: 97 | And as you may suspect, there is no way to come back in time. Time always moves forward. 98 | 99 | ``planets`` 100 | ~~~~~~~~~~~~ 101 | 102 | This attribute stores the state of all the planets in the galaxy. 103 | 104 | ``galaxy.planets`` is a python dictionary where the keys are planet's :class:`Position`, 105 | and the values are :class:`Planet` instances. 106 | 107 | .. code-block:: 108 | 109 | ipdb> type(galaxy.planets) 110 | 111 | 112 | ipdb> pp galaxy.planets 113 | {(2, 124): Planet(id=ecf5f0b9-d639-48fb-ac06-cb0027d03d5b, position=(2, 124), player=None), 114 | (3, 466): Planet(id=b20406cb-b764-4842-8dac-ec13c2038ca9, position=(3, 466), player=None), 115 | (4, 129): Planet(id=ec53e2a9-24e2-49f5-aa56-4a6337b06b87, position=(4, 129), player=None), 116 | (4, 294): Planet(id=40712b86-5bf3-453f-9714-760dbe771570, position=(4, 294), player=None), 117 | ... 118 | } 119 | 120 | ipdb> len(galaxy.planets) 121 | 300 122 | 123 | .. note:: 124 | In the previous example, we use the ``ipdb`` command ``pp``, as an alias for `pprint `_. 125 | 126 | A planet has tons of attributes, for now we will focus just in a few of them: 127 | 128 | * ``id`` a unique identifier for the planet, 129 | * ``position`` is the planet position in the galaxy in (x, y) coordinates, 130 | * ``player`` is the planet's owner, it can be ``None`` if the planet is not colonized or the owner is unknown to you. 131 | 132 | 133 | ``ships`` 134 | ~~~~~~~~~~ 135 | 136 | In a similar way as with the planets, the ``galaxy.ships`` attribute is a python list that stores references to every 137 | :class:`Ship` in the galaxy. 138 | 139 | .. code-block:: 140 | 141 | ipdb> type(galaxy.ships) 142 | 143 | 144 | ipdb> pp galaxy.ships 145 | [Ship(id=b615699e-c70e-4e55-b678-fb0513abbb0b, position=(27, 23), player=Han Solo), 146 | Ship(id=5b8e15a8-a319-43d0-bdd0-6be675d1742e, position=(27, 23), player=Han Solo)] 147 | 148 | ipdb> len(galaxy.ships) 149 | 2 150 | 151 | The ships, also have ``id``, ``position``, and ``player`` attributes. 152 | 153 | From ``galaxy.ships`` output we can tell there are two known ships in the galaxy, and both are yours (notice the ``player=Han Solo``). 154 | 155 | 156 | Querying to the galaxy 157 | ----------------------- 158 | 159 | The ``galaxy`` has methods that allow you to filter ``ships`` and ``planets`` based on several criteria. 160 | In this section, we will present some receipts to answer common questions that you may have. 161 | 162 | 163 | Where are my planets? 164 | ~~~~~~~~~~~~~~~~~~~~~ 165 | 166 | By looking carefully into the ``galaxy.planets`` output you will find a planet with ``player=Han Solo``. 167 | 168 | That's your planet! 169 | 170 | But you may be thinking there should be an easier way to find which planets are yours (if any). And there is: this can be done with the 171 | :func:`Galaxy.get_player_planets` method. 172 | 173 | This method takes a player name as attribute and returns an iterable with all the planets where the owner is the player with the 174 | name you asked for. 175 | 176 | .. code-block:: python 177 | 178 | ipdb> my_planets = galaxy.get_player_planets(self.name) 179 | ipdb> pp list(my_planets) 180 | [Planet(id=1fa89759-6834-478a-9eda-6985dd95a0c7, position=(27, 23), player=Han Solo)] 181 | 182 | 183 | .. note:: 184 | You can access to the name of your :class:`Player` inside your ``next_turn`` method with 185 | the ``self.name`` attribute. 186 | 187 | Where are my ships? 188 | ~~~~~~~~~~~~~~~~~~~ 189 | 190 | In a similar fashion to planets, you can find all your ships with the :func:`Galaxy.get_player_ships` method. 191 | 192 | .. code-block:: python 193 | 194 | ipdb> my_ships = galaxy.get_player_ships(self.name) 195 | ipdb> pp list(my_ships) 196 | [Ship(id=b615699e-c70e-4e55-b678-fb0513abbb0b, position=(27, 23), player=Han Solo), 197 | Ship(id=5b8e15a8-a319-43d0-bdd0-6be675d1742e, position=(27, 23), player=Han Solo)] 198 | 199 | 200 | In single-player mode :func:`Galaxy.get_player_ships` always returns all the ships in 201 | ``galaxy.ships``, as there are no abandoned ships in pythonium (with ``player=None``). 202 | 203 | But in multiplayer mode, you can also find enemy ships in the ``galaxy.ships`` attribute. In that case, this function can 204 | be handy to get only your ships, or the visible enemy ships. 205 | 206 | Are there ships on my planet orbit? 207 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 208 | 209 | Let’s suppose you want to transfer some resource from one planet to another, the first thing you want to know is if 210 | there is any ship in the same position as your planet, to use this ship to transfer the resource. 211 | 212 | This can be answered with the :func:`Galaxy.get_ships_in_position` method. 213 | 214 | This method takes a position as parameter and returns an iterable with all the known ships in that position. 215 | 216 | In our case, that will be the ``position`` attribute of your planet. 217 | 218 | .. code-block:: python 219 | 220 | ipdb> my_planets = galaxy.get_player_planets(self.name) 221 | ipdb> some_planet = next(my_planets) 222 | ipdb> some_planet 223 | Planet(id=1fa89759-6834-478a-9eda-6985dd95a0c7, position=(27, 23), player=Han Solo) 224 | 225 | ipdb> ships_in_planet = galaxy.get_ships_in_position(some_planet.position) 226 | ipdb> pp list(ships_in_planet) 227 | [Ship(id=b615699e-c70e-4e55-b678-fb0513abbb0b, position=(27, 23), player=Han Solo), 228 | Ship(id=5b8e15a8-a319-43d0-bdd0-6be675d1742e, position=(27, 23), player=Han Solo)] 229 | 230 | 231 | Is my ship in a planet? 232 | ~~~~~~~~~~~~~~~~~~~~~~~~ 233 | 234 | Now think the opposite example, you have a ship and you want to know if it is located on a planet or in deep space. 235 | 236 | This can be answered by simply searching if there is planets in the ship's position. 237 | 238 | .. code-block:: python 239 | 240 | ipdb> my_ships = galaxy.get_player_ships(self.name) 241 | ipdb> some_ship = next(my_ships) 242 | ipdb> some_ship 243 | Ship(id=b615699e-c70e-4e55-b678-fb0513abbb0b, position=(27, 23), player=Han Solo) 244 | 245 | ipdb> planet = galaxy.planets.get(some_ship.position) 246 | ipdb> planet 247 | Planet(id=1fa89759-6834-478a-9eda-6985dd95a0c7, position=(27, 23), player=Han Solo) 248 | 249 | 250 | Turn ``context`` 251 | ---------------- 252 | 253 | Apart from ``galaxy`` there is a second argument received by the ``Player.next_turn`` method: the turn ``context``. 254 | 255 | The ``context`` contains additional metadata about the turn and the overall game. 256 | 257 | .. code-block:: 258 | 259 | ipdb> type(context) 260 | 261 | 262 | ipdb> context.keys() 263 | dict_keys(['ship_types', 'tolerable_taxes', 'happypoints_tolerance', 'score']) 264 | 265 | 266 | Here we see that ``context`` is a dictionary with several keys. For now, we will focus on the ``score``. 267 | 268 | 269 | .. code-block:: python 270 | 271 | ipdb> context['score'] 272 | [{'turn': 1, 'player': 'Han Solo', 'planets': 1, 'ships_carrier': 2, 'ships_war': 0, 'total_ships': 2}] 273 | 274 | 275 | From the score we know: 276 | 277 | * The current turn number is ``1``, 278 | * there is only one player called 'Han Solo' (that's you!), 279 | * Han Solo owns, 280 | 281 | * one planet, 282 | * two carrier ships 283 | * zero warships, 284 | * and two ships in total 285 | 286 | This is, in fact, consistent with the found results in previous sections. When you query your owned planets, the result 287 | was one single planet, and for your ships, the result was two ships. 288 | 289 | You can verify that both ships are carriers by doing 290 | 291 | .. code-block:: python 292 | 293 | ipdb> for ship in my_ships: 294 | print(ship.type.name) 295 | 296 | carrier 297 | carrier 298 | 299 | In the next chapters, we will explore a bit more about the ``context``, different ship types, and their attributes. 300 | 301 | .. _exit the debugger: 302 | 303 | How to exit from the `ipdb` debugger 304 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 305 | 306 | Pythonium has a special command for exit the ``ipdb``. You will notice that the usual ``exit`` command 307 | will not work in this case. Exiting from the infinite loop of time is a bit more complex. 308 | 309 | If you want to exit the debugger do: 310 | 311 | .. code-block:: python 312 | 313 | ipdb> from pythonium.debugger import terminate 314 | ipdb> terminate() 315 | 316 | 317 | Final thoughts 318 | -------------- 319 | 320 | In this chapter, we explained how to access the different objects from the galaxy, with a focus on those objects owned by your player. 321 | Depending on the complexity of the player that you want to implement, you might find useful one method or another. 322 | That is something you need to discover yourself, but it is good to have an overview. 323 | 324 | You can also implement your own query methods for ``galaxy.planets`` and ``galaxy.ships`` depending on your needs. 325 | For starters space explorers, the methods presented in this section should be enough for most cases. 326 | 327 | In the next chapter, you will learn how to move your ships. 328 | 329 | Keep moving human, the battle for pythonium is waiting for you. 330 | -------------------------------------------------------------------------------- /pythonium/game_modes.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import random 3 | from collections import Counter 4 | 5 | from . import cfg 6 | from .galaxy import Galaxy 7 | from .planet import Planet 8 | from .ship import Ship 9 | from .ship_type import ShipType 10 | from .vectors import Transfer 11 | 12 | CLASSIC_MODE_SHIPS = ( 13 | ShipType( 14 | name="carrier", 15 | cost=Transfer(megacredits=600, pythonium=450), 16 | max_cargo=1200, 17 | max_mc=10**3, 18 | attack=0, 19 | speed=cfg.ship_speed, 20 | ), 21 | ShipType( 22 | name="war", 23 | cost=Transfer(megacredits=1000, pythonium=600), 24 | max_cargo=100, 25 | max_mc=10**3, 26 | attack=100, 27 | speed=cfg.ship_speed, 28 | ), 29 | ) 30 | 31 | CLASSIC_MINE_COST = Transfer(megacredits=3, pythonium=5) 32 | 33 | 34 | class GameMode: 35 | 36 | name: str 37 | 38 | def __init__( 39 | self, 40 | ship_types=CLASSIC_MODE_SHIPS, 41 | mine_cost=CLASSIC_MINE_COST, 42 | tenacity=cfg.tenacity, 43 | ): 44 | self.cfg = cfg 45 | self.ship_types = {st.name: st for st in ship_types} 46 | self.mine_cost = mine_cost 47 | self.tenacity = tenacity 48 | 49 | def build_galaxy(self, name, players): 50 | """ 51 | Fabrica la galaxy 52 | 53 | Retorna una instancia de :class:`Galaxy` 54 | """ 55 | raise NotImplementedError("Metodo no implementado") 56 | 57 | def galaxy_for_player(self, player, t): 58 | """ 59 | Devuelve una galaxy que muestra sólo las cosas que puede ver el player 60 | en un turn determinado 61 | """ 62 | raise NotImplementedError("Metodo no implementado") 63 | 64 | def get_context(self, galaxy, players): 65 | """ 66 | Genera variables de context para el player en un turn determinado 67 | """ 68 | raise NotImplementedError("Metodo no implementado") 69 | 70 | 71 | class ClassicMode(GameMode): 72 | 73 | # Define los atributos de las ships que se pueden construir. 74 | 75 | def __init__( 76 | self, 77 | planets_count=300, 78 | max_ships=500, 79 | map_size=(500, 500), 80 | pythonium_stock=10**6, 81 | pythonium_in_surface=0.1, 82 | starting_ships=(("carrier", 2),), 83 | starting_resources=(10**4, 2 * 10**3, 5 * 10**3), 84 | max_turn=150, 85 | *args, 86 | **kwargs, 87 | ): 88 | """ 89 | :param planets_count: Cantidad de planets en la galaxy 90 | :type planets_count: int 91 | :param map_size: Dimensiones del mapa 92 | :type map_size: tuple(int, int) 93 | :param pythonium_stock: Cantidad total de pythonium disponible en el mapa. 94 | :type pythonium_stock: int 95 | :param pythonium_in_surface: Proporción de `pythonium_stock` disponible 96 | en la superficie de los planets. 97 | :type pythonium_in_surface: int 98 | :param starting_carriers: Cantidad de cargueros con los que comienza cada 99 | player 100 | :type starting_carriers: int 101 | ;paran max_turn; Número máximo de turnos 102 | :type limi: int 103 | """ 104 | super().__init__(*args, **kwargs) 105 | self.planets_count = planets_count 106 | self.map_size = map_size 107 | self.pythonium_stock = pythonium_stock 108 | self.pythonium_in_surface = pythonium_in_surface 109 | self.starting_ships = starting_ships 110 | self.starting_resources = starting_resources 111 | self.max_turn = max_turn 112 | self.max_ships = max_ships 113 | self.winner = None 114 | 115 | def build_galaxy(self, name, players): 116 | """ 117 | Representa el conjunto de planets en el que se desenvolverá el juego. 118 | 119 | """ 120 | total_pythonium = int(self.pythonium_stock * self.pythonium_in_surface) 121 | total_underground_pythonium = self.pythonium_stock - total_pythonium 122 | # 1. Genera los planeras 123 | # 1.a positiones aleatorias para los planets 124 | # Nos aseguramos de todas las positiones sean únicas. 125 | positions = set() 126 | while len(positions) < self.planets_count: 127 | pos = ( 128 | random.randint(0, self.map_size[0] - 10), 129 | random.randint(0, self.map_size[1] - 10), 130 | ) 131 | positions.add(pos) 132 | 133 | # Genera comdiciones iniciales aleatorias para el resto de los atributos 134 | # de los planets 135 | # 1.b Distribuye el pythonium en superficie 136 | pythonium_distribution = [ 137 | round(random.random() * 100) for i in range(self.planets_count) 138 | ] 139 | coef_pythonium = total_pythonium / sum(pythonium_distribution) 140 | pythonium = (round(d * coef_pythonium) for d in pythonium_distribution) 141 | # 1.c Distribuye el pythonium subterraneo 142 | underground_pythonium_distribution = [ 143 | random.random() for i in range(self.planets_count) 144 | ] 145 | coef_underground_pythonium = total_underground_pythonium / sum( 146 | underground_pythonium_distribution 147 | ) 148 | underground_pythonium = ( 149 | round(d * coef_underground_pythonium) 150 | for d in underground_pythonium_distribution 151 | ) 152 | # 1.d Distribuye las concentraciones de pythonium 153 | concentrations = ( 154 | round(random.random(), 2) for i in range(self.planets_count) 155 | ) 156 | # 1.e Genera las temperatures de los planets 157 | temperatures = ( 158 | round(random.random() * 100) for i in range(self.planets_count) 159 | ) 160 | 161 | things = [] 162 | for ( 163 | position, 164 | pythonium, 165 | underground_pythonium, 166 | concentration, 167 | temperature, 168 | ) in zip( 169 | positions, 170 | pythonium, 171 | underground_pythonium, 172 | concentrations, 173 | temperatures, 174 | ): 175 | 176 | planet = Planet( 177 | position=position, 178 | temperature=temperature, 179 | underground_pythonium=underground_pythonium, 180 | concentration=concentration, 181 | pythonium=pythonium, 182 | mine_cost=self.mine_cost, 183 | ) 184 | things.append(planet) 185 | 186 | galaxy = Galaxy(name=name, size=self.map_size, things=things) 187 | 188 | galaxy = self.init_players(players, galaxy) 189 | 190 | return galaxy 191 | 192 | def init_players(self, players, galaxy): 193 | # 2. Genera las condiciones iniciales de los players. 194 | 195 | margins = (galaxy.size[0] * 0.1, galaxy.size[1] * 0.1) 196 | for i, player in enumerate(players): 197 | # 2.a Asigna planets 198 | if not i: 199 | position = margins 200 | else: 201 | position = ( 202 | galaxy.size[0] - margins[0], 203 | galaxy.size[1] - margins[1], 204 | ) 205 | nearby_planets = galaxy.nearby_planets(position, 50) 206 | 207 | homeworld = random.choice(nearby_planets) 208 | 209 | homeworld.player = player.name 210 | homeworld.clans = self.starting_resources[0] 211 | homeworld.pythonium = self.starting_resources[1] 212 | homeworld.megacredits = self.starting_resources[2] 213 | 214 | # 2.b Asigna cargueros 215 | for ship_type_name, quantity in self.starting_ships: 216 | ship_type = self.ship_types[ship_type_name] 217 | for _ in range(quantity): 218 | ship = Ship( 219 | player=player.name, 220 | type=ship_type, 221 | position=homeworld.position, 222 | max_cargo=ship_type.max_cargo, 223 | max_mc=ship_type.max_mc, 224 | attack=ship_type.attack, 225 | speed=ship_type.speed, 226 | ) 227 | galaxy.add_ship(ship) 228 | return galaxy 229 | 230 | def galaxy_for_player(self, galaxy, player): 231 | # TODO: add the following tests: 232 | # * The player can't see enemy ships not located with own ship 233 | # * The player can see enemy ships located with any own ship 234 | # or in any own planet 235 | # * The player can see any ship in deep space 236 | 237 | player_planets_positions = [ 238 | p.position for p in galaxy.get_player_planets(player.name) 239 | ] 240 | # Player can see ships that: 241 | # * Belongs to him 242 | # * Are located in any of his planets. 243 | # * Are in deep space. Not locate in any planet 244 | visible_ships = [ 245 | ship 246 | for ship in galaxy.ships 247 | if player.name == ship.player 248 | or ship.position in player_planets_positions 249 | or ship.position not in galaxy.planets.keys() 250 | ] 251 | planets = copy.deepcopy(galaxy.planets) 252 | ships = copy.deepcopy(visible_ships) 253 | 254 | # Hide information from ships that are not his own 255 | for ship in [ship for ship in ships if ship.player != player.name]: 256 | ship.max_cargo = None 257 | ship.max_mc = None 258 | ship.attack = None 259 | ship.megacredits = None 260 | ship.pythonium = None 261 | ship.clans = None 262 | ship.target = None 263 | ship.transfer = None 264 | 265 | # Hide information of enemy planets and unknown planets 266 | # (without a player's ship in the planet) 267 | for planet in [ 268 | p 269 | for pos, p in planets.items() 270 | if p.player != player.name 271 | and ( 272 | p.player is not None 273 | and pos not in [n.position for n in visible_ships] 274 | ) 275 | ]: 276 | planet.temperature = None 277 | planet.underground_pythonium = None 278 | planet.concentration = None 279 | planet.pythonium = None 280 | planet.player = None 281 | planet.megacredits = None 282 | planet.max_happypoints = None 283 | planet.happypoints = None 284 | planet.clans = None 285 | planet.mines = None 286 | planet.new_mines = None 287 | planet.new_ship = None 288 | planet.taxes = None 289 | 290 | return Galaxy( 291 | turn=galaxy.turn, 292 | name=galaxy.name, 293 | size=galaxy.size, 294 | things=list(planets.values()) + ships, 295 | explosions=galaxy.explosions, 296 | ) 297 | 298 | def has_ended(self, galaxy, t): 299 | if t >= self.max_turn: 300 | return True 301 | 302 | planets_score = Counter( 303 | (p.player for p in galaxy.planets.values() if p.player is not None) 304 | ) 305 | threshold = len(galaxy.planets) * 0.7 306 | for name, score in planets_score.items(): 307 | if score > threshold: 308 | self.winner = name 309 | return True 310 | return False 311 | 312 | def get_score(self, galaxy, players, turn): 313 | planets_score = Counter( 314 | (p.player for p in galaxy.planets.values() if p.player is not None) 315 | ) 316 | 317 | ship_scores = {} 318 | for ship_type in self.ship_types.values(): 319 | ship_scores[ship_type.name] = Counter( 320 | ( 321 | s.player 322 | for s in galaxy.ships 323 | if s.type.name == ship_type.name 324 | ) 325 | ) 326 | score = [] 327 | for player in players: 328 | name = player.name 329 | player_score = { 330 | "turn": turn, 331 | "player": name, 332 | "planets": planets_score.get(name, 0), 333 | } 334 | total_ships = 0 335 | for ship_type_name, ship_type_score in ship_scores.items(): 336 | ships_count = ship_type_score.get(name, 0) 337 | total_ships += ships_count 338 | player_score[f"ships_{ship_type_name}"] = ships_count 339 | player_score["total_ships"] = total_ships 340 | score.append(player_score) 341 | return score 342 | 343 | def get_context(self, galaxy, players, turn): 344 | score = self.get_score(galaxy, players, turn) 345 | # FIXME: Can the user change the ship types attribute? 346 | return { 347 | "ship_types": self.ship_types, 348 | "tolerable_taxes": cfg.tolerable_taxes, 349 | "happypoints_tolerance": cfg.happypoints_tolerance, 350 | "score": score, 351 | } 352 | -------------------------------------------------------------------------------- /pythonium/game.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from collections import defaultdict 4 | from itertools import groupby 5 | 6 | import numpy as np 7 | 8 | from . import cfg 9 | from .explosion import Explosion 10 | from .orders import galaxy as galaxy_orders 11 | from .orders import planet as planet_orders 12 | from .orders import ship as ship_orders 13 | from .renderer import GifRenderer 14 | 15 | logger = logging.getLogger("game") 16 | 17 | 18 | class Game: 19 | def __init__( 20 | self, 21 | name, 22 | players, 23 | gmode, 24 | *, 25 | renderer=GifRenderer, 26 | raise_exceptions=False, 27 | ): 28 | """ 29 | :param name: Name for the galaxy. Also used as game identifier. 30 | :type name: str 31 | :param players: Players for the game. Supports one or two players. 32 | :type players: list of instances of classes that extends 33 | from :class:`AbstractPlayer` 34 | :param gmode: Class that define some game rules 35 | :type gmode: :class:GameMode 36 | :param renderer: Instance that renders the game on each turn 37 | :param raise_exceptions: If ``True`` stop the game if an exception is raised when 38 | computing player actions. Useful for debuging players. 39 | :type raise_exceptions: bool 40 | """ 41 | if len(players) != len({p.name for p in players}): 42 | raise ValueError("Player names must be unique") 43 | 44 | sys.stdout.write("** Pythonium **\n") 45 | self.gmode = gmode 46 | self.players = players 47 | self.raise_exceptions = raise_exceptions 48 | logger.info( 49 | "Initializing galaxy", 50 | extra={"players": len(self.players), "galaxy_name": name}, 51 | ) 52 | self.galaxy = self.gmode.build_galaxy(name, self.players) 53 | logger.info("Galaxy initialized") 54 | sys.stdout.write(f"Running battle in galaxy #{name}\n") 55 | self._renderer = renderer(self.galaxy, f"Galaxy #{name}") 56 | 57 | def extract_player_orders(self, player, galaxy, context): 58 | player_galaxy = player.next_turn(galaxy, context) 59 | 60 | # The function must return the mutated galaxy 61 | 62 | if id(galaxy) != id(player_galaxy): 63 | raise ValueError( 64 | "The `run_player` method must return a mutated galaxy" 65 | ) 66 | 67 | planets_orders = [ 68 | p.get_orders() 69 | for p in player_galaxy.planets.values() 70 | if p.player == player.name 71 | ] 72 | ships_orders = [ 73 | s.get_orders() 74 | for s in player_galaxy.ships 75 | if s.player == player.name 76 | ] 77 | 78 | orders = [ 79 | o for orders in ships_orders + planets_orders for o in orders 80 | ] 81 | logger.info( 82 | "Player orders computed", 83 | extra={ 84 | "turn": self.galaxy.turn, 85 | "player": player.name, 86 | "orders": len(orders), 87 | }, 88 | ) 89 | 90 | grouped_actions = groupby(orders, lambda o: o[0]) 91 | return grouped_actions 92 | 93 | def play(self): 94 | while True: 95 | 96 | sys.stdout.write(f"\rPlaying game{'.' * int(self.galaxy.turn/4)}") 97 | sys.stdout.flush() 98 | 99 | logger.info("Turn started", extra={"turn": self.galaxy.turn}) 100 | orders = defaultdict(lambda: []) 101 | context = self.gmode.get_context( 102 | self.galaxy, self.players, self.galaxy.turn 103 | ) 104 | 105 | # Should I record the state? 106 | if self._renderer: 107 | self._renderer.render_frame(context) 108 | 109 | # log current score 110 | for player_score in context["score"]: 111 | logger.info("Current score", extra=player_score) 112 | 113 | for player in self.players: 114 | # Filtra cosas que ve el player según las reglas del juego 115 | galaxy = self.gmode.galaxy_for_player(self.galaxy, player) 116 | try: 117 | logger.info( 118 | "Computing orders for player", 119 | extra={ 120 | "turn": self.galaxy.turn, 121 | "player": player.name, 122 | }, 123 | ) 124 | 125 | player_orders = self.extract_player_orders( 126 | player, galaxy, context 127 | ) 128 | for name, player_orders in player_orders: 129 | for order in player_orders: 130 | orders[name].append((player, order[1:])) 131 | 132 | except Exception as e: 133 | logger.error( 134 | "Player lost turn", 135 | extra={ 136 | "turn": self.galaxy.turn, 137 | "player": player.name, 138 | "reason": str(e), 139 | }, 140 | ) 141 | logger.info( 142 | "Player orders computed", 143 | extra={ 144 | "turn": self.galaxy.turn, 145 | "player": player.name, 146 | "orders": 0, 147 | }, 148 | ) 149 | if self.raise_exceptions: 150 | raise e 151 | continue 152 | 153 | if self.gmode.has_ended(self.galaxy, self.galaxy.turn): 154 | if self.gmode.winner: 155 | logger.info( 156 | "Winner!", 157 | extra={ 158 | "turn": self.galaxy.turn, 159 | "winner": self.gmode.winner, 160 | }, 161 | ) 162 | message = f"Player {self.gmode.winner} wins\n" 163 | else: 164 | logger.info("Nobody won", extra={"turn": self.galaxy.turn}) 165 | message = "Nobody won\n" 166 | 167 | sys.stdout.write("\n") 168 | sys.stdout.write(message) 169 | 170 | if self._renderer: 171 | # Render last frame 172 | context = self.gmode.get_context( 173 | self.galaxy, self.players, self.galaxy.turn 174 | ) 175 | self._renderer.render_frame(context) 176 | # Save as gif 177 | self._renderer.save_gif(f"{self.galaxy.name}.gif") 178 | break 179 | 180 | # Reset explosions 181 | self.galaxy.explosions = [] 182 | 183 | # Sort orders by object id 184 | for o in orders.values(): 185 | o.sort(key=lambda x: x[1][0]) 186 | 187 | self.run_turn(orders) 188 | 189 | def run_turn(self, orders): 190 | """ 191 | Execute turn orders in the following order. 192 | 1. Ships download transfers :func:`action_ship_transfer` 193 | 2. Ships upload transfers :func:`action_ship_transfer` 194 | 3. Mines construction :func:`action_planet_build_mines` 195 | 4. Taxes changes 196 | 5. Ships movements :func:`action_ship_move` 197 | 6. Resolve ship to ship combats :func:`resolve_ship_to_ship` 198 | 7. Resolve ship to planet combats :func:`resolve_planet_to_ship` 199 | 8. Ships construction :func:`action_planet_build_ship` 200 | 9. Population changes 201 | 10. Happypoints changes 202 | 11. Taxes recollection 203 | 12. Pythonium extraction 204 | """ 205 | # 1. Ships download transfers 206 | # 2. Ships upload transfers 207 | self.run_player_action( 208 | "ship_transfer", orders.get("ship_transfer", []) 209 | ) 210 | 211 | # 3. Mines construction 212 | self.run_player_action( 213 | "planet_build_mines", orders.get("planet_build_mines", []) 214 | ) 215 | 216 | # 4. Taxes changes 217 | self.run_player_action( 218 | "planet_set_taxes", orders.get("planet_set_taxes", []) 219 | ) 220 | 221 | # 5. Ship movements 222 | self.run_player_action("ship_move", orders.get("ship_move", [])) 223 | 224 | # 6. Resolve ship to ship combats 225 | self.resolve_ships_conflicts() 226 | 227 | # 7. Resolve ship to planet combats 228 | self.resolve_planets_conflicts() 229 | 230 | # 8. Ship construction 231 | self.run_player_action( 232 | "planet_build_ship", orders.get("planet_build_ship", []) 233 | ) 234 | 235 | # 9. Population change 236 | # 10. Happypoints changes 237 | # 11. Taxes recollection 238 | # 12. Pythonium extraction 239 | self.produce_resources() 240 | 241 | self.galaxy.turn += 1 242 | 243 | def produce_resources(self): 244 | order = galaxy_orders.ProduceResources(self.galaxy) 245 | order.execute() 246 | 247 | def resolve_ships_conflicts(self): 248 | order = galaxy_orders.ResolveShipsConflicts(self.galaxy, cfg.tenacity) 249 | order.execute() 250 | 251 | def resolve_planets_conflicts(self): 252 | order = galaxy_orders.ResolvePlanetsConflicts(self.galaxy) 253 | order.execute() 254 | 255 | def run_player_action(self, name, orders): 256 | """ 257 | :param name: name del player que ejecuta la params 258 | :type name: str 259 | :param orders: Una tupla que indica la params a ejecutar y los parámetros 260 | de la misma. ('name', *params) 261 | :type orders: tuple 262 | """ 263 | func = getattr(self, f"action_{name}", None) 264 | for player, params in orders: 265 | if name.startswith("ship"): 266 | nid = params[0] 267 | args = params[1:] 268 | 269 | obj = self.galaxy.search_ship(nid) 270 | elif name.startswith("planet"): 271 | pid = params[0] 272 | args = params[1:] 273 | 274 | obj = self.galaxy.search_planet(pid) 275 | else: 276 | logger.warning( 277 | "Unknown params", 278 | extra={ 279 | "turn": self.galaxy.turn, 280 | "player": player.name, 281 | "params": params, 282 | }, 283 | ) 284 | continue 285 | 286 | if not obj: 287 | logger.warning( 288 | "Object not found", 289 | extra={ 290 | "turn": self.galaxy.turn, 291 | "player": player.name, 292 | "params": params, 293 | "name": name, 294 | }, 295 | ) 296 | continue 297 | 298 | if obj.player != player.name: 299 | logger.warning( 300 | "This is not yours", 301 | extra={ 302 | "turn": self.galaxy.turn, 303 | "player": player.name, 304 | "owner": obj.player, 305 | "obj": type(obj), 306 | }, 307 | ) 308 | continue 309 | 310 | logger.debug( 311 | "Running action for player", 312 | extra={ 313 | "turn": self.galaxy.turn, 314 | "player": obj.player, 315 | "action": name, 316 | "obj": type(obj), 317 | "args": args, 318 | }, 319 | ) 320 | 321 | try: 322 | func(obj, *args) 323 | except Exception as e: 324 | logger.error( 325 | "Unexpected error running player params", 326 | extra={ 327 | "turn": self.galaxy.turn, 328 | "player": obj.player, 329 | "action": name, 330 | "obj": type(obj), 331 | "args": args, 332 | "reason": e, 333 | }, 334 | ) 335 | 336 | def action_ship_move(self, ship, target): 337 | order = ship_orders.ShipMoveOrder(ship, target) 338 | order.execute(self.galaxy) 339 | 340 | def action_ship_transfer(self, ship, transfer): 341 | order = ship_orders.ShipTransferOrder(ship, transfer) 342 | order.execute(self.galaxy) 343 | 344 | def action_planet_build_mines(self, planet, new_mines): 345 | order = planet_orders.PlanetBuildMinesOrder(planet, new_mines) 346 | order.execute(self.galaxy) 347 | 348 | def action_planet_build_ship(self, planet, ship_type): 349 | 350 | ships_count = len(list(self.galaxy.get_player_ships(planet.player))) 351 | if ships_count >= self.gmode.max_ships: 352 | logger.warning( 353 | "Ships limit reached", 354 | extra={ 355 | "turn": self.galaxy.turn, 356 | "player": planet.player, 357 | "ships_count": ships_count, 358 | }, 359 | ) 360 | return 361 | order = planet_orders.PlanetBuildShipOrder(planet, ship_type) 362 | order.execute(self.galaxy) 363 | 364 | def action_planet_set_taxes(self, planet, taxes): 365 | order = planet_orders.PlanetSetTaxesOrder(planet, taxes) 366 | order.execute(self.galaxy) 367 | -------------------------------------------------------------------------------- /tests/test_planet.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythonium import Transfer, cfg 4 | 5 | from .factories import PlanetFactory, ShipTypeFactory 6 | 7 | 8 | def is_monotonic_increasing(sample): 9 | return all(sample[i] <= sample[i + 1] for i in range(len(sample) - 1)) 10 | 11 | 12 | def is_monotonic_decreasing(sample): 13 | return all(sample[i] >= sample[i + 1] for i in range(len(sample) - 1)) 14 | 15 | 16 | @pytest.fixture 17 | def planet(): 18 | return PlanetFactory() 19 | 20 | 21 | @pytest.fixture 22 | def ship_type(): 23 | return ShipTypeFactory() 24 | 25 | 26 | @pytest.fixture 27 | def colonized_planet(faker): 28 | return PlanetFactory(clans=faker.pyint(), megacredits=faker.pyint()) 29 | 30 | 31 | @pytest.fixture 32 | def optimal_temperature_planet(): 33 | return PlanetFactory(temperature=cfg.optimal_temperature) 34 | 35 | 36 | @pytest.fixture 37 | def temperatured_planet(temperature): 38 | return PlanetFactory(temperature=temperature) 39 | 40 | 41 | @pytest.fixture 42 | def planet_with_clans(clans): 43 | return PlanetFactory(clans=clans) 44 | 45 | 46 | @pytest.fixture 47 | def planet_with_hp(happypoints): 48 | planet = PlanetFactory() 49 | planet.happypoints = happypoints 50 | return planet 51 | 52 | 53 | @pytest.fixture 54 | def planets_group_with_mines(mines_range): 55 | planets = [] 56 | for mines in mines_range: 57 | planet = PlanetFactory(concentration=1.0, mines=mines) 58 | planets.append(planet) 59 | return planets 60 | 61 | 62 | @pytest.fixture 63 | def planets_group_with_concentration(concentrations_range): 64 | planets = [] 65 | for concentration in concentrations_range: 66 | planet = PlanetFactory(concentration=concentration / 100, mines=100) 67 | planets.append(planet) 68 | return planets 69 | 70 | 71 | @pytest.fixture 72 | def planets_group_with_happypoints(happypoints_range): 73 | planets = [] 74 | for happypoints in happypoints_range: 75 | planet = PlanetFactory(mines=100, concentration=1.0) 76 | planet.happypoints = happypoints 77 | planets.append(planet) 78 | return planets 79 | 80 | 81 | @pytest.fixture 82 | def planets_group_with_collection_factor(taxes_collection_factor): 83 | planets = [] 84 | for collection_factor in taxes_collection_factor: 85 | planet = PlanetFactory(clans=100, taxes=cfg.tolerable_taxes) 86 | planet.taxes_collection_factor = collection_factor 87 | planets.append(planet) 88 | return planets 89 | 90 | 91 | @pytest.fixture 92 | def planets_group_with_clans(clans_range): 93 | planets = [] 94 | for clans in clans_range: 95 | planet = PlanetFactory(clans=clans) 96 | planets.append(planet) 97 | return planets 98 | 99 | 100 | @pytest.fixture 101 | def planets_group_with_taxes(taxes_range): 102 | planets = [] 103 | for taxes in taxes_range: 104 | planet = PlanetFactory( 105 | temperature=cfg.optimal_temperature, taxes=taxes 106 | ) 107 | planets.append(planet) 108 | return planets 109 | 110 | 111 | @pytest.fixture 112 | def planets_group_with_temperature(temperatures_range): 113 | planets = [] 114 | for temperature in temperatures_range: 115 | planet = PlanetFactory(temperature=temperature) 116 | planets.append(planet) 117 | return planets 118 | 119 | 120 | class TestPlanet: 121 | @pytest.fixture 122 | def expected_repr(self, planet): 123 | return f"Planet(id={planet.id}, position={planet.position}, player={planet.player})" 124 | 125 | def test_repr(self, planet, expected_repr): 126 | assert str(planet) == expected_repr 127 | assert str(planet) == planet.__repr__() 128 | 129 | 130 | class TestPlanetHappyPoints: 131 | def test_max_hp_in_optimal_temperature(self, optimal_temperature_planet): 132 | assert optimal_temperature_planet.max_happypoints == 100 133 | 134 | @pytest.mark.parametrize("temperature", range(0, cfg.optimal_temperature)) 135 | def test_max_hp_below_optimal_temperature(self, temperatured_planet): 136 | assert temperatured_planet.max_happypoints < 100 137 | 138 | @pytest.mark.parametrize( 139 | "temperature", range(cfg.optimal_temperature + 1, 101) 140 | ) 141 | def test_max_hp_above_optimal_temperature(self, temperatured_planet): 142 | assert temperatured_planet.max_happypoints < 100 143 | 144 | @pytest.mark.parametrize( 145 | "temperatures_range", 146 | ( 147 | range(100, cfg.optimal_temperature, -1), 148 | range(0, cfg.optimal_temperature), 149 | ), 150 | ) 151 | def test_max_hp_monotonicity_with_temperature( 152 | self, planets_group_with_temperature 153 | ): 154 | max_happypoints_range = [ 155 | planet.max_happypoints for planet in planets_group_with_temperature 156 | ] 157 | assert is_monotonic_increasing(max_happypoints_range) 158 | 159 | @pytest.mark.parametrize("temperature", range(0, 101)) 160 | def test_init_hp_are_max(self, temperatured_planet): 161 | assert ( 162 | temperatured_planet.max_happypoints 163 | == temperatured_planet.happypoints 164 | ) 165 | 166 | @pytest.mark.parametrize( 167 | "taxes_range", 168 | ( 169 | range(0, cfg.tolerable_taxes), 170 | range(100, cfg.tolerable_taxes, -1), 171 | ), 172 | ) 173 | def test_dhappypoints_monotonicity_with_taxes( 174 | self, planets_group_with_taxes 175 | ): 176 | dhappypoints_range = [ 177 | planet.dhappypoints for planet in planets_group_with_taxes 178 | ] 179 | assert is_monotonic_increasing(dhappypoints_range) 180 | 181 | def test_dhappypoints_are_zero_when_hp_are_max(self, planet): 182 | planet.taxes = 0 183 | assert planet.happypoints == planet.max_happypoints 184 | assert not planet.dhappypoints 185 | 186 | def test_dhappypoints_is_int(self, planet): 187 | assert type(planet.dhappypoints) is int 188 | 189 | 190 | class TestPlanetMines: 191 | @pytest.mark.parametrize("clans", range(0, cfg.planet_max_mines)) 192 | def test_max_mines_depends_on_clans(self, planet_with_clans): 193 | assert planet_with_clans.max_mines == planet_with_clans.clans 194 | 195 | @pytest.mark.parametrize( 196 | "clans", 197 | range(cfg.planet_max_mines, cfg.max_clans_in_planet, 100), 198 | ) 199 | def test_planet_max_mines_depends_on_config(self, planet_with_clans): 200 | assert planet_with_clans.max_mines == cfg.planet_max_mines 201 | 202 | def test_can_build_mines_return_zero_if_max_mines(self, colonized_planet): 203 | colonized_planet.pythonium = colonized_planet.mine_cost.pythonium 204 | colonized_planet.megacredits = colonized_planet.mine_cost.megacredits 205 | colonized_planet.mines = colonized_planet.max_mines 206 | assert not colonized_planet.can_build_mines() 207 | 208 | def test_can_build_mines_return_zero_if_no_mc(self, colonized_planet): 209 | colonized_planet.megacredits = 0 210 | assert not colonized_planet.can_build_mines() 211 | 212 | def test_can_build_mines_return_zero_if_no_pythonium( 213 | self, colonized_planet 214 | ): 215 | colonized_planet.pythonium = 0 216 | assert not colonized_planet.can_build_mines() 217 | 218 | def test_can_build_mines_depends_on_minimum_pythonium( 219 | self, colonized_planet 220 | ): 221 | colonized_planet.pythonium = colonized_planet.mine_cost.pythonium 222 | colonized_planet.megacredits = ( 223 | colonized_planet.mine_cost.megacredits * 2 224 | ) 225 | assert colonized_planet.can_build_mines() == 1 226 | 227 | def test_can_build_mines_depends_on_minimum_megacredits( 228 | self, colonized_planet 229 | ): 230 | colonized_planet.pythonium = colonized_planet.mine_cost.pythonium * 2 231 | colonized_planet.megacredits = colonized_planet.mine_cost.megacredits 232 | assert colonized_planet.can_build_mines() == 1 233 | 234 | def test_can_build_mines_is_int(self, planet): 235 | assert type(planet.can_build_mines()) is int 236 | 237 | def test_mine_cost_is_transfer(self, planet): 238 | assert type(planet.mine_cost) is Transfer 239 | 240 | def test_mines_init_is_zero(self, planet): 241 | assert not planet.mines 242 | 243 | 244 | class TestPlanetPythonium: 245 | @pytest.mark.parametrize("mines_range", (range(0, cfg.planet_max_mines),)) 246 | def test_dpythonium_increases_with_mines(self, planets_group_with_mines): 247 | dpythonium_range = [ 248 | planet.dpythonium for planet in planets_group_with_mines 249 | ] 250 | assert is_monotonic_increasing(dpythonium_range) 251 | 252 | @pytest.mark.parametrize("concentrations_range", (range(0, 100),)) 253 | def test_dpythonium_increases_with_concentration( 254 | self, planets_group_with_concentration 255 | ): 256 | dpythonium_range = [ 257 | planet.dpythonium for planet in planets_group_with_concentration 258 | ] 259 | assert is_monotonic_increasing(dpythonium_range) 260 | 261 | @pytest.mark.parametrize( 262 | "happypoints_range", (range(0, cfg.happypoints_tolerance),) 263 | ) 264 | def test_dpythonium_decreases_with_rioting_index( 265 | self, planets_group_with_happypoints 266 | ): 267 | dpythonium_range = [ 268 | planet.dpythonium for planet in planets_group_with_happypoints 269 | ] 270 | assert is_monotonic_increasing(dpythonium_range) 271 | 272 | def test_dpythonium_is_int(self, planet): 273 | assert type(planet.dpythonium) is int 274 | 275 | def test_underground_dpythonium_is_int(self, planet): 276 | assert type(planet.underground_pythonium) is int 277 | 278 | def test_concentration_is_float(self, planet): 279 | assert type(planet.concentration) is float 280 | 281 | def test_concentration_is_ratio(self, planet): 282 | assert 0 <= planet.concentration <= 1 283 | 284 | 285 | class TestPlanetMegacredits: 286 | @pytest.mark.parametrize("taxes_collection_factor", (range(0, 100),)) 287 | def test_dmegacredits_increases_with_taxes_collection_factor( 288 | self, planets_group_with_collection_factor 289 | ): 290 | dmegacredits_range = [ 291 | planet.dmegacredits 292 | for planet in planets_group_with_collection_factor 293 | ] 294 | assert is_monotonic_increasing(dmegacredits_range) 295 | 296 | @pytest.mark.parametrize( 297 | "clans_range", (range(0, cfg.max_clans_in_planet, 100),) 298 | ) 299 | def test_dmegacredits_increases_with_clans(self, planets_group_with_clans): 300 | dmegacredits_range = [ 301 | planet.dmegacredits for planet in planets_group_with_clans 302 | ] 303 | assert is_monotonic_increasing(dmegacredits_range) 304 | 305 | @pytest.mark.parametrize("taxes_range", (range(0, cfg.tolerable_taxes),)) 306 | def test_dmegacredits_increases_with_taxes(self, planets_group_with_taxes): 307 | dmegacredits_range = [ 308 | planet.dmegacredits for planet in planets_group_with_taxes 309 | ] 310 | assert is_monotonic_increasing(dmegacredits_range) 311 | 312 | @pytest.mark.parametrize("happypoints_range", (range(0, 100),)) 313 | def test_dmegacredits_decreases_with_rioting_index( 314 | self, planets_group_with_happypoints 315 | ): 316 | dmegacredits_range = [ 317 | planet.dmegacredits for planet in planets_group_with_happypoints 318 | ] 319 | assert is_monotonic_decreasing(dmegacredits_range) 320 | 321 | def test_dmegacredits_is_int(self, planet): 322 | assert type(planet.dmegacredits) is int 323 | 324 | def test_megacredits_init_is_zero(self, planet): 325 | assert not planet.megacredits 326 | 327 | def test_taxes_init_is_zero(self, planet): 328 | assert not planet.taxes 329 | 330 | 331 | class TestPlanetRiotingIndex: 332 | @pytest.mark.parametrize( 333 | "happypoints", 334 | range(0, cfg.happypoints_tolerance), 335 | ) 336 | def test_rioting_index_decrease_with_happypoints_below_tolerance( 337 | self, planet_with_hp 338 | ): 339 | assert planet_with_hp.rioting_index < 1 340 | 341 | @pytest.mark.parametrize( 342 | "happypoints", 343 | range(cfg.happypoints_tolerance, 100), 344 | ) 345 | def test_rioting_index_no_effect_with_happypoints_above_tolerance( 346 | self, planet_with_hp 347 | ): 348 | assert planet_with_hp.rioting_index == 1 349 | 350 | 351 | class TestPlanetClans: 352 | @pytest.mark.parametrize("happypoints_range", (range(0, 100),)) 353 | def test_dclans_decreases_with_rioting_index( 354 | self, planets_group_with_happypoints 355 | ): 356 | dclans_range = [ 357 | planet.dclans for planet in planets_group_with_happypoints 358 | ] 359 | assert is_monotonic_decreasing(dclans_range) 360 | 361 | def test_dclans_is_int(self, planet): 362 | assert type(planet.dclans) is int 363 | 364 | def test_clans_init_is_zero(self, planet): 365 | assert not planet.clans 366 | 367 | def test_dclans_zero_on_max_clans_in_planet_is_reached( 368 | self, optimal_temperature_planet 369 | ): 370 | optimal_temperature_planet.clans = cfg.max_clans_in_planet 371 | assert not optimal_temperature_planet.dclans 372 | 373 | def test_dclans_in_optimal_temperature_is_pisitive( 374 | self, optimal_temperature_planet 375 | ): 376 | optimal_temperature_planet.clans = 100 377 | assert optimal_temperature_planet.dclans > 0 378 | 379 | 380 | class TestPlanetOrders: 381 | def test_get_orders_return_list(self, colonized_planet): 382 | assert isinstance(colonized_planet.get_orders(), list) 383 | 384 | @pytest.mark.parametrize("taxes", range(0, 101)) 385 | def test_set_taxes_order(self, taxes, colonized_planet): 386 | colonized_planet.taxes = taxes 387 | orders = colonized_planet.get_orders() 388 | taxes_order = next(o for o in orders if o[0] == "planet_set_taxes") 389 | assert taxes_order[1] == colonized_planet.id 390 | assert taxes_order[2] == taxes 391 | 392 | @pytest.mark.parametrize("new_mines", range(1, 101)) 393 | def test_build_mines_order(self, new_mines, colonized_planet): 394 | colonized_planet.new_mines = new_mines 395 | orders = colonized_planet.get_orders() 396 | build_mines_order = next( 397 | o for o in orders if o[0] == "planet_build_mines" 398 | ) 399 | assert build_mines_order[1] == colonized_planet.id 400 | assert build_mines_order[2] == new_mines 401 | 402 | def test_no_mines_order_if_new_mines_is_zero(self, colonized_planet): 403 | orders = colonized_planet.get_orders() 404 | order_names = [o[0] for o in orders] 405 | assert "planet_build_mines" not in order_names 406 | 407 | def test_build_ship_order(self, ship_type, colonized_planet): 408 | colonized_planet.new_ship = ship_type 409 | orders = colonized_planet.get_orders() 410 | build_ship_order = next( 411 | o for o in orders if o[0] == "planet_build_ship" 412 | ) 413 | assert build_ship_order[1] == colonized_planet.id 414 | assert build_ship_order[2] is ship_type 415 | 416 | def test_no_ship_order_if_new_ship_is_none(self, colonized_planet): 417 | orders = colonized_planet.get_orders() 418 | order_names = [o[0] for o in orders] 419 | assert "planet_build_ship" not in order_names 420 | -------------------------------------------------------------------------------- /pythonium/metrics_collector.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import defaultdict 3 | from datetime import datetime 4 | from io import BytesIO 5 | from itertools import groupby 6 | 7 | import matplotlib.pyplot as plt 8 | from PIL import Image, ImageDraw 9 | 10 | from . import __version__, cfg 11 | from .helpers import load_font 12 | 13 | log_regex = re.compile( 14 | r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) \[(?PINFO|WARNING|ERROR|DEBUG)\] (?P[\w\W_]+):(?P[a-zA-Z_]+) (?P.+) - (?=(?:(?P.+))$)?" 15 | ) 16 | 17 | 18 | class MetricsCollector: 19 | def __init__(self, logfile): 20 | self.figsize = (8, 4) 21 | self._footer_font = load_font(24) 22 | self._section_font = load_font(48) 23 | self._title_font = load_font(96) 24 | self._available_players_colors = ["#EE302F", "#50A8E0"] 25 | 26 | lines = logfile.readlines() 27 | self.logdicts = [] 28 | for line in lines: 29 | result = log_regex.match(line) 30 | if not result: 31 | continue 32 | groupdict = result.groupdict() 33 | groupdict["datetime"] = datetime.fromisoformat( 34 | groupdict["datetime"].replace(",", ".") 35 | ) 36 | extras = groupdict.get("extras") 37 | if extras: 38 | groupdict["extras"] = dict( 39 | (el.split("=") for el in extras.split("; ")) 40 | ) 41 | self.logdicts.append(groupdict) 42 | self.turns = [ 43 | int(log["extras"]["turn"]) 44 | for log in filter( 45 | lambda log: log["message"] == "Turn started", self.logdicts 46 | ) 47 | ] 48 | self.known_players = list( 49 | { 50 | log["extras"]["player"] 51 | for log in filter( 52 | lambda log: log["message"] == "Player orders computed", 53 | self.logdicts, 54 | ) 55 | } 56 | ) 57 | 58 | # Search galaxy name 59 | init_log = list( 60 | filter( 61 | lambda l: l["message"] == "Initializing galaxy", self.logdicts 62 | ) 63 | )[0] 64 | self.galaxy_name = init_log["extras"]["galaxy_name"] 65 | 66 | # Search winner 67 | winner_log = list( 68 | filter(lambda l: l["message"] == "Winner!", self.logdicts) 69 | ) 70 | self.winner = winner_log[0]["extras"]["winner"] if winner_log else None 71 | 72 | def get_metric_for_players(self, message, key, data_type=int): 73 | """ 74 | Return a time series of each value related to ``key`` in a log 75 | with ``log['message'] == message``. 76 | 77 | The value is transformed to ``data_type``. 78 | 79 | The function ensures that all there are one metric per turn for 80 | each``self.known_players`` 81 | 82 | This is used to filter, for example, the score of the players 83 | on each turn, or metrics that we know that are only one per player and turn. 84 | """ 85 | logs = filter(lambda l: l["message"] == message, self.logdicts) 86 | data = defaultdict(lambda: []) 87 | for log in logs: 88 | extras = log["extras"] 89 | data[extras["player"]].append(data_type(extras[key])) 90 | data["turn"] = self.turns 91 | # Check that all players have a metrics for each turn 92 | assert all( 93 | len(metrics) == len(self.turns) for metrics in data.values() 94 | ) 95 | found_players = data.keys() 96 | assert all(p in found_players for p in self.known_players) 97 | return data 98 | 99 | def get_turns_runtime(self): 100 | """ 101 | Return a time serie with the runtime in microseconds for each turn 102 | """ 103 | logs = list( 104 | filter(lambda l: l["message"] == "Turn started", self.logdicts) 105 | ) 106 | previous = logs[0]["datetime"] 107 | data = defaultdict(lambda: []) 108 | for log in logs[1:]: 109 | data["turn"].append(int(log["extras"]["turn"])) 110 | data["runtime"].append((log["datetime"] - previous).microseconds) 111 | previous = log["datetime"] 112 | return data 113 | 114 | def get_events_for_players(self, message, key, data_type=int): 115 | """ 116 | Find metrics related to an event for each player. 117 | There can be more than one event for each player per turn. 118 | 119 | Return a dict where player names are the keys and values are 120 | a list of lists. One list per turn with none event or more. 121 | 122 | This is used for example to filter all the pythonium produced in each 123 | planet for each player every turn. There are more than one event per 124 | turn and player. 125 | """ 126 | # Filter logs and group by turn 127 | grouped_logs = groupby( 128 | filter(lambda l: l["message"] == message, self.logdicts), 129 | lambda l: int(l["extras"]["turn"]), 130 | ) 131 | 132 | # Create the empty container for each player 133 | data = dict((p, [[] for t in self.turns]) for p in self.known_players) 134 | for turn, logs in grouped_logs: 135 | # group by player 136 | for log in logs: 137 | player = log["extras"]["player"] 138 | value = data_type(log["extras"][key]) 139 | data[player][turn + 1].append(value) 140 | data["turn"] = self.turns 141 | return data 142 | 143 | def aggregate_events_for_players(self, events, agg): 144 | """ 145 | Perform aggregation (count, sum, avg, etc) to events metrics for each player 146 | ``events`` is the output of ``get_events_for_players`` 147 | """ 148 | data = { 149 | player: [agg(m) for m in metrics] 150 | for player, metrics in events.items() 151 | if player != "turn" 152 | } 153 | data["turn"] = events["turn"] 154 | return data 155 | 156 | def plot_metrics_for_players(self, metrics, title, ylabel): 157 | fig, axs = plt.subplots(figsize=self.figsize) 158 | for i, player in enumerate(p for p in metrics.keys() if p != "turn"): 159 | color = self._available_players_colors[i] 160 | axs.plot(metrics["turn"], metrics[player], color=color) 161 | axs.set_xlabel("") 162 | axs.set_ylabel(ylabel, fontsize=16) 163 | axs.set_title(title, fontdict={"fontsize": 18}) 164 | 165 | buf = BytesIO() 166 | fig.savefig(buf, format="png") 167 | buf.seek(0) 168 | img = Image.open(buf) 169 | return img 170 | 171 | def plot_section(self, title, charts, rows, columns, header_height): 172 | """ 173 | Plot a section of metrics. 174 | The charts are drawed in mosaic acording to ``rows`` and ``columns`` 175 | """ 176 | sample_chart = charts[0] 177 | margin = 30 178 | section_size = ( 179 | sample_chart.width * columns + margin * 2, 180 | sample_chart.height * rows + header_height + margin * 2, 181 | ) 182 | image = Image.new("RGBA", section_size, "white") 183 | draw = ImageDraw.Draw(image) 184 | 185 | # Hack to fix hidden margin in section with 2 and 3 charts 186 | if len(charts) == 3: 187 | bounding_box = ( 188 | (margin / 2, margin / 2), 189 | (section_size[0], section_size[1] - margin / 2), 190 | ) 191 | elif len(charts) == 2: 192 | bounding_box = ( 193 | (0, margin / 2), 194 | (section_size[0] - margin / 2, section_size[1] - margin / 2), 195 | ) 196 | else: 197 | bounding_box = ( 198 | (margin / 2, margin / 2), 199 | (section_size[0] - margin / 2, section_size[1] - margin / 2), 200 | ) 201 | 202 | draw.rectangle( 203 | bounding_box, fill="white", outline=(40, 40, 40, 256), width=5 204 | ) 205 | 206 | draw.text( 207 | (margin + 20, margin + 40), 208 | title, 209 | font=self._section_font, 210 | fill="black", 211 | ) 212 | 213 | x = margin 214 | y = header_height + margin 215 | for chart in charts: 216 | image.paste(chart, (x, y)) 217 | x += chart.width 218 | if x == chart.width * columns + margin: 219 | x = margin 220 | y += chart.height 221 | 222 | return image 223 | 224 | def build_sections(self): 225 | # Score 226 | planet_scores = self.get_metric_for_players("Current score", "planets") 227 | # FIXME: This only works with the two classic mode ship types 228 | carriers_scores = self.get_metric_for_players( 229 | "Current score", "ships_carrier" 230 | ) 231 | warships_scores = self.get_metric_for_players( 232 | "Current score", "ships_war" 233 | ) 234 | ships_scores = self.get_metric_for_players( 235 | "Current score", "total_ships" 236 | ) 237 | 238 | # Runtime 239 | turns_runtime = self.get_turns_runtime() 240 | player_orders = self.get_metric_for_players( 241 | "Player orders computed", "orders" 242 | ) 243 | 244 | # War 245 | conquered_planets = self.aggregate_events_for_players( 246 | self.get_events_for_players( 247 | message="Planet conquered by force", 248 | key="planet", 249 | data_type=str, 250 | ), 251 | len, 252 | ) 253 | killed_clans = self.aggregate_events_for_players( 254 | self.get_events_for_players("Planet conquered by force", "clans"), 255 | sum, 256 | ) 257 | ships_lost = self.aggregate_events_for_players( 258 | self.get_events_for_players("Explosion", "ship_type", bool), len 259 | ) 260 | 261 | # Economy 262 | dpythonium = self.get_events_for_players( 263 | "Pythonium change", "dpythonium" 264 | ) 265 | dclans = self.get_events_for_players("Population change", "dclans") 266 | dmegacredits = self.get_events_for_players( 267 | "Megacredits change", "dmegacredits" 268 | ) 269 | built_mines = self.aggregate_events_for_players( 270 | self.get_events_for_players("New mines", "new_mines"), sum 271 | ) 272 | built_ships = self.aggregate_events_for_players( 273 | self.get_events_for_players("New ship built", "ship_type", bool), 274 | len, 275 | ) 276 | 277 | total_dpythonium = self.aggregate_events_for_players(dpythonium, sum) 278 | total_dclans = self.aggregate_events_for_players(dclans, sum) 279 | total_dmegacredits = self.aggregate_events_for_players( 280 | dmegacredits, sum 281 | ) 282 | 283 | def avg(i): 284 | return sum(i) / len(i) if i else 0 285 | 286 | avg_dpythonium = self.aggregate_events_for_players(dpythonium, avg) 287 | avg_dclans = self.aggregate_events_for_players(dclans, avg) 288 | avg_dmegacredits = self.aggregate_events_for_players(dmegacredits, avg) 289 | 290 | sections = [] 291 | 292 | score = {"title": "Score", "charts": []} 293 | score["charts"].append( 294 | self.plot_metrics_for_players( 295 | planet_scores, "Planets Score", "Planets" 296 | ) 297 | ) 298 | score["charts"].append( 299 | self.plot_metrics_for_players( 300 | carriers_scores, "Carriers", "Carriers" 301 | ) 302 | ) 303 | score["charts"].append( 304 | self.plot_metrics_for_players( 305 | warships_scores, "War Ships", "War Ships" 306 | ) 307 | ) 308 | score["charts"].append( 309 | self.plot_metrics_for_players( 310 | ships_scores, "Total Ships", "Total Ships" 311 | ) 312 | ) 313 | sections.append(score) 314 | 315 | combat = {"title": "Combat", "charts": []} 316 | combat["charts"].append( 317 | self.plot_metrics_for_players( 318 | conquered_planets, "Conquered Planets", "Planets" 319 | ) 320 | ) 321 | combat["charts"].append( 322 | self.plot_metrics_for_players( 323 | killed_clans, "Killed clans", "Clans" 324 | ) 325 | ) 326 | combat["charts"].append( 327 | self.plot_metrics_for_players(ships_lost, "Ships lost", "Ships") 328 | ) 329 | sections.append(combat) 330 | 331 | economy = {"title": "Economy", "charts": []} 332 | economy["charts"].append( 333 | self.plot_metrics_for_players( 334 | total_dpythonium, "Extracted pythonium", "Pythonium" 335 | ) 336 | ) 337 | economy["charts"].append( 338 | self.plot_metrics_for_players( 339 | total_dclans, "Population growth", "Clans" 340 | ) 341 | ) 342 | economy["charts"].append( 343 | self.plot_metrics_for_players( 344 | total_dmegacredits, "Collected megacredits", "Megacredits" 345 | ) 346 | ) 347 | economy["charts"].append( 348 | self.plot_metrics_for_players(built_ships, "Ships Built", "Ships") 349 | ) 350 | economy["charts"].append( 351 | self.plot_metrics_for_players( 352 | avg_dpythonium, "Avg extracted pythonium", "Pythonium" 353 | ) 354 | ) 355 | economy["charts"].append( 356 | self.plot_metrics_for_players( 357 | avg_dclans, "Avg population growth", "Clans" 358 | ) 359 | ) 360 | economy["charts"].append( 361 | self.plot_metrics_for_players( 362 | avg_dmegacredits, "Avg collected megacredits", "Megacredits" 363 | ) 364 | ) 365 | economy["charts"].append( 366 | self.plot_metrics_for_players(built_mines, "Mines Built", "Mines") 367 | ) 368 | sections.append(economy) 369 | 370 | execution = {"title": "Execution", "charts": []} 371 | execution["charts"].append( 372 | self.plot_metrics_for_players( 373 | turns_runtime, "Turn Execution Time", "Microseconds" 374 | ) 375 | ) 376 | execution["charts"].append( 377 | self.plot_metrics_for_players( 378 | player_orders, "Orders per turn", "Orders" 379 | ) 380 | ) 381 | sections.append(execution) 382 | 383 | return sections 384 | 385 | def build_report(self): 386 | """ 387 | Build the report with all the metrics 388 | """ 389 | 390 | report_size = (3260, 2530) 391 | report = Image.new("RGB", report_size, "white") 392 | sections_position = { 393 | "Execution": (2400, 0), 394 | "Economy": (0, 1520), 395 | "Score": (0, 960), 396 | "Combat": (0, 400), 397 | } 398 | 399 | sections = self.build_sections() 400 | 401 | header_height = 100 402 | for section in sections: 403 | charts_count = len(section["charts"]) 404 | title = section["title"] 405 | if charts_count == 8: 406 | rows = 2 407 | columns = 4 408 | elif charts_count == 4: 409 | rows = 1 410 | columns = 4 411 | elif charts_count == 3: 412 | rows = 1 413 | columns = 3 414 | elif charts_count == 2: 415 | rows = 2 416 | columns = 1 417 | elif charts_count == 1: 418 | rows = 1 419 | columns = 1 420 | img = self.plot_section( 421 | section["title"], 422 | section["charts"], 423 | rows, 424 | columns, 425 | header_height, 426 | ) 427 | report.paste(img, sections_position[title]) 428 | 429 | # Draw Header 430 | draw = ImageDraw.Draw(report) 431 | draw.text( 432 | (100, 50), 433 | f"Galaxy #{self.galaxy_name}", 434 | font=self._title_font, 435 | fill="black", 436 | ) 437 | if len(self.known_players) == 2: 438 | p1 = self.known_players[0] 439 | p2 = self.known_players[1] 440 | text = ( 441 | f"{p1}{'' if self.winner != p1 else ' (w)'} " 442 | + f"Vs. {self.known_players[1]}{'' if self.winner != p2 else ' (w)'}" 443 | ) 444 | draw.text((100, 200), text, font=self._section_font, fill="black") 445 | elif len(self.known_players) == 1: 446 | draw.text( 447 | (100, 200), 448 | f"Survival mode for {self.known_players[0]}", 449 | font=self._section_font, 450 | fill="black", 451 | ) 452 | draw.text( 453 | (100, 300), 454 | datetime.now().strftime("%Y-%m-%d %H:%M"), 455 | font=self._section_font, 456 | fill="black", 457 | ) 458 | 459 | draw.text( 460 | (15, report_size[1] - 40), 461 | f"github.com/Bgeninatti/pythonium - V{__version__}", 462 | font=self._footer_font, 463 | fill="black", 464 | ) 465 | 466 | report.save(f"report_{self.galaxy_name}.png") 467 | --------------------------------------------------------------------------------