diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f33a02c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ac10e8f..f8e65b8 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,29 +1,28 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - name: Upload Dofutils Package on: release: - types: [ created ] + types: [created] + +permissions: + contents: read jobs: deploy: - runs-on: ubuntu-latest - + env: + UV_PYTHON: "3.14" + UV_PUBLISH_USERNAME: ${{ secrets.PYPI_USERNAME }} + UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_PASSWORD }} steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - name: Set up Poetry - uses: Gr1N/setup-poetry@v8 - - name: Install dependencies - run: poetry install - - name: Build - run: poetry build - - name: Publish - run: | - poetry publish -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} + - name: Checkout + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + + - name: Build package + run: uv build + + - name: Publish package + run: uv publish diff --git a/.github/workflows/python-test-publish.yml b/.github/workflows/python-test-publish.yml index 8cdf4c2..4b899b1 100644 --- a/.github/workflows/python-test-publish.yml +++ b/.github/workflows/python-test-publish.yml @@ -1,31 +1,29 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - name: Upload Dofutils Test Package on: push: - branches: [ test_release ] + branches: [test_release] + +permissions: + contents: read jobs: deploy: - runs-on: ubuntu-latest - + env: + UV_PYTHON: "3.14" + UV_PUBLISH_URL: https://test.pypi.org/legacy/ + UV_PUBLISH_USERNAME: ${{ secrets.PYPI_TEST_USERNAME }} + UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TEST_PASSWORD }} steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_TEST_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_TEST_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload --repository-url https://test.pypi.org/legacy/ dist/* + - name: Checkout + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + + - name: Build package + run: uv build + + - name: Publish package to TestPyPI + run: uv publish diff --git a/.github/workflows/python-unit-test.yml b/.github/workflows/python-unit-test.yml index 24f2361..85fba6b 100644 --- a/.github/workflows/python-unit-test.yml +++ b/.github/workflows/python-unit-test.yml @@ -1,95 +1,61 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Dofutils CI Full +name: Dofutils CI on: push: branches: - - '**' + - "main" pull_request: branches: - - '**' + - "**" -jobs: - linter: - runs-on: ${{ matrix.os }} - name: Lint Python - continue-on-error: true +permissions: + contents: read +jobs: + code-sanitize: + name: Code Sanitize + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.11', 'pypy3.9'] - os: [ ubuntu-latest ] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Set up Poetry - uses: Gr1N/setup-poetry@v8 - - uses: actions/cache@v2 - with: - path: ~/.cache/pypoetry/virtualenvs - key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} - - name: Install dependencies - run: poetry install - - name: Launch linter - run: poetry run task lint - - type-checker: - runs-on: ${{ matrix.os }} - name: TypeChecker Python - continue-on-error: true + - name: Checkout + uses: actions/checkout@v7 - strategy: - matrix: - python-version: ['3.11', 'pypy3.9'] - os: [ ubuntu-latest ] + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Set up Poetry - uses: Gr1N/setup-poetry@v8 - - uses: actions/cache@v2 - with: - path: ~/.cache/pypoetry/virtualenvs - key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} - - name: Install dependencies - run: poetry install - - name: Launch type checker - run: poetry run task check + - name: Check code sanitize + run: | + uv sync --all-groups + uv run ruff check . + uv run ruff format --check . + uv run mypy . --exclude '^tests/' unit-tests: - runs-on: ${{ matrix.os }} - name: Unittest Python - continue-on-error: true - + name: Unit tests + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', 'pypy3.8', 'pypy3.9'] - os: [ ubuntu-latest ] - + python-version: ["3.11", "3.12", "3.13", "3.14"] + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Setup Poetry - uses: Gr1N/setup-poetry@v8 - - - uses: actions/cache@v2 - with: - path: ~/.cache/pypoetry/virtualenvs - key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} - - name: Install dependencies - run: poetry install - - name: Launch unit test - run: poetry run task test + - name: Checkout + uses: actions/checkout@v7 + + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-groups + + - name: Run unittests + run: uv run task test diff --git a/.gitignore b/.gitignore index 534f153..d9ac122 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ .idea/ .mypy_cache/ +.ruff_cache/ __pycache__/ build/ dist/ Dofutils.egg-info/ poetry.lock -doc/_build \ No newline at end of file +uv.lock +doc/_build +.devcontainer/devcontainer-lock.json diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 9853424..74d67f4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,6 +7,6 @@ sphinx: configuration: docs/conf.py python: - version: "3.8" + version: "3.10" install: - - requirements: docs/requirements.txt \ No newline at end of file + - requirements: docs/requirements.txt diff --git a/.vscode/launch.json b/.vscode/launch.json index 306f58e..92390e4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "configurations": [ { "name": "Python: Current File", - "type": "python", + "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal", diff --git a/.vscode/settings.json b/.vscode/settings.json index 3f988b7..399398f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,12 @@ { "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + }, }, - "python.formatting.provider": "none", "python.testing.unittestArgs": [ "-v", "-s", diff --git a/docs/modules/maps.rst b/docs/modules/maps.rst index 22ddd20..4ad583c 100644 --- a/docs/modules/maps.rst +++ b/docs/modules/maps.rst @@ -3,22 +3,90 @@ Maps This shows all the maps objects and usages. -All of these classes are importable from ``dofutils.maps`` +These core classes are importable from ``dofutils.maps``. .. currentmodule:: dofutils.maps - -BattleFieldCell +CoordinateCell --------------- -.. autoclass:: BattleFieldCell +.. autoclass:: CoordinateCell :members: :undoc-members: -CoordinateCell ---------------- +DofusMap +-------- -.. autoclass:: CoordinateCell +.. autoclass:: DofusMap + :members: + :undoc-members: + +Serialization +------------- + +.. autoclass:: CellLayerData + :members: + :undoc-members: + +.. autoclass:: GroundCellData + :members: + :undoc-members: + +.. autoclass:: InteractiveObjectData + :members: + :undoc-members: + +.. autoclass:: CellData + :members: + :undoc-members: + +.. autoclass:: DefaultMapDataSerializer + :members: + :undoc-members: + +.. autoclass:: EncryptedMapDataSerializer + :members: + :undoc-members: + +Sight +----- + +All of these classes are importable from ``dofutils.maps.sight``. + +.. currentmodule:: dofutils.maps.sight + +.. autoclass:: BattlefieldSight + :members: + :undoc-members: + +.. autoclass:: CellSight + :members: + :undoc-members: + +Pathfinding +----------- + +All of these classes are importable from ``dofutils.maps.path``. + +.. currentmodule:: dofutils.maps.path + +.. autoclass:: Path + :members: + :undoc-members: + +.. autoclass:: PathDecoder + :members: + :undoc-members: + +.. autoclass:: PathException + :members: + :undoc-members: + +.. autoclass:: PathStep + :members: + :undoc-members: + +.. autoclass:: Pathfinder :members: :undoc-members: diff --git a/dofutils/encoding/__init__.py b/dofutils/encoding/__init__.py index 0a96a77..e6971be 100644 --- a/dofutils/encoding/__init__.py +++ b/dofutils/encoding/__init__.py @@ -1,5 +1,5 @@ -from dofutils.encoding.base64 import Base64 -from dofutils.encoding.checksum import CheckSum -from dofutils.encoding.key import Key -from dofutils.encoding.password_encoder import PasswordEncoder -from dofutils.encoding.xor_cipher import XorCipher +from dofutils.encoding.base64 import Base64 as Base64 +from dofutils.encoding.checksum import CheckSum as CheckSum +from dofutils.encoding.key import Key as Key +from dofutils.encoding.password_encoder import PasswordEncoder as PasswordEncoder +from dofutils.encoding.xor_cipher import XorCipher as XorCipher diff --git a/dofutils/encoding/base64.py b/dofutils/encoding/base64.py index 2cf469b..3707aca 100644 --- a/dofutils/encoding/base64.py +++ b/dofutils/encoding/base64.py @@ -1,6 +1,11 @@ +from __future__ import annotations + +from typing import ClassVar + + class Base64: # fmt: off - _CHARSET: list = [ + _CHARSET: ClassVar[list] = [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "_", @@ -77,14 +82,7 @@ def encode(value: int, length: int) -> str: if length < 1 or length > 6: raise ValueError("Parameter length must be in range [1-6]") - v: int = value - result: str = "" - - for i in range(length, 0, -1): - result = str(Base64._CHARSET[v & 63]) + result - v >>= 6 - - return result + return "".join(Base64._CHARSET[(value >> (6 * i)) & 63] for i in range(length - 1, -1, -1)) @staticmethod def decode(encoded: str) -> int: @@ -132,8 +130,4 @@ def to_bytes(encoded: str) -> bytearray: :return: the decoded byte array. The array size will be the same as the string size :rtype: bytearray """ - b: bytearray = bytearray() - for c in encoded: - b.append(Base64.ord(c)) - - return b + return bytearray(map(Base64.ord, encoded)) diff --git a/dofutils/encoding/checksum.py b/dofutils/encoding/checksum.py index 3a58cb9..0b50cf7 100644 --- a/dofutils/encoding/checksum.py +++ b/dofutils/encoding/checksum.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + class CheckSum: @staticmethod def integer(value: str) -> int: @@ -8,7 +11,7 @@ def integer(value: str) -> int: :return: the checksum of the given value :rtype: str """ - csum: int = sum([ord(s) % 16 for s in value]) + csum: int = sum(ord(s) % 16 for s in value) return csum % 16 @staticmethod diff --git a/dofutils/encoding/key.py b/dofutils/encoding/key.py index 3905b05..c43828d 100644 --- a/dofutils/encoding/key.py +++ b/dofutils/encoding/key.py @@ -1,7 +1,6 @@ from __future__ import annotations from secrets import token_urlsafe -from typing import Optional from urllib.parse import unquote_plus, urlencode from .xor_cipher import XorCipher @@ -9,8 +8,14 @@ class Key: def __init__(self, key: str) -> None: + """ + Construct a Key object + + :param key: The key to use + :type key: str + """ self._key: str = key - self._cipher: Optional[XorCipher] = None + self._cipher: XorCipher | None = None @property def key(self) -> str: @@ -44,15 +49,7 @@ def encode(self) -> str: :rtype: str """ raw: str = urlencode({"": self._key})[1:] - encrypted: str = "" - - for c in raw: - if ord(c) < 16: - encrypted += "0" - - encrypted += hex(ord(c))[2:] - - return encrypted + return "".join(f"{ord(c):02x}" for c in raw) def __len__(self) -> int: """ @@ -75,12 +72,7 @@ def parse(input: str) -> Key: if len(input) % 2 != 0: raise ValueError("Invalid key. Length of key must be even") - key: list = [None for _ in range(len(input) // 2)] - - for i in range(0, len(input), 2): - key[i // 2] = chr(int(input[i : i + 2], 16)) - - return Key(unquote_plus("".join(key))) + return Key(unquote_plus("".join(chr(int(input[i : i + 2], 16)) for i in range(0, len(input), 2)))) @staticmethod def generate(size: int = 128) -> Key: diff --git a/dofutils/encoding/password_encoder.py b/dofutils/encoding/password_encoder.py index 4d4bb23..7c2c338 100644 --- a/dofutils/encoding/password_encoder.py +++ b/dofutils/encoding/password_encoder.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .base64 import Base64 @@ -46,14 +48,8 @@ def decode(self, encoded: str) -> str: r: int = Base64.ord(encoded[i + 1]) # remove key value - d -= k - r -= k - - # if values are negative due to modulo, reverse the modulo - while d < 0: - d += 64 - while r < 0: - r += 64 + d = (d - k) % 64 + r = (r - k) % 64 # retrieve the original value v: int = d * 16 + r diff --git a/dofutils/encoding/xor_cipher.py b/dofutils/encoding/xor_cipher.py index 37f3e92..d8a76ce 100644 --- a/dofutils/encoding/xor_cipher.py +++ b/dofutils/encoding/xor_cipher.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from urllib.parse import quote, unquote @@ -77,7 +79,7 @@ def _escape(value: str) -> str: for i in value: c: int = ord(i) - if c < 32 or c > 127 or i == "%" or i == "+": + if c < 32 or c > 127 or i in ("=", "+"): escaped += quote(i) else: escaped += i diff --git a/dofutils/maps/__init__.py b/dofutils/maps/__init__.py index c25b272..d9b0960 100644 --- a/dofutils/maps/__init__.py +++ b/dofutils/maps/__init__.py @@ -1,2 +1,19 @@ -from .battlefield_cell import BattleFieldCell -from .coordinate_cell import CoordinateCell +from .coordinate_cell import CoordinateCell as CoordinateCell +from .dofus_map import DofusMap as DofusMap +from .serializer import CellData as CellData +from .serializer import CellLayerData as CellLayerData +from .serializer import DefaultMapDataSerializer as DefaultMapDataSerializer +from .serializer import EncryptedMapDataSerializer as EncryptedMapDataSerializer +from .serializer import GroundCellData as GroundCellData +from .serializer import InteractiveObjectData as InteractiveObjectData + +__all__ = [ + "CellData", + "CellLayerData", + "CoordinateCell", + "DefaultMapDataSerializer", + "DofusMap", + "EncryptedMapDataSerializer", + "GroundCellData", + "InteractiveObjectData", +] diff --git a/dofutils/maps/abstract_map_cell.py b/dofutils/maps/abstract_map_cell.py deleted file mode 100644 index 6d184fd..0000000 --- a/dofutils/maps/abstract_map_cell.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from abc import ABC -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .abstract_map import AbstractMap - from .coordinate_cell import CoordinateCell - - -@dataclass(frozen=True) -class AbstractMapCell(ABC): - id: int - walkable: bool - map: AbstractMap - coordinate: CoordinateCell diff --git a/dofutils/maps/battlefield_cell.py b/dofutils/maps/battlefield_cell.py deleted file mode 100644 index 8a6890d..0000000 --- a/dofutils/maps/battlefield_cell.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass - -from .abstract_map_cell import AbstractMapCell - - -@dataclass(frozen=True) -class BattleFieldCell(AbstractMapCell): - sight_blocking: bool - """Check if the cell block line of sight""" diff --git a/dofutils/maps/constant/__init__.py b/dofutils/maps/constant/__init__.py index c311db6..6aee0c2 100644 --- a/dofutils/maps/constant/__init__.py +++ b/dofutils/maps/constant/__init__.py @@ -1,2 +1,2 @@ -from dofutils.maps.constant.cell_movement import CellMovement -from dofutils.maps.constant.direction import Direction +from dofutils.maps.constant.cell_movement import CellMovement as CellMovement +from dofutils.maps.constant.direction import Direction as Direction diff --git a/dofutils/maps/constant/cell_movement.py b/dofutils/maps/constant/cell_movement.py index d8cc191..824453b 100644 --- a/dofutils/maps/constant/cell_movement.py +++ b/dofutils/maps/constant/cell_movement.py @@ -30,11 +30,7 @@ def by_value(value: int) -> CellMovement: :return: The movement object :raise ValueError: When value is not in range [0-7] """ - if ( - not CellMovement.NOT_WALKABLE.value - <= value - <= CellMovement.MOST_WALKABLE.value - ): + if not CellMovement.NOT_WALKABLE.value <= value <= CellMovement.MOST_WALKABLE.value: raise ValueError(f"Incorrect parameter {value}, must be in range [0-7]") return CellMovement(value) diff --git a/dofutils/maps/constant/direction.py b/dofutils/maps/constant/direction.py index 8304ac9..de590ee 100644 --- a/dofutils/maps/constant/direction.py +++ b/dofutils/maps/constant/direction.py @@ -30,8 +30,8 @@ def to_char(self) -> str: """ return chr(self._ordinal() + ord("a")) - @staticmethod - def by_char(c: str) -> Direction: + @classmethod + def by_char(cls, c: str) -> Direction: """ Get the direction by its char value @@ -91,3 +91,12 @@ def restricted_directions() -> list: """ restricted: list = [d for d in Direction if d.restricted()] return restricted + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Direction): + return False + + return self._ordinal() == other._ordinal() + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) diff --git a/dofutils/maps/coordinate_cell.py b/dofutils/maps/coordinate_cell.py index 129ea92..6345ed3 100644 --- a/dofutils/maps/coordinate_cell.py +++ b/dofutils/maps/coordinate_cell.py @@ -2,26 +2,74 @@ from dataclasses import dataclass -from .abstract_map_cell import AbstractMapCell from .constant import Direction +from .dofus_map import DofusMap @dataclass(frozen=True) class CoordinateCell: - _cell: AbstractMapCell + id: int + map: DofusMap x: int y: int - def __init__(self, cell: AbstractMapCell): - object.__setattr__(self, "_cell", cell) + walkable: bool = True + sight_blocking: bool = False - width: int = cell.map.dimensions.width - line: int = cell.id // (width * 2 - 1) - column: int = cell.id - line * (width * 2 - 1) + def __init__( + self, + map: DofusMap, + id: int, + walkable: bool = True, + sight_blocking: bool = False, + ): + """ + Initialize a CoordinateCell instance. + + :param map: The map this cell belong to + :param id: The id of the cell in the map + :param walkable: Whether the cell is walkable, defaults to True + :param sight_blocking: Whether the cell blocks sight, defaults to False + :type map: AbstractMap + :type id: int + :type walkable: bool, optional defaults to True + :type sight_blocking: bool, optional defaults to False + """ + width: int = map.dimensions.width + line: int = id // (width * 2 - 1) + column: int = id - line * (width * 2 - 1) offset: int = column % width object.__setattr__(self, "y", line - offset) - object.__setattr__(self, "x", (cell.id - (width - 1) * self.y) // width) + object.__setattr__(self, "x", (id - (width - 1) * self.y) // width) + object.__setattr__(self, "id", id) + object.__setattr__(self, "walkable", walkable) + object.__setattr__(self, "sight_blocking", sight_blocking) + object.__setattr__(self, "map", map) + + def eq(self, target: CoordinateCell) -> bool: + """ + Check if the current coordinate cell is equal to the target + + :param target: The target coordinate cell + :type target: CoordinateCell + :return: True if the cells are equal, false otherwise + :rtype: bool + """ + return self == target + + def eq_coordinate(self, x: int, y: int) -> bool: + """ + Check if the current coordinate cell is equal to the given coordinate + + :param x: The x coordinate + :type x: int + :param y: The y coordinate + :type y: int + :return: True if the cells are equal, false otherwise + :rtype: bool + """ + return self.x == x and self.y == y def direction_to(self, target: CoordinateCell) -> Direction: """Compute the direction to the target cell @@ -32,7 +80,7 @@ def direction_to(self, target: CoordinateCell) -> Direction: :rtype: Direction """ if self.x == target.x: - if self.y > target.y: + if target.y > self.y: return Direction.SOUTH_WEST else: return Direction.NORTH_EAST diff --git a/dofutils/maps/abstract_map.py b/dofutils/maps/dofus_map.py similarity index 75% rename from dofutils/maps/abstract_map.py rename to dofutils/maps/dofus_map.py index a70377e..0353757 100644 --- a/dofutils/maps/abstract_map.py +++ b/dofutils/maps/dofus_map.py @@ -2,20 +2,23 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING from ..value import Dimension -from .abstract_map_cell import AbstractMapCell + +if TYPE_CHECKING: + from .coordinate_cell import CoordinateCell @dataclass(frozen=True) -class AbstractMap(ABC): +class DofusMap(ABC): """Base dofus map type""" size: int dimensions: Dimension @abstractmethod - def get_cell(self, id: int) -> AbstractMapCell: + def get_cell(self, id: int) -> CoordinateCell: """Return a cell by its id :param id: The cell Id diff --git a/dofutils/maps/path/__init__.py b/dofutils/maps/path/__init__.py new file mode 100644 index 0000000..60142d3 --- /dev/null +++ b/dofutils/maps/path/__init__.py @@ -0,0 +1,7 @@ +from .decoder import PathDecoder as PathDecoder +from .path import Path as Path +from .path_exception import PathException as PathException +from .path_step import PathStep as PathStep +from .pathfinder import Pathfinder as Pathfinder + +__all__ = ["Path", "PathDecoder", "PathException", "PathStep", "Pathfinder"] diff --git a/dofutils/maps/path/decoder.py b/dofutils/maps/path/decoder.py new file mode 100644 index 0000000..6043d85 --- /dev/null +++ b/dofutils/maps/path/decoder.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from dataclasses import dataclass +from itertools import groupby + +from dofutils.encoding import Base64 +from dofutils.maps import CoordinateCell, DofusMap +from dofutils.maps.constant import Direction + +from .path import Path +from .path_exception import PathException +from .path_step import PathStep + + +@dataclass(frozen=True) +class PathDecoder: + map: DofusMap + + def pathfinder(self): + from .pathfinder import Pathfinder + + return Pathfinder(self) + + def next_cell_by_direction(self, start: CoordinateCell, dir: Direction) -> CoordinateCell | None: + """ + Get the next cell on the map given a starting cell and a direction + + :param start: The starting cell + :type start: AbstractMapCell + :param dir: The direction + :type dir: Direction + :return: The next cell on the map, or None if the direction is invalid (out of map bounds) + :rtype: Optional[AbstractMapCell] + """ + next_id: int = start.id + dir.next_cell_increment(self.map.dimensions.width) + + if next_id < 0 or next_id >= self.map.size: + return None + + return self.map.get_cell(next_id) + + def decode(self, encoded: str, start: CoordinateCell | None = None) -> Path: + """ + Decode a path encoded string into a list of directions + + :param encoded: The encoded path string + :type encoded: str + :param start: The starting cell, if None the first direction is considered absolute, defaults to None + :type start: Optional[AbstractMapCell] + :return: The list of directions + :rtype: list[Direction] + """ + if len(encoded) % 3 != 0: + raise ValueError("Encoded path length must be a multiple of 3") + + directions: Path = Path(self) + + if start: + directions.steps.append(PathStep(start, Direction.EAST)) + + for i in range(0, len(encoded), 3): + c = encoded[i] + if c < "a" or c > "h": + raise ValueError(f"Invalid direction character: {c}") + + dire: Direction = Direction.by_char(c) + cell: int = ((Base64.ord(encoded[i + 1]) & 15) << 6) + Base64.ord(encoded[i + 2]) + + if cell >= self.map.size: + raise ValueError(f"Invalid cell id: {cell}") + + if directions.empty(): + directions.steps.append(PathStep(self.map.get_cell(cell), Direction.EAST)) + continue + + self._expand_rectilinear_move(directions, directions.target().cell, self.map.get_cell(cell), dire) + + return directions + + def encode(self, path: Path, include_start: bool = False) -> str: + """ + Encode a path into a string + + :param path: The path to encode + :type path: Path + :param include_start: Whether to include the starting cell in the encoded string, defaults to False + :type include_start: bool + :return: The encoded path string + :rtype: str + """ + encoded: list[str] = [] + + if include_start: + encoded.append(Direction.EAST.to_char()) + encoded.append(Base64.encode(path.first().cell.id, 2)) + + start: int = 1 if include_start else 0 + for direction, steps in groupby(path.steps[start:], lambda s: s.direction): + steps_l = list(steps) + + encoded.append(direction.to_char()) + encoded.append(Base64.encode(steps_l[-1].cell.id, 2)) + + return "".join(encoded) + + def _expand_rectilinear_move( + self, + path: Path, + start: CoordinateCell, + target: CoordinateCell, + direction: Direction, + ) -> None: + steps_limit: int = 2 * self.map.dimensions.width + 1 + + while start != target: + if (c := self.next_cell_by_direction(start, direction)) is None: + raise PathException(f"Invalid cell number, cannot move from {start} to {target}") + + start = c + path.steps.append(PathStep(start, direction)) + steps_limit -= 1 + if steps_limit < 0: + raise PathException(f"Invalid path, too many steps from {start} to {target}") diff --git a/dofutils/maps/path/path.py b/dofutils/maps/path/path.py new file mode 100644 index 0000000..b2569e6 --- /dev/null +++ b/dofutils/maps/path/path.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from itertools import takewhile +from typing import TYPE_CHECKING, overload + +from .path_step import PathStep + +if TYPE_CHECKING: + from .decoder import PathDecoder + + +@dataclass +class Path: + decoder: PathDecoder + steps: list[PathStep] = field(default_factory=list) + + def path_step(self, index: int) -> PathStep: + """ + Get a path step at a given index + + :param index: the index of the step to get + :raises IndexError: if the index is out of range + :return: the step at the given index + :rtype: PathStep + """ + if index < 0 or index >= len(self.steps): + raise IndexError("Path step index out of range") + + return self.steps[index] + + def first(self) -> PathStep: + """ + Get the first path step of the path + + :return: the first path step + :rtype: PathStep + """ + return self.path_step(0) + + def last(self) -> PathStep: + """ + Get the last path step of the path + + :return: the last path step + :rtype: PathStep + """ + return self.path_step(len(self.steps) - 1) + + def target(self) -> PathStep: + """ + Get the target path step of the path (the last step) + + :return: the target path step + :rtype: PathStep + """ + return self.last() + + def encode(self) -> str: + """ + Encode the path into a string + + :return: The encoded path as a string + :rtype: str + """ + return self.decoder.encode(self) + + def keep_while(self, predicate: Callable[[PathStep], bool]) -> Path: + """ + Keep the path steps while the predicate is true + + :param predicate: The predicate to apply to each path step + :type predicate: callable[[PathStep], bool] + :return: A new path with the kept steps + :rtype: Path + """ + return Path(self.decoder, list(takewhile(predicate, self.steps))) + + def empty(self) -> bool: + """ + Check if the path is empty + + :return: True if the path is empty, false otherwise + :rtype: bool + """ + return len(self.steps) == 0 + + def __len__(self) -> int: + """ + Return the number of steps in the path + + :return: The number of steps + :rtype: int + """ + return len(self.steps) + + def __iter__(self): + """ + Return an iterator over the path steps + + :return: An iterator over the path steps + :rtype: Iterator[PathStep] + """ + return iter(self.steps) + + @overload + def __getitem__(self, index: int) -> PathStep: ... + @overload + def __getitem__(self, index: slice) -> list[PathStep]: ... + + def __getitem__(self, index: int | slice) -> PathStep | list[PathStep]: + """ + Get a path step or a slice of path steps at a given index + + :param index: The index of the step or the slice to get + :type index: Union[int, slice] + :raises IndexError: if the index is out of range + :return: The step or the slice of steps at the given index + :rtype: Union[PathStep, List[PathStep]] + """ + if isinstance(index, int): + return self.path_step(index) + elif isinstance(index, slice): + return [self.path_step(i) for i in range(*index.indices(len(self)))] + else: + raise TypeError(f"Invalid argument type: {type(index)}") + + def __add__(self, other: Path | list[PathStep] | PathStep) -> Path: + """ + Concatenate two paths together + + :param other: The other path to concatenate + :type other: Union[Path, List[PathStep], PathStep] + :return: A new path with all the steps of both paths + :rtype: Path + """ + if isinstance(other, list): + return Path(self.decoder, self.steps + other) + + if isinstance(other, PathStep): + return Path(self.decoder, self.steps + [other]) + + return Path(self.decoder, self.steps + other.steps) diff --git a/dofutils/maps/path/path_exception.py b/dofutils/maps/path/path_exception.py new file mode 100644 index 0000000..88241b0 --- /dev/null +++ b/dofutils/maps/path/path_exception.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +class PathException(Exception): + """Base exception for path related errors""" diff --git a/dofutils/maps/path/path_step.py b/dofutils/maps/path/path_step.py new file mode 100644 index 0000000..4abe069 --- /dev/null +++ b/dofutils/maps/path/path_step.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from dofutils.maps.constant.direction import Direction +from dofutils.maps.coordinate_cell import CoordinateCell + + +@dataclass(frozen=True) +class PathStep: + cell: CoordinateCell + direction: Direction + + def __str__(self) -> str: + return f"{{{self.cell.id}, {self.direction.name}}}" diff --git a/dofutils/maps/path/pathfinder.py b/dofutils/maps/path/pathfinder.py new file mode 100644 index 0000000..c932b8b --- /dev/null +++ b/dofutils/maps/path/pathfinder.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import heapq +from collections.abc import Callable, Iterable +from itertools import count + +from dofutils.maps.constant import Direction +from dofutils.maps.coordinate_cell import CoordinateCell + +from .decoder import PathDecoder +from .path import Path +from .path_exception import PathException +from .path_step import PathStep + + +class Pathfinder: + def __init__(self, decoder: PathDecoder): + self.decoder = decoder + self._target_distance = 0 + self._walkable_predicate: Callable[[CoordinateCell], bool] = lambda cell: cell.walkable + self._cell_weight: Callable[[CoordinateCell], int] = lambda cell: 1 + self._directions: Iterable[Direction] = Direction.restricted_directions() + self._explored_cell_limit = float("inf") + self._add_first_cell = True + + def target_distance(self, distance: int) -> Pathfinder: + self._target_distance = distance + return self + + def walkable_predicate(self, predicate: Callable[[CoordinateCell], bool]) -> Pathfinder: + self._walkable_predicate = predicate + return self + + def cell_weight_function(self, function: Callable[[CoordinateCell], int]) -> Pathfinder: + self._cell_weight = function + return self + + def with_directions(self, directions: Iterable[Direction]) -> Pathfinder: + self._directions = directions + return self + + def explored_cell_limit(self, limit: int) -> Pathfinder: + self._explored_cell_limit = limit + return self + + def include_first_cell(self, include: bool) -> Pathfinder: + self._add_first_cell = include + return self + + def find_path(self, source: CoordinateCell, target: CoordinateCell) -> Path: + queue: list = [] + order = count() + heapq.heappush(queue, (source.distance(target), 0, next(order), source, Direction.EAST, None)) + best, explored = {source.id: 0}, set() + + while queue: + _, cost, _, cell, direction, previous = heapq.heappop(queue) + if cell.id in explored: + continue + explored.add(cell.id) + if len(explored) > self._explored_cell_limit: + raise PathException("Limit exceeded for finding path") + if cell.distance(target) <= self._target_distance: + steps = [] + while previous is not None: + steps.append(PathStep(cell, direction)) + cell, direction, previous = previous + steps.reverse() + if self._add_first_cell: + steps.insert(0, PathStep(source, Direction.EAST)) + return Path(self.decoder, steps) + for new_direction in self._directions: + adjacent = self.decoder.next_cell_by_direction(cell, new_direction) + if adjacent is None or adjacent.id in explored or not self._walkable_predicate(adjacent): + continue + new_cost = cost + self._cell_weight(adjacent) + if new_cost < best.get(adjacent.id, float("inf")): + best[adjacent.id] = new_cost + state = (cell, direction, previous) + heapq.heappush( + queue, + (new_cost + adjacent.distance(target), new_cost, next(order), adjacent, new_direction, state), + ) + raise PathException(f"Cannot find any valid path between {source.id} and {target.id}") diff --git a/dofutils/maps/serializer.py b/dofutils/maps/serializer.py new file mode 100644 index 0000000..e8c4e15 --- /dev/null +++ b/dofutils/maps/serializer.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from dofutils.encoding import Base64, CheckSum, Key +from dofutils.maps.constant import CellMovement + + +@dataclass(frozen=True) +class CellLayerData: + number: int = 0 + rotation: int = 0 + flip: bool = False + + @property + def active(self) -> bool: + return self.number != 0 + + +@dataclass(frozen=True) +class GroundCellData(CellLayerData): + level: int = 0 + slope: int = 0 + + +@dataclass(frozen=True) +class InteractiveObjectData(CellLayerData): + interactive: bool = False + rotation: int = 0 + + +@dataclass(frozen=True) +class CellData: + line_of_sight: bool + movement: CellMovement + active: bool + ground: GroundCellData + layer1: CellLayerData + layer2: InteractiveObjectData + + +class DefaultMapDataSerializer: + cell_data_length = 10 + + def __init__(self): + self._cache: dict[str, CellData] | None = None + + def enable_cache(self) -> None: + self._cache = {} + + def disable_cache(self) -> None: + self._cache = None + + def deserialize(self, map_data: str) -> list[CellData]: + if len(map_data) % self.cell_data_length: + raise ValueError("Invalid map data") + return [self._deserialize_cell(map_data[i : i + 10]) for i in range(0, len(map_data), 10)] + + def serialize(self, cells: list[CellData]) -> str: + return "".join(self._serialize_cell(cell) for cell in cells) + + def with_key(self, key: Key | str) -> EncryptedMapDataSerializer: + return EncryptedMapDataSerializer(Key.parse(key) if isinstance(key, str) else key, self) + + def _deserialize_cell(self, value: str) -> CellData: + if self._cache is not None and value in self._cache: + return self._cache[value] + d = Base64.to_bytes(value) + cell = CellData( + bool(d[0] & 1), + CellMovement.by_value((d[2] & 56) >> 3), + bool(d[0] & 32), + GroundCellData( + ((d[0] & 24) << 6) + ((d[2] & 7) << 6) + d[3], + (d[1] & 48) >> 4, + bool(d[4] & 2), + d[1] & 15, + (d[4] & 60) >> 2, + ), + CellLayerData( + ((d[0] & 4) << 11) + ((d[4] & 1) << 12) + (d[5] << 6) + d[6], (d[7] & 48) >> 4, bool(d[7] & 8) + ), + InteractiveObjectData( + ((d[0] & 2) << 12) + ((d[7] & 1) << 12) + (d[8] << 6) + d[9], 0, bool(d[7] & 4), bool(d[7] & 2) + ), + ) + if self._cache is not None: + self._cache[value] = cell + return cell + + @staticmethod + def _serialize_cell(cell: CellData) -> str: + d = [0] * 10 + d[0] = ( + (int(cell.active) << 5) + | int(cell.line_of_sight) + | ((cell.ground.number & 1536) >> 6) + | ((cell.layer1.number & 8192) >> 11) + | ((cell.layer2.number & 8192) >> 12) + ) + d[1] = ((cell.ground.rotation & 3) << 4) | (cell.ground.level & 15) + d[2] = ((cell.movement.value & 7) << 3) | ((cell.ground.number >> 6) & 7) + d[3] = cell.ground.number & 63 + d[4] = ((cell.ground.slope & 15) << 2) | (int(cell.ground.flip) << 1) | ((cell.layer1.number >> 12) & 1) + d[5], d[6] = (cell.layer1.number >> 6) & 63, cell.layer1.number & 63 + d[7] = ( + ((cell.layer1.rotation & 3) << 4) + | (int(cell.layer1.flip) << 3) + | (int(cell.layer2.flip) << 2) + | (int(cell.layer2.interactive) << 1) + | ((cell.layer2.number >> 12) & 1) + ) + d[8], d[9] = (cell.layer2.number >> 6) & 63, cell.layer2.number & 63 + return "".join(Base64.chr(value) for value in d) + + +class EncryptedMapDataSerializer: + def __init__(self, key: Key, serializer: DefaultMapDataSerializer | None = None): + self.key, self.serializer = key, serializer or DefaultMapDataSerializer() + + def deserialize(self, map_data: str) -> list[CellData]: + return self.serializer.deserialize(self.key.cipher.decrypt(map_data, CheckSum.integer(self.key.key) * 2)) + + def serialize(self, cells: list[CellData]) -> str: + return self.key.cipher.encrypt(self.serializer.serialize(cells), CheckSum.integer(self.key.key) * 2) diff --git a/dofutils/maps/sight/__init__.py b/dofutils/maps/sight/__init__.py new file mode 100644 index 0000000..b934b53 --- /dev/null +++ b/dofutils/maps/sight/__init__.py @@ -0,0 +1,4 @@ +from .battlefield_sight import BattlefieldSight as BattlefieldSight +from .battlefield_sight import CellSight as CellSight + +__all__ = ["BattlefieldSight", "CellSight"] diff --git a/dofutils/maps/sight/battlefield_sight.py b/dofutils/maps/sight/battlefield_sight.py new file mode 100644 index 0000000..bbabac4 --- /dev/null +++ b/dofutils/maps/sight/battlefield_sight.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from math import ceil, floor + +from dofutils.maps.coordinate_cell import CoordinateCell +from dofutils.maps.dofus_map import DofusMap + + +class BattlefieldSight: + def __init__(self, battlefield: DofusMap): + self.battlefield = battlefield + + def between(self, source: CoordinateCell, target: CoordinateCell) -> bool: + return self.from_cell(source).is_free(target) + + def from_cell(self, source: CoordinateCell) -> CellSight: + return CellSight(self, source) + + def get_cell_by_coordinates(self, x: int, y: int) -> CoordinateCell: + return self.battlefield.get_cell( + x * self.battlefield.dimensions.width + y * (self.battlefield.dimensions.width - 1) + ) + + +class CellSight: + def __init__(self, battlefield: BattlefieldSight, source: CoordinateCell): + self.battlefield, self.source = battlefield, source + + def to(self, target: CoordinateCell) -> Iterator[CoordinateCell]: + if self.source == target: + return iter(()) + return self._same_x(target) if self.source.x == target.x else self._line(target) + + def is_free(self, target: CoordinateCell) -> bool: + return all(not cell.sight_blocking or cell == target for cell in self.to(target)) + + def accessible(self) -> list[CoordinateCell]: + return [ + self.battlefield.battlefield.get_cell(i) + for i in range(self.battlefield.battlefield.size) + if self.is_free(self.battlefield.battlefield.get_cell(i)) + ] + + def blocked(self) -> list[CoordinateCell]: + return [ + self.battlefield.battlefield.get_cell(i) + for i in range(self.battlefield.battlefield.size) + if not self.is_free(self.battlefield.battlefield.get_cell(i)) + ] + + def for_each(self, consumer: Callable[[CoordinateCell, bool], None]) -> None: + for cell in (self.battlefield.battlefield.get_cell(i) for i in range(self.battlefield.battlefield.size)): + consumer(cell, self.is_free(cell)) + + def _same_x(self, target: CoordinateCell) -> Iterator[CoordinateCell]: + direction = -1 if self.source.y > target.y else 1 + for y in range(self.source.y + direction, target.y + direction, direction): + yield self.battlefield.get_cell_by_coordinates(self.source.x, y) + + def _line(self, target: CoordinateCell) -> Iterator[CoordinateCell]: + x_direction = -1 if self.source.x > target.x else 1 + y_direction = -1 if self.source.y > target.y else 1 + slope = (target.y - self.source.y) / (target.x - self.source.x) + intercept = self.source.y - slope * self.source.x + x, y = self.source.x, self.source.y + y_at_x = (x + x_direction * 0.5) * slope + intercept + rounded = floor(y_at_x + 0.5) + next_y, last_y = (rounded, ceil(y_at_x - 0.5)) if y_direction > 0 else (ceil(y_at_x - 0.5), rounded) + while (x, y) != (target.x, target.y): + y += y_direction + if y * y_direction > last_y * y_direction: + x, y = x + x_direction, next_y + y_at_x = (x + x_direction * 0.5) * slope + intercept + rounded = floor(y_at_x + 0.5) + next_y, last_y = (rounded, ceil(y_at_x - 0.5)) if y_direction > 0 else (ceil(y_at_x - 0.5), rounded) + yield self.battlefield.get_cell_by_coordinates(x, y) diff --git a/dofutils/value/__init__.py b/dofutils/value/__init__.py index 254ded6..5d36975 100644 --- a/dofutils/value/__init__.py +++ b/dofutils/value/__init__.py @@ -1,3 +1,3 @@ -from dofutils.value.color import Color -from dofutils.value.dimension import Dimension -from dofutils.value.interval import Interval +from dofutils.value.color import Color as Color +from dofutils.value.dimension import Dimension as Dimension +from dofutils.value.interval import Interval as Interval diff --git a/dofutils/value/color.py b/dofutils/value/color.py index a439f47..b05cb41 100644 --- a/dofutils/value/color.py +++ b/dofutils/value/color.py @@ -41,25 +41,25 @@ def color3(self) -> int: """ return self._color3 - def colors(self) -> list: + def colors(self) -> tuple[int, int, int]: """ - Return a list containing the colors + Return a tuple containing the colors - :return: a list containing the color - :rtype: list + :return: a tuple containing the color + :rtype: tuple(int, int, int) """ - return [self.color1, self.color2, self.color3] + return (self.color1, self.color2, self.color3) - def hex_colors(self) -> list: + def hex_colors(self) -> tuple[str, str, str]: """ - Return a list containing the colors in a hexadecimal format + Return a tuple containing the colors in a hexadecimal format - :return: a list containing the color - :rtype: list + :return: a tuple containing the color + :rtype: tuple(str, str, str) """ - return [hex(self.color1)[2:], hex(self.color2)[2:], hex(self.color3)[2:]] + return (hex(self.color1)[2:], hex(self.color2)[2:], hex(self.color3)[2:]) - def hex_color_str(self, separator: str) -> str: + def hex_color_str(self, separator: str = ";") -> str: """ Return a str with the color joined by the separator @@ -73,14 +73,10 @@ def __eq__(self, other) -> bool: if self.__class__ != other.__class__: return False - return ( - self.color1 == other.color1 - and self.color2 == other.color2 - and self.color3 == other.color3 - ) + return self.color1 == other.color1 and self.color2 == other.color2 and self.color3 == other.color3 - @staticmethod - def default() -> Color: + @classmethod + def default(cls) -> Color: """ Return a object color with the default color -1 -1 -1 @@ -88,17 +84,17 @@ def default() -> Color: :return: Return a default color :rtype: Color """ - return Color(-1, -1, -1) + return cls(-1, -1, -1) - @staticmethod - def random() -> Color: + @classmethod + def random(cls) -> Color: """ Return a object color with random color :return: Return a random color :rtype: Color """ - return Color( + return cls( randint(0, Color._MAX_COLOR), randint(0, Color._MAX_COLOR), randint(0, Color._MAX_COLOR), diff --git a/dofutils/value/constant/__init__.py b/dofutils/value/constant/__init__.py index 00ac48b..00b03e6 100644 --- a/dofutils/value/constant/__init__.py +++ b/dofutils/value/constant/__init__.py @@ -1,2 +1,2 @@ -from dofutils.value.constant.race import Race -from dofutils.value.constant.gender import Gender +from dofutils.value.constant.gender import Gender as Gender +from dofutils.value.constant.race import Race as Race diff --git a/dofutils/value/constant/gender.py b/dofutils/value/constant/gender.py index 6940f73..9d53630 100644 --- a/dofutils/value/constant/gender.py +++ b/dofutils/value/constant/gender.py @@ -18,7 +18,26 @@ def parse(value: Literal["0", "1"]) -> Gender: :rtype: Gender """ val: int = int(value) - if val not in list(map(int, Gender)): + try: + return Gender(val) + except ValueError: raise ValueError(f"Incorrect parameter {value}, must be 0 or 1") - return Gender(val) + def __eq__(self, value: object) -> bool: + """ + Check if the given value is equal to this gender + + :param value: The value to check + :return: True if the value is equal, false otherwise + :rtype: bool + """ + return super().__eq__(value) + + def __str__(self) -> str: + """ + Return the name of the gender as a string. + + :return: The name of the gender + :rtype: str + """ + return self.name diff --git a/dofutils/value/constant/race.py b/dofutils/value/constant/race.py index 6959435..9145cec 100644 --- a/dofutils/value/constant/race.py +++ b/dofutils/value/constant/race.py @@ -27,7 +27,25 @@ def by_id(race_id: int) -> Race: :return: The race object :rtype: Race """ - if not race_id in list(map(int, Race)): + try: + return Race(race_id) + except ValueError: raise ValueError(f"Incorrect parameter {race_id}, must be between 1 and 12") - return Race(race_id) + def __eq__(self, value: object) -> bool: + """ + Check if the given object is equal to this race. + + :param value: The value to compare + :return: True if the value is equal, False otherwise + """ + return super().__eq__(value) + + def __str__(self) -> str: + """ + Return the name of the race as a string. + + :return: The name of the race + :rtype: str + """ + return self.name diff --git a/dofutils/value/dimension.py b/dofutils/value/dimension.py index 7ba3a55..63bee17 100644 --- a/dofutils/value/dimension.py +++ b/dofutils/value/dimension.py @@ -24,7 +24,7 @@ def height(self) -> int: return self._height def __eq__(self, other) -> bool: - if other.__class__ != self.__class__: + if not isinstance(other, Dimension): return False return self.width == other.width and self.height == other.height diff --git a/dofutils/value/interval.py b/dofutils/value/interval.py index 0a3533a..5d2f773 100644 --- a/dofutils/value/interval.py +++ b/dofutils/value/interval.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + class Interval: def __init__(self, min: int, max: int) -> None: if max < min: @@ -40,7 +43,7 @@ def __contains__(self, value: int) -> bool: return self._min <= value <= self._max - def modify(self, modifier: int) -> "Interval": + def modify(self, modifier: int) -> Interval: """ Modify the end of the interval The returned interval will be [min, max + modifier] @@ -61,8 +64,8 @@ def __eq__(self, other) -> bool: return self.min == other.min and self.max == other.max - @staticmethod - def of(a: int, b: int) -> "Interval": + @classmethod + def of(cls, a: int, b: int) -> Interval: """ Create a interval with unordered boundary The two boundary will be ordered to create a valid interval @@ -73,8 +76,8 @@ def of(a: int, b: int) -> "Interval": :rtype: Interval """ if a > b: - return Interval(b, a) - return Interval(a, b) + return cls(b, a) + return cls(a, b) def is_singleton(self) -> bool: """ @@ -85,6 +88,7 @@ def is_singleton(self) -> bool: """ return self._min == self._max + @property def average(self) -> float: """ Return the average value of the interval (i.e. min + max / 2) @@ -94,6 +98,7 @@ def average(self) -> float: """ return (self._min + self._max) / 2 + @property def amplitude(self) -> int: """ Return the amplitude of the interval (i.e. max - min) diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..1caf9a4 --- /dev/null +++ b/mise.toml @@ -0,0 +1,12 @@ +[tools] +uv = "latest" + +[env] +PYTHON_VERSION = "3.12" + +[tasks.install] +description = "Install python and the dependencies for the project" +run = ''' +uv python install ${PYTHON_VERSION} +uv sync +''' diff --git a/pyproject.toml b/pyproject.toml index 15e29ab..7fa7ea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,42 +1,65 @@ -[tool.poetry] +[project] name = "Dofutils" version = "0.0.4" description = "Collection of useful things to build Dofus Retro bot or emulator" -keywords = ["dofus retro", "dofus", "retro", "bot", "emulateur"] -authors = ["Dysta"] readme = "README.md" -repository = "https://github.com/Dysta/Dofutils" -exclude = ["tests", "tests.*"] +requires-python = ">=3.11, <4.0" +authors = [{ name = "Dysta" }] +keywords = [ + "bot", + "dofus", + "dofus retro", + "emulateur", + "retro", +] classifiers = [ - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - 'Development Status :: 3 - Alpha' - ] - -[tool.poetry.dependencies] -python = ">=3.8, <4.0" - -[tool.poetry.scripts] -tests = "scripts:tests" -fmt = "scripts:fmt" -tcheck = "scripts:type_check" - -[tool.poetry.group.dev.dependencies] -taskipy = "^1.11.0" -black = "^23.3.0" -mypy = "^1.3.0" -autoflake = "^2.1.1" -isort = "^5.12.0" -Sphinx = "^7.0.1" -sphinx-material = "^0.0.35" + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +[project.urls] +Repository = "https://github.com/Dysta/Dofutils" + +[dependency-groups] +dev = [ + "Sphinx>=7.0.1,<8", + "mypy>=1.3.0,<2", + "ruff>=0.16.1", + "sphinx-material>=0.0.35,<0.0.36", + "taskipy>=1.11.0,<2", +] + +[build-system] +requires = ["uv_build>=0.12.1,<0.13.0"] +build-backend = "uv_build" + +[tool.ruff] +extend-exclude = ["tests", ".tools"] +line-length = 120 + +[tool.ruff.lint] +extend-select = ["I"] [tool.taskipy.tasks] +check = { cmd = "python -m mypy . --exclude '^tests/'", help = "type check the lib with mypy" } +clean = { cmd = "ruff check . --fix --unsafe-fixes", help = "remove unused code/imports and sort imports" } +format = { cmd = "ruff format .", help = "format code" } test = { cmd = "python -m unittest discover -v", help = "run all the tests" } -lint = { cmd = "python -m black .", help = "formate the code using black" } -clean = { cmd = "python -m autoflake . -r -i -v --ignore-init-module-imports --remove-all-unused-imports --remove-unused-variables", help = "remove unused code/import/variable" } -check = { cmd = "python -m mypy .", help = "type check the lib with mypy" } -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +[tool.uv] +default-groups = "all" + +[tool.uv.build-backend] +module-root = "" +source-exclude = [ + "tests", + "tests.*", +] +wheel-exclude = [ + "tests", + "tests.*", +] diff --git a/tests/maps/_map.py b/tests/maps/_map.py new file mode 100644 index 0000000..c32be50 --- /dev/null +++ b/tests/maps/_map.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from dofutils.maps import CoordinateCell, DefaultMapDataSerializer +from dofutils.value import Dimension + +MAP_DATA = "HhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaa6GHhaaeaaaaaHhaaeaaaaaHhaae6HaaaHhaae60aaaHhaaeaaaaaHhaae6HaaaHhaaeaaaaaGhaaeaaa7oHhaae6HiaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaa6SHhgSe6HaaaHhaaeaaa6IHhGaeaaaaaHhGaeaaaaaHhqaeaaaqgHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaGhaaeaaa7iHhGaeaaaaaHhGaeaaa6IHhMSeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhWaeaaaaaHhGaeJgaaaHhGaeaaaaaGhaaeaaa7hHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaa6THhGaeaaaaaHhGaeaaaaaHhMSe62aaaHhGaeaaaaaHhGaeaaaaaHhGaeaaa6IHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhqaeaaaqgGhaaeaaa7AHhGaeaaaaaHhGaeaaaaaHhaae6Ha7eHhGaeaaaaaHhGaeaaaaaHhGaeaaa6IHhWaeaaaaaHhGaeaaaaaGhaaeaaa7gHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeJgaaaHhGaeaaaaaGhaaeaaa7jHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhWaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGae8uaaaGhaaeaaa7jHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGae8uaaaHhWae60aaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhqaeaaaqgHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeJgaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaGhaaeaaa7iHhGaeJgaaaHhaaeaaaaaHhaaeJgaaaHhGae6HaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhWaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaa6IHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaa6IGhaaeaaa7hGhaaeaaa7iHhGaeaaaaaHhGaeaaaaaHhGaeJgaaaHhGaeaaaaaHhGaeaaaaaGhaaeaaa7lGhaae8sa7gHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaGhaaeaaa7gGhaaeaaa7kHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhWae62aaaGhaaeaaa7kGhaaeaaa7hHhGaeaaaaaHhGaeaaaaaGhaaeaaa7lHhaaeaaaaaGhaaeaaa7nHhGaeaaaaaGhaaeaaa7lGhaaeaaa7jHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaGhaaeaaa7hHhGaeaaaaaGhaaeaaa7mHhGaeaaaaaGhaaeJga7hHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhMTgJgaaaHhGaeaaaaaHhGaeaaa6IHhGaeaaaaaHhGaeaaaaaHhGae8saaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhMSeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaae6HaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaGhaaeaaa7jHhGaeaaa6IHhGaeaaaaaHhaaeaaaaaHhaaeaaa6IHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaGhaaeaaa7gHhGaeaaaaaHhGaeaaaaaHhaaeaaa6GHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaGhaaeaaa7kHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaa6GHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhgTeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaa7dHhaaeaaaaaHhaaeaaa6WHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaGhaaeaaa7yHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaa6XHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhMVgaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhGaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaaHhaaeaaaaa" + + +@dataclass(frozen=True) +class TestMap: + data: str = MAP_DATA + dimensions: Dimension = field(default_factory=lambda: Dimension(15, 17)) + + def __post_init__(self): + object.__setattr__(self, "cells", DefaultMapDataSerializer().deserialize(self.data)) + object.__setattr__(self, "size", len(self.cells)) + + def get_cell(self, id: int) -> CoordinateCell: + data = self.cells[id] + return CoordinateCell(self, id, data.active and data.movement.walkable(), not data.line_of_sight) diff --git a/tests/maps/path/__init__.py b/tests/maps/path/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/maps/path/test_decoder.py b/tests/maps/path/test_decoder.py new file mode 100644 index 0000000..dcf1850 --- /dev/null +++ b/tests/maps/path/test_decoder.py @@ -0,0 +1,26 @@ +from unittest import TestCase + +from dofutils.maps.constant import Direction +from dofutils.maps.path import PathDecoder, PathException + +from .._map import TestMap + + +class TestDecoder(TestCase): + def setUp(self): + self.map = TestMap() + self.decoder = PathDecoder(self.map) + + def test_next_cell_by_direction(self): + self.assertEqual(101, self.decoder.next_cell_by_direction(self.map.get_cell(100), Direction.EAST).id) + self.assertIsNone(self.decoder.next_cell_by_direction(self.map.get_cell(470), Direction.SOUTH)) + + def test_decode_and_encode(self): + path = self.decoder.decode("ebIgbf", self.map.get_cell(100)) + self.assertEqual([100, 99, 98, 69], [step.cell.id for step in path]) + self.assertEqual("abKebIgbf", self.decoder.encode(path)) + self.assertEqual([100, 99, 98, 69], [step.cell.id for step in self.decoder.decode("abKebIgbf")]) + + def test_invalid_path(self): + with self.assertRaises((PathException, ValueError)): + self.decoder.decode("abcd", self.map.get_cell(123)) diff --git a/tests/maps/path/test_pathfinder.py b/tests/maps/path/test_pathfinder.py new file mode 100644 index 0000000..137ab81 --- /dev/null +++ b/tests/maps/path/test_pathfinder.py @@ -0,0 +1,19 @@ +from unittest import TestCase + +from dofutils.maps.constant import Direction +from dofutils.maps.path import PathDecoder + +from .._map import TestMap + + +class TestPathfinder(TestCase): + def setUp(self): + self.map = TestMap() + self.pathfinder = PathDecoder(self.map).pathfinder() + + def test_paths(self): + self.assertEqual([123], [step.cell.id for step in self.pathfinder.find_path(self.map.get_cell(123), self.map.get_cell(123))]) + self.assertEqual([336, 322], [step.cell.id for step in self.pathfinder.find_path(self.map.get_cell(336), self.map.get_cell(322))]) + self.assertEqual([305, 291, 277, 263, 249, 235, 221], [step.cell.id for step in self.pathfinder.find_path(self.map.get_cell(305), self.map.get_cell(221))]) + self.assertEqual([169, 183, 168, 153, 139], [step.cell.id for step in self.pathfinder.find_path(self.map.get_cell(169), self.map.get_cell(139))]) + self.assertEqual([169, 168, 139], [step.cell.id for step in self.pathfinder.with_directions(Direction).find_path(self.map.get_cell(169), self.map.get_cell(139))]) diff --git a/tests/maps/sight/__init__.py b/tests/maps/sight/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/maps/sight/test_battlefield_sight.py b/tests/maps/sight/test_battlefield_sight.py new file mode 100644 index 0000000..1c7b9e8 --- /dev/null +++ b/tests/maps/sight/test_battlefield_sight.py @@ -0,0 +1,21 @@ +from unittest import TestCase + +from dofutils.maps.sight import BattlefieldSight + +from .._map import TestMap + + +class TestBattlefieldSight(TestCase): + def setUp(self): + self.map = TestMap() + self.sight = BattlefieldSight(self.map) + + def test_between(self): + for source, target in ((177, 210), (210, 177), (241, 242), (137, 112), (126, 123), (123, 126), (156, 226)): + self.assertTrue(self.sight.between(self.map.get_cell(source), self.map.get_cell(target))) + for source, target in ((177, 146), (146, 177), (241, 243), (127, 169)): + self.assertFalse(self.sight.between(self.map.get_cell(source), self.map.get_cell(target))) + + def test_line_cells(self): + self.assertEqual([178, 163, 177, 162, 147], [cell.id for cell in self.sight.from_cell(self.map.get_cell(193)).to(self.map.get_cell(147))]) + self.assertEqual([181, 182, 183, 184, 185], [cell.id for cell in self.sight.from_cell(self.map.get_cell(180)).to(self.map.get_cell(185))]) diff --git a/tests/maps/test_coordinate_cell.py b/tests/maps/test_coordinate_cell.py index 4c824d4..ddc4aea 100644 --- a/tests/maps/test_coordinate_cell.py +++ b/tests/maps/test_coordinate_cell.py @@ -1,7 +1,19 @@ from unittest import TestCase -from dofutils.maps import CoordinateCell +from dofutils.maps.constant import Direction + +from ._map import TestMap class TestCoordinateCell(TestCase): - ... + def setUp(self): + self.map = TestMap() + + def test_coordinates_distance_and_direction(self): + cell = self.map.get_cell(157) + self.assertEqual((17, -7), (cell.x, cell.y)) + self.assertEqual(5, cell.distance(self.map.get_cell(227))) + self.assertEqual(Direction.SOUTH_WEST, cell.direction_to(self.map.get_cell(227))) + self.assertEqual(Direction.NORTH_EAST, cell.direction_to(self.map.get_cell(129))) + self.assertEqual(Direction.SOUTH_EAST, cell.direction_to(self.map.get_cell(217))) + self.assertEqual(Direction.NORTH_WEST, cell.direction_to(self.map.get_cell(67))) diff --git a/tests/maps/test_serializer.py b/tests/maps/test_serializer.py new file mode 100644 index 0000000..5efae7d --- /dev/null +++ b/tests/maps/test_serializer.py @@ -0,0 +1,11 @@ +from unittest import TestCase + +from dofutils.maps import CellData, CellLayerData, DefaultMapDataSerializer, GroundCellData, InteractiveObjectData +from dofutils.maps.constant import CellMovement + + +class TestSerializer(TestCase): + def test_round_trip(self): + cell = CellData(True, CellMovement.DEFAULT, True, GroundCellData(123, 2, True, 4, 5), CellLayerData(456, 3, True), InteractiveObjectData(789, 0, False, True)) + serializer = DefaultMapDataSerializer() + self.assertEqual([cell], serializer.deserialize(serializer.serialize([cell]))) diff --git a/tests/value/constant/test_gender.py b/tests/value/constant/test_gender.py index 7cc5f65..6d7c17a 100644 --- a/tests/value/constant/test_gender.py +++ b/tests/value/constant/test_gender.py @@ -1,4 +1,5 @@ from unittest import TestCase + from dofutils.value.constant import Gender @@ -11,3 +12,11 @@ def test_parse_wrong_param(self): self.assertRaises(ValueError, Gender.parse, value=5) self.assertRaises(ValueError, Gender.parse, value="5") self.assertRaises(ValueError, Gender.parse, value="95") + + def test_eq_operator(self): + assert Gender.MALE == Gender.parse("0") + assert Gender.FEMALE == Gender.parse("1") + + def test_str_operator(self): + assert str(Gender.MALE) == "MALE" + assert str(Gender.FEMALE) == "FEMALE" diff --git a/tests/value/constant/test_race.py b/tests/value/constant/test_race.py index 6ca169d..b21caec 100644 --- a/tests/value/constant/test_race.py +++ b/tests/value/constant/test_race.py @@ -13,3 +13,31 @@ def test_by_id_wrong_param(self): self.assertRaises(ValueError, Race.by_id, race_id=13) self.assertRaises(ValueError, Race.by_id, race_id="5") self.assertRaises(ValueError, Race.by_id, race_id="95") + + def test_eq_operator(self): + assert Race.FECA == Race.by_id(1) + assert Race.OSAMODAS == Race.by_id(2) + assert Race.ENUTROF == Race.by_id(3) + assert Race.SRAM == Race.by_id(4) + assert Race.XELOR == Race.by_id(5) + assert Race.ECAFLIP == Race.by_id(6) + assert Race.ENIRIPSA == Race.by_id(7) + assert Race.IOP == Race.by_id(8) + assert Race.CRA == Race.by_id(9) + assert Race.SADIDA == Race.by_id(10) + assert Race.SACRIEUR == Race.by_id(11) + assert Race.PANDAWA == Race.by_id(12) + + def test_str_operator(self): + assert str(Race.FECA) == "FECA" + assert str(Race.OSAMODAS) == "OSAMODAS" + assert str(Race.ENUTROF) == "ENUTROF" + assert str(Race.SRAM) == "SRAM" + assert str(Race.XELOR) == "XELOR" + assert str(Race.ECAFLIP) == "ECAFLIP" + assert str(Race.ENIRIPSA) == "ENIRIPSA" + assert str(Race.IOP) == "IOP" + assert str(Race.CRA) == "CRA" + assert str(Race.SADIDA) == "SADIDA" + assert str(Race.SACRIEUR) == "SACRIEUR" + assert str(Race.PANDAWA) == "PANDAWA" diff --git a/tests/value/test_color.py b/tests/value/test_color.py index 6ee4497..6212c7a 100644 --- a/tests/value/test_color.py +++ b/tests/value/test_color.py @@ -14,12 +14,12 @@ def test_getters(self): def test_colors(self): c: Color = Color(123, 456, 789) - self.assertListEqual([123, 456, 789], c.colors()) + self.assertTupleEqual((123, 456, 789), c.colors()) def test_hex_colors(self): c: Color = Color(123, 456, 789) - self.assertListEqual(["7b", "1c8", "315"], c.hex_colors()) + self.assertTupleEqual(("7b", "1c8", "315"), c.hex_colors()) def test_hex_color_str(self): c: Color = Color(123, 456, 789) diff --git a/tests/value/test_interval.py b/tests/value/test_interval.py index 28f1c58..831b0b4 100644 --- a/tests/value/test_interval.py +++ b/tests/value/test_interval.py @@ -52,16 +52,16 @@ def test_equals(self): self.assertNotEqual(Interval(5, 7), Interval(5, 9)) def test_average(self): - self.assertEqual(12.5, Interval(10, 15).average()) + self.assertEqual(12.5, Interval(10, 15).average) def test_amplitude(self): - self.assertEqual(5, Interval(10, 15).amplitude()) + self.assertEqual(5, Interval(10, 15).amplitude) - self.assertEqual(0, Interval(10, 10).amplitude()) + self.assertEqual(0, Interval(10, 10).amplitude) - self.assertEqual(20, Interval(0, 20).amplitude()) + self.assertEqual(20, Interval(0, 20).amplitude) - self.assertEqual(15, Interval(-20, -5).amplitude()) + self.assertEqual(15, Interval(-20, -5).amplitude) def test_is_singleton(self): self.assertTrue(Interval(5, 5).is_singleton())