From c054dc122a865c9c58a1170b3fd64798306f6844 Mon Sep 17 00:00:00 2001 From: Dysta Date: Wed, 28 Jan 2026 08:08:32 +0100 Subject: [PATCH 1/9] wip --- .devcontainer/devcontainer.json | 38 +++++ .github/dependabot.yml | 12 ++ .vscode/launch.json | 2 +- dofutils/encoding/base64.py | 3 + dofutils/encoding/checksum.py | 3 + dofutils/encoding/key.py | 6 + dofutils/encoding/password_encoder.py | 2 + dofutils/encoding/xor_cipher.py | 4 +- dofutils/maps/__init__.py | 4 +- dofutils/maps/abstract_map_cell.py | 17 -- dofutils/maps/battlefield_cell.py | 9 -- dofutils/maps/constant/direction.py | 15 +- dofutils/maps/coordinate_cell.py | 58 ++++++- .../maps/{abstract_map.py => dofus_map.py} | 6 +- dofutils/maps/path/__init__.py | 6 + dofutils/maps/path/decoder.py | 115 ++++++++++++++ dofutils/maps/path/path.py | 147 ++++++++++++++++++ dofutils/maps/path/path_exception.py | 5 + dofutils/maps/path/path_step.py | 15 ++ dofutils/maps/sight/__init__.py | 0 dofutils/maps/sight/battlefield_sight.py | 37 +++++ dofutils/value/color.py | 41 +++-- dofutils/value/constant/gender.py | 19 +++ dofutils/value/constant/race.py | 18 +++ dofutils/value/interval.py | 13 +- pyproject.toml | 5 - tests/value/constant/test_gender.py | 9 ++ tests/value/constant/test_race.py | 28 ++++ tests/value/test_color.py | 4 +- tests/value/test_interval.py | 10 +- 30 files changed, 571 insertions(+), 80 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/dependabot.yml delete mode 100644 dofutils/maps/abstract_map_cell.py delete mode 100644 dofutils/maps/battlefield_cell.py rename dofutils/maps/{abstract_map.py => dofus_map.py} (80%) create mode 100644 dofutils/maps/path/__init__.py create mode 100644 dofutils/maps/path/decoder.py create mode 100644 dofutils/maps/path/path.py create mode 100644 dofutils/maps/path/path_exception.py create mode 100644 dofutils/maps/path/path_step.py create mode 100644 dofutils/maps/sight/__init__.py create mode 100644 dofutils/maps/sight/battlefield_sight.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b0824dc --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,38 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers-extra/features/poetry:2": {} + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip3 install --user -r requirements.txt", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "aaron-bond.better-comments", + "ms-python.black-formatter", + "ms-python.isort", + "ms-python.vscode-pylance", + "ms-python.python", + "ms-python.debugpy", + "donjayamanne.python-environment-manager", + "ms-python.vscode-python-envs", + "donjayamanne.python-extension-pack", + "KevinRose.vsc-python-indent" + ] + } + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} 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/.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/dofutils/encoding/base64.py b/dofutils/encoding/base64.py index 2cf469b..44c1e03 100644 --- a/dofutils/encoding/base64.py +++ b/dofutils/encoding/base64.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + class Base64: # fmt: off _CHARSET: list = [ diff --git a/dofutils/encoding/checksum.py b/dofutils/encoding/checksum.py index 3a58cb9..4f8e791 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: diff --git a/dofutils/encoding/key.py b/dofutils/encoding/key.py index 3905b05..a5ea808 100644 --- a/dofutils/encoding/key.py +++ b/dofutils/encoding/key.py @@ -9,6 +9,12 @@ 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 diff --git a/dofutils/encoding/password_encoder.py b/dofutils/encoding/password_encoder.py index 4d4bb23..992804a 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 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..e05cf73 100644 --- a/dofutils/maps/__init__.py +++ b/dofutils/maps/__init__.py @@ -1,2 +1,4 @@ -from .battlefield_cell import BattleFieldCell from .coordinate_cell import CoordinateCell +from .dofus_map import DofusMap + +__all__ = ["DofusMap", "CoordinateCell"] 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/direction.py b/dofutils/maps/constant/direction.py index 8304ac9..32a12ec 100644 --- a/dofutils/maps/constant/direction.py +++ b/dofutils/maps/constant/direction.py @@ -4,6 +4,8 @@ class Direction(Enum): + UNKNOWN = (-1, lambda width: -1) + EAST = (0, lambda width: 1) SOUTH_EAST = (1, lambda width: width) SOUTH = (2, lambda width: 2 * width - 1) @@ -30,8 +32,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 +93,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..671e375 100644 --- a/dofutils/maps/coordinate_cell.py +++ b/dofutils/maps/coordinate_cell.py @@ -2,26 +2,68 @@ 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 diff --git a/dofutils/maps/abstract_map.py b/dofutils/maps/dofus_map.py similarity index 80% rename from dofutils/maps/abstract_map.py rename to dofutils/maps/dofus_map.py index a70377e..17080d4 100644 --- a/dofutils/maps/abstract_map.py +++ b/dofutils/maps/dofus_map.py @@ -4,18 +4,18 @@ from dataclasses import dataclass from ..value import Dimension -from .abstract_map_cell import AbstractMapCell +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..44880b1 --- /dev/null +++ b/dofutils/maps/path/__init__.py @@ -0,0 +1,6 @@ +from .decoder import PathDecoder +from .path import Path +from .path_exception import PathException +from .path_step import PathStep + +__all__ = ["PathStep", "PathDecoder", "Path", "PathException"] diff --git a/dofutils/maps/path/decoder.py b/dofutils/maps/path/decoder.py new file mode 100644 index 0000000..435352b --- /dev/null +++ b/dofutils/maps/path/decoder.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from dataclasses import dataclass +from itertools import groupby +from typing import List, Optional + +from dofutils.encoding import Base64 +from dofutils.maps import CoordinateCell, DofusMap +from dofutils.maps.constant import Direction +from dofutils.maps.path import Path + +from .path_exception import PathException +from .path_step import PathStep + + +@dataclass(frozen=True) +class PathDecoder: + map: DofusMap + + def next_cell_by_direction(self, start: CoordinateCell, dir: Direction) -> Optional[CoordinateCell]: + """ + 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: Optional[CoordinateCell] = 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 += PathStep(start, Direction.EAST) + + for i, c in enumerate(encoded): + 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 += 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 = 0 if include_start else 1 + for direction, steps in groupby(path.steps[start:], lambda s: s.direction): + steps = list(steps) + + encoded.append(direction.to_char()) + encoded.append(Base64.encode(steps[-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}") + + path += PathStep(c, direction) + + if steps_limit < 0: + raise PathException(f"Invalid path, too many steps from {start} to {target}") + + steps_limit -= 1 diff --git a/dofutils/maps/path/path.py b/dofutils/maps/path/path.py new file mode 100644 index 0000000..6055308 --- /dev/null +++ b/dofutils/maps/path/path.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, List, Union, overload + +from dofutils.maps.path import PathDecoder, PathStep + + +@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 + """ + kept_steps: list[PathStep] = [] + + for step in self.steps: + if not predicate(step): + break + + kept_steps.append(step) + + return Path(self.decoder, kept_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: Union[int, slice]) -> Union[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: Union[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..662be50 --- /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 "{%s, %s}" % (self.cell.id, self.direction.name) diff --git a/dofutils/maps/sight/__init__.py b/dofutils/maps/sight/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dofutils/maps/sight/battlefield_sight.py b/dofutils/maps/sight/battlefield_sight.py new file mode 100644 index 0000000..e5ac43d --- /dev/null +++ b/dofutils/maps/sight/battlefield_sight.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from dofutils.maps.coordinate_cell import CoordinateCell +from dofutils.maps.dofus_map import DofusMap + + +@dataclass(frozen=True) +class BattleFieldSight(CoordinateCell): + sight_blocking: bool = True + + def __init__(self, map: DofusMap, id: int, walkable: bool = False, sight_blocking: bool = False): + """ + Initialize a BattleFieldSight 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 False + :param sight_blocking: Whether the cell blocks sight, defaults to False + :type map: DofusMap + :type id: int + :type walkable: bool, optional defaults to False + :type sight_blocking: bool, optional defaults to False + """ + super().__init__(map, id, walkable=walkable, sight_blocking=sight_blocking) + + def between(self, target: CoordinateCell) -> int: + """ + Calculate the number of cells between the current cell and the target cell using Manhattan distance. + + :param target: The target coordinate cell + :type target: CoordinateCell + :return: The number of cells between the current cell and the target cell + :rtype: int + """ + return abs(self.x - target.x) + abs(self.y - target.y) diff --git a/dofutils/value/color.py b/dofutils/value/color.py index a439f47..47fcf66 100644 --- a/dofutils/value/color.py +++ b/dofutils/value/color.py @@ -1,6 +1,7 @@ from __future__ import annotations from random import randint +from typing import Tuple class Color: @@ -41,25 +42,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 +74,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 +85,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/gender.py b/dofutils/value/constant/gender.py index 6940f73..847e382 100644 --- a/dofutils/value/constant/gender.py +++ b/dofutils/value/constant/gender.py @@ -22,3 +22,22 @@ def parse(value: Literal["0", "1"]) -> Gender: 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..b13e0ca 100644 --- a/dofutils/value/constant/race.py +++ b/dofutils/value/constant/race.py @@ -31,3 +31,21 @@ def by_id(race_id: int) -> Race: 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/interval.py b/dofutils/value/interval.py index 0a3533a..03cf3c4 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: @@ -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/pyproject.toml b/pyproject.toml index 15e29ab..f38626a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,6 @@ classifiers = [ [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" 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()) From 6297caf4f647267070a26a9b239aa31e2ed096d8 Mon Sep 17 00:00:00 2001 From: Dysta Date: Thu, 6 Aug 2026 10:04:52 +0200 Subject: [PATCH 2/9] feat: migrate to uv --- .devcontainer/devcontainer.json | 38 ------- .github/workflows/python-publish.yml | 32 ++---- .github/workflows/python-test-publish.yml | 35 +++--- .github/workflows/python-unit-test.yml | 132 ++++++++++------------ .gitignore | 5 +- .readthedocs.yaml | 4 +- .vscode/settings.json | 8 +- dofutils/encoding/__init__.py | 10 +- dofutils/encoding/base64.py | 4 +- dofutils/encoding/key.py | 3 +- dofutils/maps/__init__.py | 6 +- dofutils/maps/constant/__init__.py | 4 +- dofutils/maps/constant/cell_movement.py | 6 +- dofutils/maps/coordinate_cell.py | 8 +- dofutils/maps/path/__init__.py | 10 +- dofutils/maps/path/decoder.py | 13 ++- dofutils/maps/path/path.py | 11 +- dofutils/maps/path/path_step.py | 2 +- dofutils/maps/sight/battlefield_sight.py | 8 +- dofutils/value/__init__.py | 6 +- dofutils/value/color.py | 5 +- dofutils/value/constant/__init__.py | 4 +- dofutils/value/dimension.py | 2 +- dofutils/value/interval.py | 2 +- mise.toml | 12 ++ pyproject.toml | 85 +++++++++----- tests/maps/test_coordinate_cell.py | 5 +- 27 files changed, 223 insertions(+), 237 deletions(-) delete mode 100644 .devcontainer/devcontainer.json create mode 100644 mise.toml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index b0824dc..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,38 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/python -{ - "name": "Python 3", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", - - // Features to add to the dev container. More info: https://containers.dev/features. - "features": { - "ghcr.io/devcontainers-extra/features/poetry:2": {} - }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "pip3 install --user -r requirements.txt", - - // Configure tool-specific properties. - "customizations": { - "vscode": { - "extensions": [ - "aaron-bond.better-comments", - "ms-python.black-formatter", - "ms-python.isort", - "ms-python.vscode-pylance", - "ms-python.python", - "ms-python.debugpy", - "donjayamanne.python-environment-manager", - "ms-python.vscode-python-envs", - "donjayamanne.python-extension-pack", - "KevinRose.vsc-python-indent" - ] - } - } - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ac10e8f..c6fd17c 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,29 +1,21 @@ -# 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 }} + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - run: uv build + - run: uv publish diff --git a/.github/workflows/python-test-publish.yml b/.github/workflows/python-test-publish.yml index 8cdf4c2..62bfd5c 100644 --- a/.github/workflows/python-test-publish.yml +++ b/.github/workflows/python-test-publish.yml @@ -1,31 +1,22 @@ -# 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/* + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - run: uv build + - run: uv publish diff --git a/.github/workflows/python-unit-test.yml b/.github/workflows/python-unit-test.yml index 24f2361..c27d79c 100644 --- a/.github/workflows/python-unit-test.yml +++ b/.github/workflows/python-unit-test.yml @@ -1,95 +1,77 @@ -# 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: - - '**' + - "**" + +permissions: + contents: read jobs: linter: - runs-on: ${{ matrix.os }} name: Lint Python - continue-on-error: true + runs-on: ubuntu-latest + env: + UV_PYTHON: "3.14" + steps: + - 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 + + - name: Run ruff + run: | + uv sync --all-groups + uv run ruff check . + uv run ruff format --check . - 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: Type check Python + runs-on: ubuntu-latest + env: + UV_PYTHON: "3.14" + steps: + - 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: Install dependencies + run: uv sync --all-groups - unit-tests: - runs-on: ${{ matrix.os }} - name: Unittest Python - continue-on-error: true + - name: Run mypy + run: uv run task check + unit-tests: + name: Unit tests (Python ${{ matrix.python-version }}) + 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.10", "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/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/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 44c1e03..a317bce 100644 --- a/dofutils/encoding/base64.py +++ b/dofutils/encoding/base64.py @@ -1,9 +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", "-", "_", diff --git a/dofutils/encoding/key.py b/dofutils/encoding/key.py index a5ea808..08ff840 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 @@ -16,7 +15,7 @@ def __init__(self, key: str) -> None: :type key: str """ self._key: str = key - self._cipher: Optional[XorCipher] = None + self._cipher: XorCipher | None = None @property def key(self) -> str: diff --git a/dofutils/maps/__init__.py b/dofutils/maps/__init__.py index e05cf73..5a74d01 100644 --- a/dofutils/maps/__init__.py +++ b/dofutils/maps/__init__.py @@ -1,4 +1,4 @@ -from .coordinate_cell import CoordinateCell -from .dofus_map import DofusMap +from .coordinate_cell import CoordinateCell as CoordinateCell +from .dofus_map import DofusMap as DofusMap -__all__ = ["DofusMap", "CoordinateCell"] +__all__ = ["CoordinateCell", "DofusMap"] 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/coordinate_cell.py b/dofutils/maps/coordinate_cell.py index 671e375..47979d7 100644 --- a/dofutils/maps/coordinate_cell.py +++ b/dofutils/maps/coordinate_cell.py @@ -16,7 +16,13 @@ class CoordinateCell: walkable: bool = True sight_blocking: bool = False - def __init__(self, map: DofusMap, id: int, walkable: bool = True, sight_blocking: bool = False): + def __init__( + self, + map: DofusMap, + id: int, + walkable: bool = True, + sight_blocking: bool = False, + ): """ Initialize a CoordinateCell instance. diff --git a/dofutils/maps/path/__init__.py b/dofutils/maps/path/__init__.py index 44880b1..9f0ee90 100644 --- a/dofutils/maps/path/__init__.py +++ b/dofutils/maps/path/__init__.py @@ -1,6 +1,6 @@ -from .decoder import PathDecoder -from .path import Path -from .path_exception import PathException -from .path_step import PathStep +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 -__all__ = ["PathStep", "PathDecoder", "Path", "PathException"] +__all__ = ["Path", "PathDecoder", "PathException", "PathStep"] diff --git a/dofutils/maps/path/decoder.py b/dofutils/maps/path/decoder.py index 435352b..24fc3c3 100644 --- a/dofutils/maps/path/decoder.py +++ b/dofutils/maps/path/decoder.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from itertools import groupby -from typing import List, Optional from dofutils.encoding import Base64 from dofutils.maps import CoordinateCell, DofusMap @@ -17,7 +16,7 @@ class PathDecoder: map: DofusMap - def next_cell_by_direction(self, start: CoordinateCell, dir: Direction) -> Optional[CoordinateCell]: + 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 @@ -35,7 +34,7 @@ def next_cell_by_direction(self, start: CoordinateCell, dir: Direction) -> Optio return self.map.get_cell(next_id) - def decode(self, encoded: str, start: Optional[CoordinateCell] = None) -> Path: + def decode(self, encoded: str, start: CoordinateCell | None = None) -> Path: """ Decode a path encoded string into a list of directions @@ -83,7 +82,7 @@ def encode(self, path: Path, include_start: bool = False) -> str: :return: The encoded path string :rtype: str """ - encoded: List[str] = [] + encoded: list[str] = [] if include_start: encoded.append(Direction.EAST.to_char()) @@ -99,7 +98,11 @@ def encode(self, path: Path, include_start: bool = False) -> str: return "".join(encoded) def _expand_rectilinear_move( - self, path: Path, start: CoordinateCell, target: CoordinateCell, direction: Direction + self, + path: Path, + start: CoordinateCell, + target: CoordinateCell, + direction: Direction, ) -> None: steps_limit: int = 2 * self.map.dimensions.width + 1 diff --git a/dofutils/maps/path/path.py b/dofutils/maps/path/path.py index 6055308..3158fa5 100644 --- a/dofutils/maps/path/path.py +++ b/dofutils/maps/path/path.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Callable, List, Union, overload +from typing import overload from dofutils.maps.path import PathDecoder, PathStep @@ -9,7 +10,7 @@ @dataclass class Path: decoder: PathDecoder - steps: List[PathStep] = field(default_factory=list) + steps: list[PathStep] = field(default_factory=list) def path_step(self, index: int) -> PathStep: """ @@ -110,9 +111,9 @@ def __iter__(self): @overload def __getitem__(self, index: int) -> PathStep: ... @overload - def __getitem__(self, index: slice) -> List[PathStep]: ... + def __getitem__(self, index: slice) -> list[PathStep]: ... - def __getitem__(self, index: Union[int, slice]) -> Union[PathStep, 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 @@ -129,7 +130,7 @@ def __getitem__(self, index: Union[int, slice]) -> Union[PathStep, List[PathStep else: raise TypeError(f"Invalid argument type: {type(index)}") - def __add__(self, other: Union[Path, List[PathStep], PathStep]) -> Path: + def __add__(self, other: Path | list[PathStep] | PathStep) -> Path: """ Concatenate two paths together diff --git a/dofutils/maps/path/path_step.py b/dofutils/maps/path/path_step.py index 662be50..4abe069 100644 --- a/dofutils/maps/path/path_step.py +++ b/dofutils/maps/path/path_step.py @@ -12,4 +12,4 @@ class PathStep: direction: Direction def __str__(self) -> str: - return "{%s, %s}" % (self.cell.id, self.direction.name) + return f"{{{self.cell.id}, {self.direction.name}}}" diff --git a/dofutils/maps/sight/battlefield_sight.py b/dofutils/maps/sight/battlefield_sight.py index e5ac43d..c9caa48 100644 --- a/dofutils/maps/sight/battlefield_sight.py +++ b/dofutils/maps/sight/battlefield_sight.py @@ -10,7 +10,13 @@ class BattleFieldSight(CoordinateCell): sight_blocking: bool = True - def __init__(self, map: DofusMap, id: int, walkable: bool = False, sight_blocking: bool = False): + def __init__( + self, + map: DofusMap, + id: int, + walkable: bool = False, + sight_blocking: bool = False, + ): """ Initialize a BattleFieldSight instance. 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 47fcf66..b05cb41 100644 --- a/dofutils/value/color.py +++ b/dofutils/value/color.py @@ -1,7 +1,6 @@ from __future__ import annotations from random import randint -from typing import Tuple class Color: @@ -42,7 +41,7 @@ def color3(self) -> int: """ return self._color3 - def colors(self) -> Tuple[int, int, int]: + def colors(self) -> tuple[int, int, int]: """ Return a tuple containing the colors @@ -51,7 +50,7 @@ def colors(self) -> Tuple[int, int, int]: """ return (self.color1, self.color2, self.color3) - def hex_colors(self) -> Tuple[str, str, str]: + def hex_colors(self) -> tuple[str, str, str]: """ Return a tuple containing the colors in a hexadecimal format 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/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 03cf3c4..5d2f773 100644 --- a/dofutils/value/interval.py +++ b/dofutils/value/interval.py @@ -43,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] 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 f38626a..d805a49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,37 +1,66 @@ -[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.10, <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.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.10", + "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/test_coordinate_cell.py b/tests/maps/test_coordinate_cell.py index 4c824d4..0058fb7 100644 --- a/tests/maps/test_coordinate_cell.py +++ b/tests/maps/test_coordinate_cell.py @@ -1,7 +1,6 @@ from unittest import TestCase -from dofutils.maps import CoordinateCell +# from dofutils.maps import CoordinateCell -class TestCoordinateCell(TestCase): - ... +class TestCoordinateCell(TestCase): ... From 00de519c54bf98771543829307a01c33510c3f0f Mon Sep 17 00:00:00 2001 From: Dysta Date: Thu, 6 Aug 2026 11:11:30 +0200 Subject: [PATCH 3/9] fix: direction tests --- dofutils/maps/constant/direction.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dofutils/maps/constant/direction.py b/dofutils/maps/constant/direction.py index 32a12ec..de590ee 100644 --- a/dofutils/maps/constant/direction.py +++ b/dofutils/maps/constant/direction.py @@ -4,8 +4,6 @@ class Direction(Enum): - UNKNOWN = (-1, lambda width: -1) - EAST = (0, lambda width: 1) SOUTH_EAST = (1, lambda width: width) SOUTH = (2, lambda width: 2 * width - 1) From 41df631f0992ea181502e69b5a981e04664cf831 Mon Sep 17 00:00:00 2001 From: Dysta Date: Thu, 6 Aug 2026 11:13:49 +0200 Subject: [PATCH 4/9] fix: linter --- dofutils/maps/path/decoder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dofutils/maps/path/decoder.py b/dofutils/maps/path/decoder.py index 24fc3c3..b59efdf 100644 --- a/dofutils/maps/path/decoder.py +++ b/dofutils/maps/path/decoder.py @@ -90,10 +90,10 @@ def encode(self, path: Path, include_start: bool = False) -> str: start: int = 0 if include_start else 1 for direction, steps in groupby(path.steps[start:], lambda s: s.direction): - steps = list(steps) + steps_l = list(steps) encoded.append(direction.to_char()) - encoded.append(Base64.encode(steps[-1].cell.id, 2)) + encoded.append(Base64.encode(steps_l[-1].cell.id, 2)) return "".join(encoded) From eeb3d4ccbb77245b4c109ddaf78c5d02d647409c Mon Sep 17 00:00:00 2001 From: Dysta Date: Thu, 6 Aug 2026 11:16:48 +0200 Subject: [PATCH 5/9] feat: cover more py version --- .github/workflows/python-unit-test.yml | 36 +++++++------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/.github/workflows/python-unit-test.yml b/.github/workflows/python-unit-test.yml index c27d79c..d80cf93 100644 --- a/.github/workflows/python-unit-test.yml +++ b/.github/workflows/python-unit-test.yml @@ -12,11 +12,14 @@ permissions: contents: read jobs: - linter: - name: Lint Python + code-sanitize: + name: Code Sanitize runs-on: ubuntu-latest - env: - UV_PYTHON: "3.14" + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: - name: Checkout uses: actions/checkout@v7 @@ -26,34 +29,15 @@ jobs: with: enable-cache: true - - name: Run ruff + - name: Check code sanitize run: | uv sync --all-groups uv run ruff check . uv run ruff format --check . - - type-checker: - name: Type check Python - runs-on: ubuntu-latest - env: - UV_PYTHON: "3.14" - steps: - - 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 mypy - run: uv run task check + uv run mypy . --exclude '^tests/' unit-tests: - name: Unit tests (Python ${{ matrix.python-version }}) + name: Unit tests runs-on: ubuntu-latest strategy: fail-fast: false From 6f03aa8380e13c65a30fc531d71f7796149c8d79 Mon Sep 17 00:00:00 2001 From: Dysta Date: Thu, 6 Aug 2026 11:21:14 +0200 Subject: [PATCH 6/9] feat: better naming --- .github/workflows/python-publish.yml | 15 +++++++++++---- .github/workflows/python-test-publish.yml | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index c6fd17c..f8e65b8 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,14 @@ jobs: UV_PUBLISH_USERNAME: ${{ secrets.PYPI_USERNAME }} UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_PASSWORD }} steps: - - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - - run: uv build - - run: uv publish + - 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 62bfd5c..4b899b1 100644 --- a/.github/workflows/python-test-publish.yml +++ b/.github/workflows/python-test-publish.yml @@ -16,7 +16,14 @@ jobs: UV_PUBLISH_USERNAME: ${{ secrets.PYPI_TEST_USERNAME }} UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TEST_PASSWORD }} steps: - - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - - run: uv build - - run: uv publish + - 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 From af75a06be70ceb5f32ac9f2bb31e89efbc48a266 Mon Sep 17 00:00:00 2001 From: Dysta Date: Sat, 8 Aug 2026 21:15:51 +0200 Subject: [PATCH 7/9] feat: end implementation of maps --- dofutils/encoding/base64.py | 15 +-- dofutils/encoding/checksum.py | 2 +- dofutils/encoding/key.py | 17 +-- dofutils/encoding/password_encoder.py | 10 +- dofutils/maps/__init__.py | 17 ++- dofutils/maps/coordinate_cell.py | 2 +- dofutils/maps/dofus_map.py | 5 +- dofutils/maps/path/__init__.py | 3 +- dofutils/maps/path/decoder.py | 27 +++-- dofutils/maps/path/path.py | 18 ++- dofutils/maps/path/pathfinder.py | 84 ++++++++++++++ dofutils/maps/serializer.py | 125 +++++++++++++++++++++ dofutils/maps/sight/__init__.py | 4 + dofutils/maps/sight/battlefield_sight.py | 106 +++++++++++------ dofutils/value/constant/gender.py | 6 +- dofutils/value/constant/race.py | 6 +- tests/maps/_map.py | 22 ++++ tests/maps/path/__init__.py | 0 tests/maps/path/test_decoder.py | 26 +++++ tests/maps/path/test_pathfinder.py | 19 ++++ tests/maps/sight/__init__.py | 0 tests/maps/sight/test_battlefield_sight.py | 21 ++++ tests/maps/test_coordinate_cell.py | 17 ++- tests/maps/test_serializer.py | 11 ++ 24 files changed, 456 insertions(+), 107 deletions(-) create mode 100644 dofutils/maps/path/pathfinder.py create mode 100644 dofutils/maps/serializer.py create mode 100644 tests/maps/_map.py create mode 100644 tests/maps/path/__init__.py create mode 100644 tests/maps/path/test_decoder.py create mode 100644 tests/maps/path/test_pathfinder.py create mode 100644 tests/maps/sight/__init__.py create mode 100644 tests/maps/sight/test_battlefield_sight.py create mode 100644 tests/maps/test_serializer.py diff --git a/dofutils/encoding/base64.py b/dofutils/encoding/base64.py index a317bce..3707aca 100644 --- a/dofutils/encoding/base64.py +++ b/dofutils/encoding/base64.py @@ -82,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: @@ -137,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 4f8e791..0b50cf7 100644 --- a/dofutils/encoding/checksum.py +++ b/dofutils/encoding/checksum.py @@ -11,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 08ff840..c43828d 100644 --- a/dofutils/encoding/key.py +++ b/dofutils/encoding/key.py @@ -49,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: """ @@ -80,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 992804a..7c2c338 100644 --- a/dofutils/encoding/password_encoder.py +++ b/dofutils/encoding/password_encoder.py @@ -48,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/maps/__init__.py b/dofutils/maps/__init__.py index 5a74d01..d9b0960 100644 --- a/dofutils/maps/__init__.py +++ b/dofutils/maps/__init__.py @@ -1,4 +1,19 @@ 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__ = ["CoordinateCell", "DofusMap"] +__all__ = [ + "CellData", + "CellLayerData", + "CoordinateCell", + "DefaultMapDataSerializer", + "DofusMap", + "EncryptedMapDataSerializer", + "GroundCellData", + "InteractiveObjectData", +] diff --git a/dofutils/maps/coordinate_cell.py b/dofutils/maps/coordinate_cell.py index 47979d7..6345ed3 100644 --- a/dofutils/maps/coordinate_cell.py +++ b/dofutils/maps/coordinate_cell.py @@ -80,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/dofus_map.py b/dofutils/maps/dofus_map.py index 17080d4..0353757 100644 --- a/dofutils/maps/dofus_map.py +++ b/dofutils/maps/dofus_map.py @@ -2,9 +2,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING from ..value import Dimension -from .coordinate_cell import CoordinateCell + +if TYPE_CHECKING: + from .coordinate_cell import CoordinateCell @dataclass(frozen=True) diff --git a/dofutils/maps/path/__init__.py b/dofutils/maps/path/__init__.py index 9f0ee90..60142d3 100644 --- a/dofutils/maps/path/__init__.py +++ b/dofutils/maps/path/__init__.py @@ -2,5 +2,6 @@ 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"] +__all__ = ["Path", "PathDecoder", "PathException", "PathStep", "Pathfinder"] diff --git a/dofutils/maps/path/decoder.py b/dofutils/maps/path/decoder.py index b59efdf..6043d85 100644 --- a/dofutils/maps/path/decoder.py +++ b/dofutils/maps/path/decoder.py @@ -6,8 +6,8 @@ from dofutils.encoding import Base64 from dofutils.maps import CoordinateCell, DofusMap from dofutils.maps.constant import Direction -from dofutils.maps.path import Path +from .path import Path from .path_exception import PathException from .path_step import PathStep @@ -16,6 +16,11 @@ 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 @@ -51,9 +56,10 @@ def decode(self, encoded: str, start: CoordinateCell | None = None) -> Path: directions: Path = Path(self) if start: - directions += PathStep(start, Direction.EAST) + directions.steps.append(PathStep(start, Direction.EAST)) - for i, c in enumerate(encoded): + for i in range(0, len(encoded), 3): + c = encoded[i] if c < "a" or c > "h": raise ValueError(f"Invalid direction character: {c}") @@ -64,7 +70,7 @@ def decode(self, encoded: str, start: CoordinateCell | None = None) -> Path: raise ValueError(f"Invalid cell id: {cell}") if directions.empty(): - directions += PathStep(self.map.get_cell(cell), Direction.EAST) + 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) @@ -88,7 +94,7 @@ def encode(self, path: Path, include_start: bool = False) -> str: encoded.append(Direction.EAST.to_char()) encoded.append(Base64.encode(path.first().cell.id, 2)) - start: int = 0 if include_start else 1 + start: int = 1 if include_start else 0 for direction, steps in groupby(path.steps[start:], lambda s: s.direction): steps_l = list(steps) @@ -110,9 +116,8 @@ def _expand_rectilinear_move( if (c := self.next_cell_by_direction(start, direction)) is None: raise PathException(f"Invalid cell number, cannot move from {start} to {target}") - path += PathStep(c, direction) - - if steps_limit < 0: - raise PathException(f"Invalid path, too many steps from {start} to {target}") - - steps_limit -= 1 + 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 index 3158fa5..b2569e6 100644 --- a/dofutils/maps/path/path.py +++ b/dofutils/maps/path/path.py @@ -2,9 +2,13 @@ from collections.abc import Callable from dataclasses import dataclass, field -from typing import overload +from itertools import takewhile +from typing import TYPE_CHECKING, overload -from dofutils.maps.path import PathDecoder, PathStep +from .path_step import PathStep + +if TYPE_CHECKING: + from .decoder import PathDecoder @dataclass @@ -71,15 +75,7 @@ def keep_while(self, predicate: Callable[[PathStep], bool]) -> Path: :return: A new path with the kept steps :rtype: Path """ - kept_steps: list[PathStep] = [] - - for step in self.steps: - if not predicate(step): - break - - kept_steps.append(step) - - return Path(self.decoder, kept_steps) + return Path(self.decoder, list(takewhile(predicate, self.steps))) def empty(self) -> bool: """ 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 index e69de29..b934b53 100644 --- a/dofutils/maps/sight/__init__.py +++ 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 index c9caa48..bbabac4 100644 --- a/dofutils/maps/sight/battlefield_sight.py +++ b/dofutils/maps/sight/battlefield_sight.py @@ -1,43 +1,77 @@ from __future__ import annotations -from dataclasses import dataclass +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 -@dataclass(frozen=True) -class BattleFieldSight(CoordinateCell): - sight_blocking: bool = True - - def __init__( - self, - map: DofusMap, - id: int, - walkable: bool = False, - sight_blocking: bool = False, - ): - """ - Initialize a BattleFieldSight 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 False - :param sight_blocking: Whether the cell blocks sight, defaults to False - :type map: DofusMap - :type id: int - :type walkable: bool, optional defaults to False - :type sight_blocking: bool, optional defaults to False - """ - super().__init__(map, id, walkable=walkable, sight_blocking=sight_blocking) - - def between(self, target: CoordinateCell) -> int: - """ - Calculate the number of cells between the current cell and the target cell using Manhattan distance. - - :param target: The target coordinate cell - :type target: CoordinateCell - :return: The number of cells between the current cell and the target cell - :rtype: int - """ - return abs(self.x - target.x) + abs(self.y - target.y) +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/constant/gender.py b/dofutils/value/constant/gender.py index 847e382..9d53630 100644 --- a/dofutils/value/constant/gender.py +++ b/dofutils/value/constant/gender.py @@ -18,11 +18,11 @@ 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 diff --git a/dofutils/value/constant/race.py b/dofutils/value/constant/race.py index b13e0ca..9145cec 100644 --- a/dofutils/value/constant/race.py +++ b/dofutils/value/constant/race.py @@ -27,11 +27,11 @@ 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. 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 0058fb7..ddc4aea 100644 --- a/tests/maps/test_coordinate_cell.py +++ b/tests/maps/test_coordinate_cell.py @@ -1,6 +1,19 @@ from unittest import TestCase -# from dofutils.maps import CoordinateCell +from dofutils.maps.constant import Direction +from ._map import TestMap -class TestCoordinateCell(TestCase): ... + +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]))) From 7fa98c10385bfc692d6e60ebce1419b2a6189a04 Mon Sep 17 00:00:00 2001 From: Dysta Date: Sat, 8 Aug 2026 21:35:17 +0200 Subject: [PATCH 8/9] feat: drop support for 3.10 --- .github/workflows/python-unit-test.yml | 4 ++-- pyproject.toml | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-unit-test.yml b/.github/workflows/python-unit-test.yml index d80cf93..85fba6b 100644 --- a/.github/workflows/python-unit-test.yml +++ b/.github/workflows/python-unit-test.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout @@ -42,7 +42,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] env: UV_PYTHON: ${{ matrix.python-version }} steps: diff --git a/pyproject.toml b/pyproject.toml index d805a49..7fa7ea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "Dofutils" version = "0.0.4" description = "Collection of useful things to build Dofus Retro bot or emulator" readme = "README.md" -requires-python = ">=3.10, <4.0" +requires-python = ">=3.11, <4.0" authors = [{ name = "Dysta" }] keywords = [ "bot", @@ -15,7 +15,6 @@ keywords = [ classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", From 1d03a926120ae6791870058fcbd174a9418cf889 Mon Sep 17 00:00:00 2001 From: Dysta Date: Sun, 9 Aug 2026 17:30:08 +0200 Subject: [PATCH 9/9] feat: update docs --- docs/modules/maps.rst | 82 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 7 deletions(-) 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: