"""Models related to the tournaments section in Tibia.com and the tournaments leaderboards."""
import datetime
import math
import re
from typing import List, TYPE_CHECKING
from tibiapy import abc
from tibiapy.enums import PvpType, TournamentPhase, Vocation
from tibiapy.errors import InvalidContent
from tibiapy.utils import get_tibia_url, parse_integer, parse_popup, parse_tibia_datetime, parse_tibia_full_date, \
parse_tibiacom_content, split_list, try_enum
if TYPE_CHECKING:
import bs4
__all__ = (
"TournamentLeaderboardEntry",
"TournamentEntry",
"RewardEntry",
"RuleSet",
"ScoreSet",
"Tournament",
"TournamentLeaderboard",
)
RANGE_PATTERN = re.compile(r'(\d+)(?:-(\d+))?')
CUP_PATTERN = re.compile(r'(\w+ cup)')
DEED_PATTERN = re.compile(r'(\w+ deed)')
ARCHIVE_LIST_PATTERN = re.compile(r'([\w\s]+)\s\(([^-]+)-\s([^)]+)\)')
RANK_PATTERN = re.compile(r'(\d+)\.\s\(\+?(-?\d+)\)')
RESULTS_PATTERN = re.compile(r'Results: (\d+)')
CURRENT_TOURNAMENT_PATTERN = re.compile(r'(?:.*- (\w+))')
TOURNAMENT_LEADERBOARDS_URL = "https://www.tibia.com/community/?subtopic=tournamentleaderboard"
[docs]class TournamentLeaderboardEntry(abc.BaseCharacter, abc.Serializable):
"""Represents a single tournament leaderboard's entry.
.. versionadded:: 2.5.0
Attributes
----------
name: :class:`str`
The character's name.
rank: :class:`int`
The entry's rank.
change: :class:`int`
The entry's change in rank since the last server save.
vocation: :class:`Vocation`
The character's vocation. This will always show the base vocation, without promotions.
score: :class:`int`
The entry's score.
"""
__slots__ = (
"name",
"rank",
"change",
"vocation",
"score",
)
def __init__(self, **kwargs):
self.name: str = kwargs.get("name")
self.rank: int = kwargs.get("rank")
self.change: int = kwargs.get("change")
self.vocation: Vocation = kwargs.get("vocation")
self.score: int = kwargs.get("score")
def __repr__(self):
return "<{0.__class__.__name__} rank={0.rank} name={0.name!r} vocation={0.vocation!r} " \
"points={0.score}>".format(self)
[docs]class TournamentEntry(abc.BaseTournament, abc.Serializable):
"""Represents an tournament in the archived tournaments list.
:py:attr:`start_date` and :py:attr:`end_date` might be :obj:`None` when a tournament that is currently running
is on the list (e.g. on the leaderboards tournament selection section).
.. versionadded:: 2.5.0
Attributes
----------
title: :class:`str`
The title of the tournament.
cycle: :class:`int`
An internal number used to get direct access to a specific tournament in the archive.
start_date: :class:`datetime.date`
The start date of the tournament.
end_date: :class:`datetime.date`
The end date of the tournament.
"""
__slots__ = (
"title",
"cycle",
"start_date",
"end_date",
)
_serializable_properties = ("duration",)
def __init__(self, title, start_date, end_date, **kwargs):
self.title: str = title
self.start_date: datetime.date = start_date
self.end_date: datetime.date = end_date
self.cycle: int = kwargs.get("cycle", 0)
def __repr__(self):
return "<{0.__class__.__name__} title={0.title!r} cycle={0.cycle} start_date={0.start_date!r} " \
"end_date={0.end_date!r}>".format(self)
@property
def duration(self):
""":class:`datetime.timedelta`: The total duration of the tournament."""
if self.start_date and self.end_date:
return self.end_date - self.start_date
return None
[docs]class RewardEntry(abc.Serializable):
"""Represents the rewards for a specific rank range.
Attributes
----------
initial_rank: :class:`int`
The highest rank that gets this reward.
last_rank: :class:`int`
The lowest rank that gets this reward.
tibia_coins: :class`int`
The amount of tibia coins awarded.
tournament_coins: :class:`int`
The amount of tournament coins awarded.
tournament_ticket_voucher: :class:`int`
The amount of tournament ticker vouchers awarded.
cup: :class:`str`
The type of cup awarded.
deed: :class:`str`
The type of deed awarded.
other_rewards: :class:`str`
Other rewards given for this rank.
"""
__slots__ = (
"initial_rank",
"last_rank",
"tibia_coins",
"tournament_coins",
"tournament_ticker_voucher",
"cup",
"deed",
"other_rewards",
)
def __init__(self, **kwargs):
self.initial_rank = kwargs.get("initial_rank", 0)
self.last_rank = kwargs.get("last_rank", 0)
self.tibia_coins = kwargs.get("tibia_coins", 0)
self.tournament_coins = kwargs.get("tournament_coins", 0)
self.tournament_ticker_voucher = kwargs.get("tournament_ticker_voucher", 0)
self.cup = kwargs.get("cup")
self.deed = kwargs.get("deed")
self.other_rewards = kwargs.get("other_rewards")
def __repr__(self):
attributes = ""
for attr in self.__slots__:
v = getattr(self, attr)
attributes += f" {attr}={v!r}"
return f"<{self.__class__.__name__}{attributes}>"
[docs]class RuleSet(abc.Serializable):
"""Contains the tournament rule set.
Attributes
----------
pvp_type: :class:`PvPType`
The PvP type of the tournament.
daily_tournament_playtime: :class:`datetime.timedelta`
The maximum amount of time participants can play each day.
total_tournament_playtime: :class:`datetime.timedelta`
The total amount of time participants can play in the tournament.
playtime_reduced_only_in_combat: :class:`bool`
Whether playtime will only be reduced while in combat or not.
death_penalty_modifier: :class:`float`
The modifier for the death penalty.
xp_multiplier: :class:`float`
The multiplier for experience gained.
skill_multiplier: :class:`float`
The multiplier for skill gained.
spawn_rate_multiplier: :class:`float`
The multiplier for the spawn rate.
loot_probability: :class:`float`
The multiplier for the loot rate.
rent_percentage: :class:`int`
The percentage of rent prices relative to the regular price.
house_auction_durations: :class:`int`
The duration of house auctions.
shared_xp_bonus: :class:`bool`
Whether there is a bonus for sharing experience or not.
"""
__slots__ = (
"pvp_type",
"daily_tournament_playtime",
"total_tournament_playtime",
"playtime_reduced_only_in_combat",
"death_penalty_modifier",
"xp_multiplier",
"skill_multiplier",
"spawn_rate_multiplier",
"loot_probability",
"rent_percentage",
"house_auction_durations",
"shared_xp_bonus",
)
def __init__(self, **kwargs):
self.pvp_type = try_enum(PvpType, kwargs.get("pvp_type"))
self.daily_tournament_playtime = self._try_parse_interval(kwargs.get("daily_tournament_playtime"))
self.total_tournament_playtime = self._try_parse_interval(kwargs.get("total_tournament_playtime"))
self.playtime_reduced_only_in_combat = kwargs.get("playtime_reduced_only_in_combat")
self.death_penalty_modifier = kwargs.get("death_penalty_modifier")
self.xp_multiplier = kwargs.get("xp_multiplier")
self.skill_multiplier = kwargs.get("skill_multiplier")
self.spawn_rate_multiplier = kwargs.get("spawn_rate_multiplier")
self.loot_probability = kwargs.get("loot_probability")
self.rent_percentage = kwargs.get("rent_percentage")
self.house_auction_durations = kwargs.get("house_auction_durations")
self.shared_xp_bonus = kwargs.get("shared_xp_bonus")
def __repr__(self):
attributes = ""
for attr in self.__slots__:
v = getattr(self, attr)
attributes += f" {attr}={v!r}"
return f"<{self.__class__.__name__}{attributes}>"
@staticmethod
def _try_parse_interval(interval):
if interval is None:
return None
if isinstance(interval, datetime.timedelta):
return interval
try:
t = datetime.datetime.strptime(interval, "%H:%M:%S")
return datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second)
except ValueError:
return None
[docs]class ScoreSet(abc.Serializable):
"""Represents the ways to earn or lose points in the tournament.
.. versionadded:: 2.5.0
Attributes
----------
creature_kills: :class:`dict`
Points received for participating in creature kills.
level_gain_loss: :class:`int`
The points gained for leveling up or lost for losing a level.
skill_gain_loss: :class:`int`
The points gained for leveling up or lost for losing a skill level.
charm_point_multiplier: :class:`int`
The multiplier for every charm point.
character_death: :class:`int`
The points lost for dying.
area_discovery: :class:`int`
Points that will be added to the score for discovering an area entirely.
"""
__slots__ = (
"creature_kills",
"level_gain_loss",
"skill_gain_loss",
"charm_point_multiplier",
"character_death",
"area_discovery",
)
def __init__(self, **kwargs):
self.creature_kills = kwargs.get("creature_kills", {})
self.level_gain_loss = kwargs.get("level_gain_loss", 0)
self.skill_gain_loss = kwargs.get("skill_gain_loss", 0)
self.charm_point_multiplier = kwargs.get("charm_point_multiplier", 0)
self.character_death = kwargs.get("character_death", 0)
self.area_discovery = kwargs.get("area_discovery", 0)
def __repr__(self):
attributes = ""
for attr in self.__slots__:
v = getattr(self, attr)
attributes += f" {attr}={v!r}"
return f"<{self.__class__.__name__}{attributes}>"
[docs]class Tournament(abc.BaseTournament, abc.Serializable):
"""Represents a tournament's information.
.. versionadded:: 2.5.0
Attributes
----------
title: :class:`str`
The title of the tournament.
cycle: :class:`int`
An internal number used to get direct access to a specific tournament in the archive.
This will only be present when viewing an archived tournament, otherwise it will default to 0.
phase: :class:`TournamentPhase`
The current phase of the tournament.
start_date: :class:`datetime.datetime`
The start date of the tournament.
end_date: :class:`datetime.datetime`
The end date of the tournament.
worlds: :obj:`list` of :class:`str`
The worlds where this tournament is active on.
rule_set: :class:`RuleSet`
The specific rules for this tournament.
score_set: :class:`ScoreSet`
The ways to gain points in the tournament.
reward_set: :obj:`list` of :class:`RewardEntry`
The list of rewards awarded for the specified ranges.
archived_tournaments: :obj:`list` of :class:`TournamentEntry`
The list of other archived tournaments. This is only present when viewing an archived tournament.
"""
__slots__ = (
"phase",
"start_date",
"end_date",
"worlds",
"rule_set",
"score_set",
"reward_set",
"archived_tournaments",
)
_serializable_properties = (
"duration",
"rewards_range",
)
def __init__(self, **kwargs):
self.title = kwargs.get("title")
self.cycle = kwargs.get("cycle", 0)
self.phase = try_enum(TournamentPhase, kwargs.get("phase"))
self.start_date: datetime.datetime = kwargs.get("start_date")
self.end_date: datetime.datetime = kwargs.get("end_date")
self.worlds: List[str] = kwargs.get("worlds")
self.rule_set: RuleSet = kwargs.get("rule_set")
self.score_set: ScoreSet = kwargs.get("score_set")
self.reward_set: List[RewardEntry] = kwargs.get("reward_set", [])
self.archived_tournaments: List[TournamentEntry] = kwargs.get("archived_tournaments", [])
def __repr__(self):
return ("<{0.__class__.__name__} title={0.title!r} phase={0.phase!r} start_date={0.start_date!r} "
"end_date={0.start_date!r}>").format(self)
@property
def rewards_range(self):
""":class:`tuple`:The range of ranks that might receive rewards."""
return (self.reward_set[0].initial_rank, self.reward_set[-1].last_rank) if self.reward_set else (0, 0)
@property
def duration(self):
""":class:`datetime.timedelta`: The total duration of the tournament."""
return self.end_date - self.start_date
[docs] def rewards_for_rank(self, rank):
"""Get the rewards for a given rank, if any.
Parameters
----------
rank: :class:`int`
The rank to check.
Returns
-------
:class:`RewardEntry`, optional:
The rewards for the given rank or None if there are no rewards.
"""
for rewards in self.reward_set:
if rewards.initial_rank <= rank <= rewards.last_rank:
return rewards
return None
[docs] @classmethod
def from_content(cls, content):
"""Create an instance of the class from the html content of the tournament's page.
Parameters
----------
content: :class:`str`
The HTML content of the page.
Returns
-------
:class:`Tournament`
The tournament contained in the page, or None if the tournament doesn't exist.
Raises
------
InvalidContent
If content is not the HTML of a tournament's page.
"""
try:
if "An internal error has occurred" in content:
return None
if "Currently there is no Tournament running." in content:
return None
parsed_content = parse_tibiacom_content(content, builder='html5lib')
box_content = parsed_content.find("div", attrs={"class": "BoxContent"})
tables = box_content.find_all('table', attrs={"class": "Table5"})
archive_table = box_content.find('table', attrs={"class": "Table4"})
tournament_details_table = tables[-1]
info_tables = tournament_details_table.find_all('table', attrs={'class': 'TableContent'})
main_info = info_tables[0]
rule_set = info_tables[1]
score_set = info_tables[2]
reward_set = info_tables[3]
tournament = cls()
tournament._parse_tournament_info(main_info)
tournament._parse_tournament_rules(rule_set)
tournament._parse_tournament_scores(score_set)
tournament._parse_tournament_rewards(reward_set)
if archive_table:
tournament._parse_archive_list(archive_table)
return tournament
except IndexError as e:
raise InvalidContent("content does not belong to the Tibia.com's tournament section", e)
def _parse_tournament_info(self, table):
"""Parse the tournament info table.
Parameters
----------
table: :class:`bs4.BeautifulSoup`
The parsed table containing the tournament's information.
"""
rows = table.find_all('tr')
date_fields = ("start_date", "end_date")
list_fields = ("worlds",)
for row in rows:
cols_raw = row.find_all('td')
cols = [ele.text.strip() for ele in cols_raw]
field, value = cols
field = field.replace("\xa0", "_").replace(" ", "_").replace(":", "").lower()
value = value.replace("\xa0", " ")
if field in date_fields:
value = parse_tibia_datetime(value)
if field in list_fields:
value = split_list(value, ",", ",")
if field == "phase":
value = try_enum(TournamentPhase, value)
try:
setattr(self, field, value)
except AttributeError:
pass
def _parse_tournament_rules(self, table):
"""Parse the tournament rules table.
Parameters
----------
table: :class:`bs4.BeautifulSoup`
The table containing the tournament rule set.
"""
rows = table.find_all('tr')
bool_fields = ("playtime_reduced_only_in_combat", "shared_xp_bonus")
float_fields = (
"death_penalty_modifier",
"xp_multiplier",
"skill_multiplier",
"spawn_rate_multiplier",
"loot_probability",
)
int_fields = ("rent_percentage", "house_auction_durations")
rules = {}
for row in rows[1:]:
cols_raw = row.find_all('td')
cols = [ele.text.strip() for ele in cols_raw]
field, value, *_ = cols
field = field.replace("\xa0", "_").replace(" ", "_").replace(":", "").lower()
value = value.replace("\xa0", " ")
if field in bool_fields:
value = value.lower() == "yes"
if field in float_fields:
value = float(value.replace("x", ""))
if field in int_fields:
value = int(value.replace("%", ""))
rules[field] = value
self.rule_set = RuleSet(**rules)
def _parse_tournament_scores(self, table):
"""Parse the tournament scores table.
Parameters
----------
table: :class:`bs4.BeautifulSoup`
The parsed table containing the tournament score set.
"""
creatures = {}
rows = table.find_all('tr')
rules = {}
for row in rows[1:]:
cols_raw = row.find_all('td')
cols = [ele.text.strip() for ele in cols_raw]
field, value, *_ = cols
icon = cols_raw[2].find("span")
field = field.replace("\xa0", "_").replace(" ", "_").replace(":", "").replace("/", "_").lower()
value = re.sub(r'[^-0-9]', '', value.replace("+/-", ""))
if not icon:
creatures[field.replace("_", " ")] = int(value)
else:
rules[field] = parse_integer(value)
if "creature_kills" in rules:
rules["creature_kills"] = creatures
self.score_set = ScoreSet(**rules)
def _parse_tournament_rewards(self, table):
"""Parse the reward section of the tournament information section.
Parameters
----------
table: :class:`bs4.BeautifulSoup`
The parsed table containing the information.
"""
rows = table.find_all('tr')
rewards = []
for row in rows[1:]:
cols_raw = row.find_all('td')
rank_row, *rewards_cols = cols_raw
rank_text = rank_row.text
if not rank_text:
break
first, last = self._parse_rank_range(rank_text)
entry = RewardEntry(initial_rank=first, last_rank=last)
for col in rewards_cols:
self._parse_rewards_column(col, entry)
rewards.append(entry)
self.reward_set = rewards
@classmethod
def _parse_rewards_column(cls, column, entry):
"""Parse a column from the tournament's reward section.
Parameters
----------
column: :class:`bs4.BeautifulSoup`
The parsed content of the column.
entry: :class:`RewardEntry`
The reward entry where the data will be stored to.
"""
col_str = str(column)
img = column.find('img')
if img and "tibiacoin" in img["src"]:
entry.tibia_coins = parse_integer(column.text)
if img and "tournamentcoin" in img["src"]:
entry.tournament_coins = parse_integer(column.text)
if img and "tournamentvoucher" in img["src"]:
entry.tournament_ticker_voucher = parse_integer(column.text)
if img and "trophy" in img["src"]:
m = CUP_PATTERN.search(col_str)
if m:
entry.cup = m.group(1)
m = DEED_PATTERN.search(col_str)
if m:
entry.deed = m.group(1)
if img and "reward" in img["src"]:
span = column.find('span', attrs={"class": "HelperDivIndicator"})
mouse_over = span["onmouseover"]
title, popup = parse_popup(mouse_over)
label = popup.find('div', attrs={'class': 'ItemOverLabel'})
entry.other_rewards = label.text.strip()
@staticmethod
def _parse_rank_range(rank_text):
"""Parse the rank range text from the reward set table.
Parameters
----------
rank_text: :class:`str`
The string describing the ranks.
Returns
-------
:class:`tuple` of :class:`int`
A tuple containing the highest and lower rank for this reward bracket.
If the reward is for a single rank, both tuple elements will be the same.
"""
m = RANGE_PATTERN.search(rank_text)
first = int(m.group(1))
last = first
if m.group(2):
last = int(m.group(2))
return first, last
def _parse_archive_list(self, archive_table):
"""Parse the archive list table.
This table is only visible when viewing a tournament from the archive.
Parameters
----------
archive_table: :class:`bs4.Tag`
The parsed element containing the table.
"""
_, *options = archive_table.find_all("option")
self.archived_tournaments = []
for option in options:
m = ARCHIVE_LIST_PATTERN.match(option.text)
if not m:
continue
title = m.group(1).strip()
start_date = parse_tibia_full_date(m.group(2))
end_date = parse_tibia_full_date(m.group(3))
value = int(option["value"])
if title == self.title:
self.cycle = value
self.archived_tournaments.append(TournamentEntry(title=title, start_date=start_date, end_date=end_date,
cycle=value))
[docs]class TournamentLeaderboard(abc.Serializable):
"""Represents a tournament's leaderboards.
.. versionadded:: 2.5.0
Attributes
----------
world: :class:`str`
The world this leaderboard belongs to.
tournament: :class:`TournamentEntry`
The tournament this leaderboard belongs to.
entries: :obj:`list` of :class:`TournamentLeaderboardEntry`
The leaderboard entries.
results_count: :class:`int`
The total number of leaderboard entries. These might be in a different page.
"""
ENTRIES_PER_PAGE = 100
__slots__ = (
"world",
"tournament",
"entries",
"results_count",
)
_serializable_properties = (
"page",
"total_pages",
)
def __init__(self, **kwargs):
self.world: str = kwargs.get("world")
self.tournament: TournamentEntry = kwargs.get("tournament")
self.entries: List[TournamentLeaderboardEntry] = kwargs.get("entries", [])
self.results_count = kwargs.get("results_count", 0)
def __repr__(self):
return f"<{self.__class__.__name__} world={self.world!r} tournament={self.tournament} " \
f"results_count={self.results_count}>"
@property
def from_rank(self):
""":class:`int`: The starting rank of the provided entries."""
return self.entries[0].rank if self.entries else 0
@property
def to_rank(self):
""":class:`int`: The last rank of the provided entries."""
return self.entries[-1].rank if self.entries else 0
@property
def page(self):
""":class:`int`: The page number the shown results correspond to on Tibia.com."""
return int(math.floor(self.from_rank / self.ENTRIES_PER_PAGE)) + 1 if self.from_rank else 0
@property
def total_pages(self):
""":class:`int`: The total of pages in the leaderboard."""
return int(math.ceil(self.results_count / self.ENTRIES_PER_PAGE))
@property
def url(self):
""":class:`str`: Get the URL to the current leaderboard and page."""
return self.get_url(self.world, self.tournament.cycle, self.page)
[docs] @classmethod
def get_url(cls, world, tournament_cycle, page=1):
"""Get the URL to the leaderboards of a specific world, tournament and page.
Parameters
----------
world: :class:`str`
The world to get the leaderboards for.
tournament_cycle: :class:`int`
The cycle of the tournament to get the leaderboards for.
page: :class:`int`
The leader board's page to view. By default 1.
Returns
-------
The URL to the specified leaderboard.
"""
return get_tibia_url("community", "tournamentleaderboards", tournamentworld=world,
tournamentcycle=tournament_cycle, selectedleaderboardpage=page)
[docs] @classmethod
def from_content(cls, content):
"""Create an instance of the class from the html content of the tournament's leaderboards page.
Parameters
----------
content: :class:`str`
The HTML content of the page.
Returns
-------
:class:`TournamentLeaderboard`
The tournament contained in the page, or None if the tournament leaderboard doesn't exist.
Raises
------
InvalidContent
If content is not the HTML of a tournament's leaderboard page.
"""
try:
parsed_content = parse_tibiacom_content(content)
tables = parsed_content.find_all('div', attrs={'class': 'TableContainer'})
if not tables:
raise InvalidContent("content does not belong to the Tibia.com's tournament leaderboards section")
selector_table = tables[0]
leaderboard = cls()
result = leaderboard._parse_leaderboard_selectors(selector_table)
if not result:
return None
ranking_table = tables[1]
leaderboard._parse_leaderboard_entries(ranking_table)
return leaderboard
except AttributeError as e:
raise InvalidContent("content does not belong to the Tibia.com's tournament leaderboards section", e)
def _parse_leaderboard_selectors(self, selector_table):
"""Parse the option selectors from the leaderboards to get their information.
Parameters
----------
selector_table: :class:`bs4.BeautifulSoup`
Returns
-------
:class:`bool`
Whether the selectors could be parsed or not.
"""
world_select = selector_table.find("select", attrs={"name": "tournamentworld"})
selected_world = world_select.find("option", {"selected": "selected"})
if not selected_world:
return False
self.world = selected_world.text
tournament_select = selector_table.find("select", attrs={"name": "tournamentcycle"})
selected_tournament = tournament_select.find("option", {"selected": "selected"})
tournament_text = selected_tournament.text
start_date = None
end_date = None
cycle = int(selected_tournament["value"])
if "current tournament" in tournament_text.lower():
tournament_title = CURRENT_TOURNAMENT_PATTERN.sub(r"\g<1>", tournament_text)
else:
m = ARCHIVE_LIST_PATTERN.search(tournament_text)
tournament_title = m.group(1).strip()
start_date = parse_tibia_full_date(m.group(2))
end_date = parse_tibia_full_date(m.group(3))
self.tournament = TournamentEntry(title=tournament_title, start_date=start_date, end_date=end_date,
cycle=cycle)
return True
def _parse_leaderboard_entries(self, ranking_table):
"""Parse the leaderboards' entries.
Parameters
----------
ranking_table: :class:`bs4.BeautifulSoup`
The table containing the rankings.
"""
ranking_table_content = ranking_table.find("table", attrs={"class": "TableContent"})
header, *rows = ranking_table_content.find_all('tr')
entries = []
for row in rows:
raw_columns = row.find_all("td")
if len(raw_columns) != 4:
break
cols = [c.text.strip() for c in raw_columns]
rank_and_change, character, vocation, score = cols
m = RANK_PATTERN.search(rank_and_change)
rank = int(m.group(1))
change = int(m.group(2))
voc = try_enum(Vocation, vocation)
score = parse_integer(score, 0)
entries.append(TournamentLeaderboardEntry(rank=rank, change=change, name=character, vocation=voc, score=score))
# Results footer
small = ranking_table.find("small")
if small:
pagination_text = small.text
results_str = RESULTS_PATTERN.search(pagination_text)
self.results_count = int(results_str.group(1))
self.entries = entries