"""Models for data sent over the wire."""
# ruff: noqa: RUF023
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, ClassVar, final
from cable_club import constants
from cable_club.constants import ABILITIES_FILE, ITEMS_FILE, MOVES_FILE, POKEMONS_FILE
from cable_club.data import configparser
from cable_club.exceptions import ValidationError
if TYPE_CHECKING:
from typing_extensions import Self
from cable_club.config import Config
from cable_club.data import Reader
# TODO(elpekenin): some sentinel value on the not-yet-configured fields
# so that they error out if we try and assign to them, rather than not running
# checks (or run wrong ones) ??
[docs]
class Model(ABC):
"""Base class for all data models.
Provides some common logic for all data models.
"""
config: Config
"""Not present until configure() gets called."""
__slots__: tuple[str, ...]
@final
def __repr__(self) -> str:
"""Represent an instance as string."""
classname = type(self).__name__
reprs: list[str] = []
for field in self.__slots__:
raw = getattr(self, field, "NOT_SET")
repr_ = repr(raw)
reprs.append(f"{field}: {repr_}")
body = ", ".join(reprs)
return f"<{classname}: {body}>"
[docs]
@abstractmethod
def do_read_from(self, reader: Reader) -> None:
"""Model-specific reading."""
[docs]
def validate(self) -> None:
"""Run checks that can not be done in the fields (eg: inter-field relations).
Default to noop rather than being an abstractmethod because most models wont
need to do anything here.
"""
return
[docs]
@final
@classmethod
def read_from(cls, reader: Reader) -> Self:
"""Create an instance by reading values."""
self = cls()
self.do_read_from(reader)
self.validate()
return self
[docs]
class Move(Model):
"""Represent a Pokemon's move."""
__slots__ = ("name",)
names: ClassVar[set[str]] = set()
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.name = reader.one_of(options=self.names)
[docs]
class SketchedMove(Model):
"""Represent a Pokemon's move obtained via sketch."""
__slots__ = (
"name",
"ppup",
"mastery",
)
names: ClassVar[set[str]] = set()
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.name = reader.one_of(options=self.names)
self.ppup = reader.integer(min_val=0, max_val=3)
if self.config.pla_installed:
self.mastery = reader.boolean(allow_none=True)
[docs]
class Iv(Model):
"""Represent a Pokemon's IV."""
__slots__ = (
"val",
"maxed",
)
max_val: ClassVar[int] = 0
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.val = reader.integer(
min_val=0,
max_val=self.max_val,
)
self.maxed = reader.boolean(allow_none=True)
[docs]
class Ev(Model):
"""Represent a Pokemon's EV."""
__slots__ = ("val",)
max_val: ClassVar[int] = 0
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.val = reader.integer(
min_val=0,
max_val=self.max_val,
)
[docs]
class ObtainStats(Model):
"""Represent how a Pokémon was obtained."""
__slots__ = (
"mode",
"map",
"text",
"level",
"hatched_map",
)
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.mode = reader.integer(min_val=0)
self.map = reader.integer(min_val=0)
self.text = reader.read()
self.level = reader.integer(min_val=0)
self.hatched_map = reader.integer(min_val=0)
[docs]
class ContestStats(Model):
"""Represent a Pokémon stats for contests."""
__slots__ = (
"cool",
"beauty",
"cute",
"smart",
"tough",
"sheen",
)
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.cool = reader.integer(min_val=0)
self.beauty = reader.integer(min_val=0)
self.cute = reader.integer(min_val=0)
self.smart = reader.integer(min_val=0)
self.tough = reader.integer(min_val=0)
self.sheen = reader.integer(min_val=0)
[docs]
class EssentialDeluxeProperties(Model):
"""Represent optional fields based on game/server configuration."""
__slots__ = (
"scale",
"memento",
"dmax_level",
"gmax_factor",
"dmax_able",
"tera_type",
"focus_type",
)
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
if (
self.config.essentials_deluxe_installed
or self.config.mui_mementos_installed
):
self.scale = reader.integer(min_val=0)
if self.config.mui_mementos_installed:
self.memento = reader.read()
if self.config.zud_dynamax_installed:
self.dmax_level = reader.integer(min_val=0)
self.gmax_factor = reader.boolean()
self.dmax_able = reader.boolean()
if self.config.tera_installed:
self.tera_type = reader.read()
if self.config.focus_installed:
self.focus_type = reader.read()
class _MailSpecies(Model):
"""Pokemon data on a mail."""
__slots__ = (
"gender",
"shiny",
"form",
"shadow",
"egg",
)
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.gender = reader.integer(min_val=0)
self.shiny = reader.boolean()
self.form = reader.integer(min_val=0)
self.shadow = reader.boolean()
self.egg = reader.boolean()
[docs]
class Mail(Model):
"""Data on a mail."""
__slots__ = (
"item",
"msg",
"sender",
"species",
)
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.item = reader.read()
self.msg = reader.read()
self.sender = reader.read()
self.species: list[_MailSpecies] = []
for _ in range(3):
has_species = reader.integer(allow_none=True)
if has_species:
self.species.append(_MailSpecies.read_from(reader))
[docs]
class Pokemon(Model):
"""Represent a Pokemon's data."""
# TODO(elpekenin): move related fields into classes
__slots__ = (
"species",
"level",
"personal_id",
"owner_id",
"owner_name",
"owner_gender",
"exp",
"form",
"item",
"sketched_moves",
"regular_moves",
"mastered_moves",
"gender",
"shiny",
"ability",
"ability_index",
"nature_id",
"nature_stats_id",
"ivs",
"evs",
"happiness",
"name",
"pokeball",
"steps_to_hatch",
"pokerus",
"obtain_stats",
"contest_stats",
"ribbons",
"essential_deluxe_properties",
"mail",
"fusion",
)
species_names: ClassVar[set[str]] = set()
max_level: ClassVar[int] = 0
max_owner_name_len: ClassVar[int] = 0
item_names: ClassVar[set[str]] = set()
ability_names: ClassVar[set[str]] = set()
max_ev_sum: ClassVar[int] = 0
pokeball_names: ClassVar[set[str]] = set()
max_name_len: ClassVar[int] = 0
# ruff doesnt like the code being this long, but we dont care :)
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.species = reader.one_of(options=self.species_names)
self.level = reader.integer(min_val=1)
self.personal_id = reader.integer(min_val=0)
self.owner_id = reader.integer(
# NOTE(elpekenin): original code used if owner_id & ~0xFFFFFFFF
# which i believe is equivalent max_val = flag+1
max_val=0xFFFFFFFF + 1,
)
self.owner_name = reader.read(max_len=self.max_owner_name_len)
self.owner_gender = reader.integer(min_val=0, max_val=1)
self.exp = reader.integer(min_val=0)
self.form = reader.integer(min_val=0)
self.item = reader.one_of(options=self.item_names)
self.sketched_moves: list[SketchedMove] = []
n_sketched_moves = reader.integer(min_val=0)
for _ in range(n_sketched_moves):
self.sketched_moves.append(SketchedMove.read_from(reader))
self.regular_moves: list[Move] = []
n_regular_moves = reader.integer(min_val=0)
for _ in range(n_regular_moves):
self.regular_moves.append(Move.read_from(reader))
if self.config.pla_installed:
self.mastered_moves: list[Move] = []
n_mastered_moves = reader.integer(min_val=0)
for _ in range(n_mastered_moves):
self.mastered_moves.append(Move.read_from(reader))
self.gender = reader.integer(min_val=0, max_val=2)
self.shiny = reader.boolean()
self.ability = reader.one_of(options=self.ability_names)
self.ability_index = reader.integer(min_val=0, allow_none=True)
self.nature_id = reader.read()
self.nature_stats_id = reader.read()
self.ivs: list[Iv] = []
self.evs: list[Ev] = []
for _ in range(6):
self.ivs.append(Iv.read_from(reader))
self.evs.append(Ev.read_from(reader))
self.happiness = reader.integer(
min_val=0,
max_val=255,
)
self.name = reader.read(max_len=self.max_name_len)
self.pokeball = reader.one_of(options=self.pokeball_names)
self.steps_to_hatch = reader.integer(min_val=0)
self.pokerus = reader.integer(min_val=0)
self.obtain_stats = ObtainStats.read_from(reader)
self.contest_stats = ContestStats.read_from(reader)
self.ribbons: list[str] = []
n_ribbons = reader.integer(min_val=0)
for _ in range(n_ribbons):
self.ribbons.append(reader.read())
self.essential_deluxe_properties = EssentialDeluxeProperties.read_from(reader)
# mail
has_mail = reader.boolean()
if has_mail:
self.mail = Mail.read_from(reader)
fused = reader.boolean()
if fused:
self.fusion = Pokemon.read_from(reader)
[docs]
def validate(self) -> None:
"""Run some checks between parsed attributes."""
pokedex = configparser.parse_pokemon_data(
self.config.pbs_dir / constants.POKEMONS_FILE,
)
pokemon = pokedex[self.species]
# FIXME(elpekenin): this doesnt seem to be what i first thought
if False:
moves = pokemon["moves"]
copy_moves = moves.intersection(self.config.sketch_move_ids)
if not copy_moves and self.sketched_moves:
msg = (
f"{self.species} can not learn any move to copy moves (eg: sketch)."
" Thus, it can not specify any moves as learnt this way."
)
raise ValidationError(msg)
for move in (
*self.regular_moves,
*self.mastered_moves,
):
if move.name not in moves:
msg = f"{self.species} can not learn {move}."
raise ValidationError(msg)
genders = pokemon["genders"]
if self.gender not in genders:
msg = f"{self.species} can not have gender={self.gender}"
raise ValidationError(msg)
forms = pokemon["forms"]
if self.form not in forms:
msg = f"{self.species} can not have form={self.form}"
raise ValidationError(msg)
ev_sum = 0
for ev in self.evs:
ev_sum += ev.val
if ev_sum > self.max_ev_sum:
msg = "ev sum exceeds maximum configured value"
raise ValidationError(msg)
# TODO(elpekenin): check if any validation is missing
[docs]
class Party(Model):
"""A team of Pokemon."""
__slots__ = ("pokemons",)
[docs]
def do_read_from(self, reader: Reader) -> None:
"""Initialize an instance by reading input."""
self.pokemons: list[Pokemon] = []
n_pokemon = reader.integer(min_val=0)
for _ in range(n_pokemon):
self.pokemons.append(Pokemon.read_from(reader))
leftovers = reader.left()
if leftovers:
rest = ", ".join(leftovers)
msg = f"Data left in reader: {rest}"
raise ValidationError(msg)