Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add fuzzy mods utility method #164

Merged
merged 15 commits into from
Jan 23, 2021
21 changes: 14 additions & 7 deletions STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ def add(a, b):
"""
Adds `a` and `b`.

Arguments:
Parameters
----------
a: int
The first number to add.
b: int
The second number to add.

Returns:
Returns
-------
int
The sum of `a` and `b`.
"""
Expand All @@ -34,7 +36,8 @@ def add(a, b):
"""
Adds ``a`` and ``b``.

Arguments:
Parameters
----------
a: int
The first number to add.
b: int
Expand All @@ -58,13 +61,15 @@ def add(a, b):
"""
Adds ``a`` and ``b``.

Arguments:
Parameters
----------
a : int
The first number to add.
b : int
The second number to add.

Returns:
Returns
-------
int
The sum of ``a`` and ``b``.
"""
Expand All @@ -78,13 +83,15 @@ def add(a, b):
"""
Adds ``a`` and ``b``.

Arguments:
Parameters
----------
a: int
The first number to add.
b: int
The second number to add.

Returns:
Returns
-------
int
The sum of ``a`` and ``b``.
"""
Expand Down
3 changes: 2 additions & 1 deletion circleguard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from circleguard.investigator import Snap, Hit
from circleguard.span import Span
from circleguard.utils import (convert_statistic, order, Key,
RatelimitWeight, TRACE, ColoredFormatter, replay_pairs)
RatelimitWeight, TRACE, ColoredFormatter, replay_pairs, fuzzy_mods)
from circleguard.game_version import GameVersion, NoGameVersion
from circleguard.hitobjects import Hitobject, Circle, Slider, Spinner

Expand All @@ -39,6 +39,7 @@
"Mod",
# utils
"convert_statistic", "order", "Key", "RatelimitWeight", "TRACE", "replay_pairs",
"fuzzy_mods",
# loader
"Loader", "ReplayInfo",
# exceptions
Expand Down
2 changes: 1 addition & 1 deletion circleguard/loadables.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,7 @@ class ReplayMap(Replay):
The id of the map the replay was played on.
user_id: int
The id of the player who played the replay.
mods: ModCombination
mods: :class:`~circleguard.mod.ModCombination`
The mods the replay was played with. If ``None``, the
highest scoring replay of ``user_id`` on ``map_id`` will be loaded,
regardless of mod combination. Otherwise, the replay with ``mods``
Expand Down
140 changes: 75 additions & 65 deletions circleguard/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,69 @@
1 << 30 : ["MR", "Mirror"]
}


class ModCombination():
"""
An ingame osu! mod, or combination of mods.
An osu! mod combination.

Notes
-----
This class is not meant to be instantiated. Use :class:`~.Mod` and combine
them as necessary instead.

A full list of mods and their specification can be found at
https://osu.ppy.sh/help/wiki/Game_Modifiers.
This class only exists to allow ``Mod`` to have ``ModCombination`` objects
as class attributes, as you can't instantiate instances of your own class in
a class definition.
"""

def __init__(self, value):
self.value = value

@staticmethod
def _parse_mod_string(mod_string):
"""
Creates an integer representation of a mod string made up of two letter
mod names ("HDHR", for example).

Parameters
----------
mod_string: str
The mod string to represent as an int.

Returns
-------
int
The integer representation of the mod string.

Raises
------
ValueError
If mod_string is empty, not of even length, or any of its 2-length
substrings do not correspond to a Mod in Mod.ORDER.
"""
if mod_string == "":
raise ValueError("Invalid mod string (cannot be empty)")
if len(mod_string) % 2 != 0:
raise ValueError(f"Invalid mod string {mod_string} (not of even "
"length)")
mod_value = 0
for i in range(0, len(mod_string) - 1, 2):
single_mod = mod_string[i: i + 2]
# there better only be one Mod that has an acronym matching ours,
# but a comp + 0 index works too
matching_mods = [mod for mod in Mod.ORDER if \
mod.short_name() == single_mod]
# ``mod.ORDER`` uses ``_NC`` and ``_PF``, and we want to parse
# eg "NC" as "DTNC"
if Mod._NC in matching_mods:
matching_mods.remove(Mod._NC)
matching_mods.append(Mod.NC)
if Mod._PF in matching_mods:
matching_mods.remove(Mod._PF)
matching_mods.append(Mod.PF)
if not matching_mods:
raise ValueError("Invalid mod string (no matching mod found "
f"for {single_mod})")
mod_value += matching_mods[0].value
return mod_value

def short_name(self):
"""
The acronym-ized names of the component mods.
Expand All @@ -68,8 +115,8 @@ def short_name(self):
Notes
-----
This is a function instead of an attribute set at initialization time
because otherwise we couldn't refer to :class:`~.Mod`\s as its class
body isn't loaded while it's instantiating :class:`~.ModCombination`\s.
because otherwise we couldn't refer to a :class:`~.Mod`\s as its class
body isn't loaded while it's instantiating :class:`~.Mod`\s.

Although technically mods such as NC are represented with two bits -
DT and NC - being set, short_name removes DT and so returns "NC"
Expand Down Expand Up @@ -103,7 +150,7 @@ def long_name(self):
-----
This is a function instead of an attribute set at initialization time
because otherwise we couldn't refer to :class:`~.Mod`\s as its class
body isn't loaded while it's instantiating :class:`~.ModCombination`\s.
body isn't loaded while it's instantiating :class:`~.Mod`\s.

Although technically mods such as NC are represented with two bits -
DT and NC - being set, long_name removes DT and so returns "Nightcore"
Expand Down Expand Up @@ -146,8 +193,8 @@ def decompose(self, clean=False):
Decomposes this mod into its base component mods, which are
:class:`~.ModCombination`\s with a ``value`` of a power of two.

Arguments
---------
Parameters
----------
clean: bool
If true, removes mods that we would think of as duplicate - if both
NC and DT are component mods, remove DT. If both PF and SD are
Expand All @@ -157,11 +204,11 @@ def decompose(self, clean=False):
-------
list[:class:`~.ModCombination`]
A list of the component :class:`~.ModCombination`\s of this mod,
ordered according to :const:`~circleguard.mod.Mod.ORDER`.
ordered according to :const:`~circleguard.mod.ModCombination.ORDER`.
"""

mods = [ModCombination(mod) for mod in int_to_mod.keys() if \
self.value & mod]
mods = [ModCombination(mod) for mod in int_to_mod.keys() if
self.value & mod]
# order the mods by Mod.ORDER
mods = [mod for mod in Mod.ORDER if mod in mods]
if not clean:
Expand All @@ -173,12 +220,24 @@ def decompose(self, clean=False):
mods.remove(Mod.SD)
return mods


class Mod(ModCombination):
"""
An ingame osu! mod.

Common combinations are available as ``HDDT``, ``HDHR``, and ``HDDTHR``.

Parameters
----------
value: int or str
A representation of the desired mod. This can either be its integer
representation such as ``64`` for ``DT`` and ``72`` (``64`` + ``8``) for
``HDDT``, or a string such as ``"DT"`` for ``DT`` and ``"HDDT"`` (or
``DTHD``) for ``HDDT``.
|br|
If used, the string must be composed of two-letter acronyms for mods,
in any order.

Notes
-----
The nightcore mod is never set by itself. When we see plays set with ``NC``,
Expand Down Expand Up @@ -256,55 +315,6 @@ class Mod(ModCombination):
FI, RD, CN, TP, K1, K2, K3, K4, K5, K6, K7, K8, K9, CO, MR]

def __init__(self, value):
if isinstance(value, int):
super().__init__(value)
if isinstance(value, str):
super().__init__(Mod._parse_mod_string(value))

@staticmethod
def _parse_mod_string(mod_string):
"""
Creates an integer representation of a mod string made up of two letter
mod names ("HDHR", for example).

Arguments
---------
mod_string: str
The mod string to represent as an int.

Returns
-------
int
The integer representation of the mod string.

Raises
------
ValueError
If mod_string is empty, not of even length, or any of its 2-length
substrings do not correspond to a ModCombination in Mod.ORDER.
"""
if mod_string == "":
raise ValueError("Invalid mod string (cannot be empty)")
if len(mod_string) % 2 != 0:
raise ValueError(f"Invalid mod string {mod_string} (not of even "
"length)")
mod_value = 0
for i in range(0, len(mod_string) - 1, 2):
single_mod = mod_string[i: i + 2]
# there better only be one Mod that has an acronym matching ours,
# but a comp + 0 index works too
matching_mods = [mod for mod in Mod.ORDER if \
mod.short_name() == single_mod]
# ``mod.ORDER`` uses ``_NC`` and ``_PF``, and we want to parse
# eg "NC" as "DTNC"
if Mod._NC in matching_mods:
matching_mods.remove(Mod._NC)
matching_mods.append(Mod.NC)
if Mod._PF in matching_mods:
matching_mods.remove(Mod._PF)
matching_mods.append(Mod.PF)
if not matching_mods:
raise ValueError("Invalid mod string (no matching mod found "
f"for {single_mod})")
mod_value += matching_mods[0].value
return mod_value
value = ModCombination._parse_mod_string(value)
super().__init__(value)
57 changes: 54 additions & 3 deletions circleguard/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from logging import Formatter
from copy import copy
from enum import Enum, IntFlag
import itertools
from itertools import product, chain, combinations

from circleguard.mod import Mod

Expand Down Expand Up @@ -133,15 +133,66 @@ def replay_pairs(replays, replays2=None):
otherwise.
"""
if not replays2:
return itertools.combinations(replays, 2)
return itertools.product(replays, replays2)
return combinations(replays, 2)
return product(replays, replays2)


def check_param(param, options):
if not param in options:
raise ValueError(f"Expected one of {','.join(options)}. Got {param}")


def fuzzy_mods(required_mod, optional_mods):
"""
All mod combinations where each mod in ``optional_mods`` is allowed to be
present or absent.

If you don't want any mods to be required, pass ``Mod.NM`` as your
``required_mod``.

Parameters
----------
required_mod: class:`~circleguard.mod.ModCombination`
What mod to require be present for all mods.
optional_mods = [class:`~circleguard.mod.ModCombination`]
What mods are allowed, but not required, to be present.

Examples
--------
>>> fuzzy_mods(Mod.HD, [Mod.DT])
[HD, HDDT]
>>> fuzzy_mods(Mod.HD, [Mod.EZ, Mod.DT])
[HD, HDDT, HDEZ, HDDTEZ]
>>> fuzzy_mods(Mod.NM, [Mod.EZ, Mod.DT])
[NM, DT, EZ, DTEZ]
"""

all_mods = []
for mods in powerset(optional_mods):
final_mod = required_mod
for mod in mods:
final_mod = final_mod + mod
all_mods.append(final_mod)

return all_mods


def powerset(iterable):
"""
The powerset of an iterable.

Examples
--------
>>> powerset([1,2,3])
[(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]

Notes
-----
https://stackoverflow.com/a/1482316
"""
s = list(iterable)
return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1))


TRACE = 5

Expand Down
13 changes: 12 additions & 1 deletion tests/test_mod.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from tests.utils import CGTestCase

from circleguard import Mod
from circleguard import Mod, fuzzy_mods

class TestLoader(CGTestCase):
@classmethod
Expand Down Expand Up @@ -29,3 +29,14 @@ def test_mod_ordering(self):
self.assertEqual(Mod("HR").long_name(), "HardRock")
self.assertEqual(Mod("DTHR").long_name(), "DoubleTime HardRock")
self.assertEqual(Mod("HRDT").long_name(), "DoubleTime HardRock")

def test_fuzzy_mod(self):
mods = fuzzy_mods(Mod.HD, [Mod.DT, Mod.EZ])
self.assertListEqual(mods,
[Mod.HD, Mod.HDDT, Mod.HD + Mod.EZ, Mod.HD + Mod.EZ + Mod.DT])

mods = fuzzy_mods(Mod.HD, [Mod.DT])
self.assertListEqual(mods, [Mod.HD, Mod.HD + Mod.DT])

mods = fuzzy_mods(Mod.NM, [Mod.DT, Mod.EZ])
self.assertListEqual(mods, [Mod.NM, Mod.DT, Mod.EZ, Mod.DT + Mod.EZ])