diff --git a/.gitignore b/.gitignore index f80f6e28..cb6a6366 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.DS_Store .spyproject osu-ac/secret.py +circleguard/secret.py ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. @@ -268,4 +269,4 @@ __pycache__/ *.pyc # pip nonsense -src/* \ No newline at end of file +src/* diff --git a/README.md b/README.md index 61ce86f3..26889764 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,10 @@ -**PLEASE JOIN OUR DISCORD AND REPORT CHEATERS THERE. Public reporting to /r/osureport will allow for cheaters to overwrite their stolen score before staff can get to them. The link to our discord can be found below** - -https://discord.gg/wanBtNY - - -# osu!anticheat +# Circleguard This project ultimately aims to create a comprehensive, player-run anticheat. A by no means complete list of cheats includes replay stealing, relax, replay editing, and timewarp. As of the v1.0 release, we only attempt to detect the first item in that list - replay stealing. -**Disclaimer: Neither the osu!ac organization nor any of the osu!anticheat devs are associated with osu! or the official osu! staff in any way.** +**Disclaimer: Neither the Circleguard organization nor any of the circleguard devs are associated with osu! or the official osu! staff in any way.** ## Getting Started @@ -28,39 +23,38 @@ There are two ways to use the program - purely through the CLI, or through a GUI ### CLI -For the former, run the anticheat.py file with some or all of the following flags: +For the former, run the circleguard.py file with some or all of the following flags: | Flag | Usage | | --- | --- | | -h, --help | displays the messages below | | -m, --map | checks the leaderboard on the given beatmap id against each other | | -u, --user | checks only the given user against the other leaderboard replays. Must be set with -m | -| -l, --local | compare scores under the user/ directory to a beatmap leaderboard (if set with just -m), a score set by a user on a beatmap (if set with -m and -u) or other locally saved replays (default behavior) | +| -l, --local | compare scores under the replays/ directory to a beatmap leaderboard (if set with -m), a score set by a user on a beatmap (if set with -m and -u) or the other scores in the folder (default behavior) | | -t, --threshold | sets the similarity threshold to print comparisons that score under it. Defaults to 20 | | -a, --auto-threshold | sets the number of standard deviations from the average similarity the threshold will automatically be set to. Overrides -t **Note: If more than ![formula](https://latex.codecogs.com/gif.latex?\frac{1}{2}&space;-&space;\frac{1}{2}&space;\mathbf{erf}\frac{a}{\sqrt{2}}) of the input is stolen this may cause false negatives** | | -n, --number | how many replays to get from a beatmap. No effect if not set with -m. Defaults to 50. **Note: the time complexity of the comparisons scales with O(n^2)** | | -c, --cache | if set, locally caches replays so they don't have to be redownloaded when checking the same map multiple times | -| --single | compare all replays under user/ with all other replays under user/. No effect if not set with -l | | -s, --silent | if set, you will not be prompted for a visualization of comparisons under the threshold. Results will still be printed | - +| -v, --verify | Takes 3 positional arguments - map id, user1 id and user2 id. Verifies that the scores are steals of each other | #### Some Examples ```bash # compares https://osu.ppy.sh/u/1019489's replay on https://osu.ppy.sh/b/1776628 with the 49 other leaderboard replays -$ python anticheat.py -m 1776628 -u 1019489 +$ python circleguard.py -m 1776628 -u 1019489 # compares the top 57 leaderboard replays against the other top 57 replays (57 choose 2 comparisons) -$ python anticheat.py -m 1618546 -n 57 +$ python circleguard.py -m 1618546 -n 57 # compares the top 50 leaderboard replays against the other top 50 replays (50 choose 2 comparisons) and sets the threshold to be one standard deviation below the average similarity. -$ python anticheat.py -m 1618546 -n 50 -a 1.0 +$ python circleguard.py -m 1618546 -n 50 -a 1.0 # compares all replays under user/ with the top 50 scores on https://osu.ppy.sh/b/1611251 -$ python anticheat.py -l -m 1611251 +$ python circleguard.py -l -m 1611251 # compares all replays under user/ with all replays under compare/ -$ python anticheat.py -l +$ python circleguard.py -l ``` This means that if you have a replay from a player and want to see if it's stolen, you should place it in the user/ directory and run with the -l and -m flags. @@ -88,7 +82,7 @@ When you click 'run' in the gui, keep an eye on the command line you started the ## Methodology This program compares the cursor positions of two replays to determine average distance between them. Since the times rarely match up perfectly between replays, the coordinates from one replay are interpolated from its previous and next position to estimate its position at a time identical to the other replay. By doing this we force all timestamps to be identical for easy comparison, at the cost of some precision. -If run with -c (or with the appropriate option checked in the GUI), downloaded replays will be lossily compressed to roughly half their original size with [wtc compression](https://github.com/osu-anticheat/wtc-lzma-compressor). This reduces the need to wait for API ratelimits if run again. +If run with -c (or with the appropriate option checked in the GUI), downloaded replays will be lossily compressed to roughly half their original size with [wtc compression](https://github.com/circleguard/wtc-lzma-compressor) and then stored in a local databsae. This reduces the need to wait for API ratelimits if run again. ## Developement @@ -98,4 +92,4 @@ If you have feedback on the program, are interested in contributing, or just wan ## Credits -Thanks to [kszlim](https://github.com/kszlim), whose [replay parser](https://github.com/kszlim/osu-replay-parser) formed the basis of [our modified replay parser](https://github.com/osu-anticheat/osu-replay-parser). +Thanks to [kszlim](https://github.com/kszlim), whose [replay parser](https://github.com/kszlim/osu-replay-parser) formed the basis of [our modified replay parser](https://github.com/circleguard/osu-replay-parser). diff --git a/STYLE.md b/STYLE.md index 798e596f..59cdb6bd 100644 --- a/STYLE.md +++ b/STYLE.md @@ -29,7 +29,7 @@ def method(arg1, arg2): [arg type] [arg name]: [description of arg] Returns: - [Description of return value] + [Description of return value] Raises: [error name]: [description of when this error is raised] @@ -56,8 +56,8 @@ Classes documentation is a little different. Classes follow all the same guideli ```python class Comparer: """ - A class for managing a set of replay comparisons. - + A class for managing a set of replay comparisons. + Attributes: List replays1: A list of Replay instances to compare against replays2. List replays2: A list of Replay instances to be compared against. @@ -116,4 +116,4 @@ Follow general git conventions when committing: Pull Requests with messy history may be converted to a squash merge at a mantainer's discretion. -Finally, thou shalt not commit directly to master. \ No newline at end of file +Finally, thou shalt not commit directly to master. diff --git a/osu-ac/argparser.py b/circleguard/argparser.py similarity index 72% rename from osu-ac/argparser.py rename to circleguard/argparser.py index 6d21b0be..790f0c95 100644 --- a/osu-ac/argparser.py +++ b/circleguard/argparser.py @@ -7,22 +7,23 @@ argparser.add_argument("-u", "--user", dest="user_id", help="checks only the given user against the other leaderboard replays. Must be set with -m") -argparser.add_argument("-l", "--local", help=("compare scores under the user/ directory to a beatmap leaderboard (if set with -m), " - "a score set by a user on a beatmap (if set with -m and -u) or other locally " - "saved replays (default behavior)"), action="store_true") +argparser.add_argument("-l", "--local", help=("compare scores under the replays/ directory to a beatmap leaderboard (if set with -m), " + "a score set by a user on a beatmap (if set with -m and -u) or the other scores in the folder " + "(default behavior)"), action="store_true") -argparser.add_argument("-t", "--threshold", help="sets the similarity threshold to print results that score under it. Defaults to 20", type=int, default=20) +argparser.add_argument("-t", "--threshold", help="sets the similarity threshold to print results that score under it. Defaults to 20", type=int, default=18) argparser.add_argument("-a", "--auto", help="Sets the threshold to a number of standard deviations below the average similarity", type=float, dest="stddevs") -argparser.add_argument("-n", "--number", help="how many replays to get from a beatmap. No effect if not set with -m. Must be between 1 and 100 inclusive," +argparser.add_argument("-n", "--number", help="how many replays to get from a beatmap. No effect if not set with -m. Must be between 2 and 100 inclusive," "defaults to 50. NOTE: THE TIME COMPLEXITY OF THE COMPARISONS WILL SCALE WITH O(n^2).", type=int, default=50) argparser.add_argument("-c", "--cache", help="If set, locally caches replays so they don't have to be redownloaded when checking the same map multiple times.", action="store_true") -argparser.add_argument("--single", help="Compare all replays under user/ with all other replays under user/. No effect if not set with -l", - action="store_true") - argparser.add_argument("-s", "--silent", help="If set, you will not be prompted for a visualization of comparisons under the threshold", action="store_true") + +argparser.add_argument("-v", "--verify", help="Takes 3 positional arguments - map id, user1 id and user2 id. Verifies that the scores are steals of each other", nargs=3) + +argparser.add_argument("--version", help="Prints the program version", action="store_true") diff --git a/osu-ac/cacher.py b/circleguard/cacher.py similarity index 88% rename from osu-ac/cacher.py rename to circleguard/cacher.py index a58641e9..43c0b72a 100644 --- a/osu-ac/cacher.py +++ b/circleguard/cacher.py @@ -52,18 +52,22 @@ def cache(self, map_id, user_id, lzma_bytes, replay_id): else: # else just insert self.write("INSERT INTO replays VALUES(?, ?, ?, ?)", [map_id, user_id, compressed_bytes, replay_id]) - def revalidate(self, map_id, user_to_replay): + def revalidate(self, map_id, user_info): """ Re-caches a stored replay if one of the given users has overwritten their score on the given map since it was cached. Args: String map_id: The map to revalidate. - Dictionary user_to_replay: The up tp date mapping of user_id to replay_id to revalidate. + Dictionary user_info: The up to date mapping of user_id to [username, replay_id, enabled_mods] to revalidate. + Only contains information for a single map. """ result = self.cursor.execute("SELECT user_id, replay_id FROM replays WHERE map_id=?", [map_id]).fetchall() + + # filter result to only contain entries also in user_info + result = [info for info in result if info[0] in user_info.keys()] for user_id, local_replay_id in result: - online_replay_id = user_to_replay[user_id] + online_replay_id = user_info[user_id][1] if(local_replay_id != online_replay_id): # local (outdated) id does not match online (updated) id print("replay outdated, redownloading...", end="") # this **could** conceivable be the source of a logic error by Loader.replay_data returning None and the cache storing None, @@ -85,7 +89,11 @@ def check_cache(self, map_id, user_id): """ result = self.cursor.execute("SELECT replay_data FROM replays WHERE map_id=? AND user_id=?", [map_id, user_id]).fetchone() - return wtc.decompress(result[0]) if result else None + if(result): + print("Loading replay by {} from cache".format(user_id)) + return wtc.decompress(result[0]) + + return None def write(self, statement, args): """ diff --git a/osu-ac/anticheat.py b/circleguard/circleguard.py similarity index 67% rename from osu-ac/anticheat.py rename to circleguard/circleguard.py index 0d8ee723..15523e51 100644 --- a/osu-ac/anticheat.py +++ b/circleguard/circleguard.py @@ -9,6 +9,8 @@ import sys import itertools +import os +from os.path import isfile, join from argparser import argparser from draw import Draw @@ -18,45 +20,63 @@ from comparer import Comparer from investigator import Investigator from cacher import Cacher -from config import PATH_REPLAYS_USER, PATH_REPLAYS_CHECK, WHITELIST +from config import PATH_REPLAYS_STUB, WHITELIST, VERSION -class Anticheat: +class Circleguard: def __init__(self, args): """ - Initializes an Anticheat instance. + Initializes a Circleguard instance. [SimpleNamespace or argparse.Namespace] args: A namespace-like object representing how and what to compare. An example may look like `Namespace(cache=False, local=False, map_id=None, number=50, threshold=20, user_id=None)` """ + # get all replays in path to check against. Load this per circleguard instance or users moving files around while the gui is open doesn't work. + self.PATH_REPLAYS = [join(PATH_REPLAYS_STUB, f) for f in os.listdir(PATH_REPLAYS_STUB) if isfile(join(PATH_REPLAYS_STUB, f)) and f != ".DS_Store"] + self.cacher = Cacher(args.cache) self.args = args if(args.map_id): self.users_info = Loader.users_info(args.map_id, args.number) if(args.user_id and args.map_id): - user_info = Loader.user_info(args.map_id, args.user_id) - self.replays_check = [OnlineReplay.from_map(self.cacher, args.map_id, args.user_id, user_info[args.user_id][0], user_info[args.user_id][1])] + user_info = Loader.user_info(args.map_id, args.user_id)[args.user_id] # should be guaranteed to only be a single mapping of user_id to a list + self.replays_check = [OnlineReplay.from_map(self.cacher, args.map_id, args.user_id, user_info[0], user_info[1], user_info[2])] def run(self): """ Starts loading and detecting replays based on the args passed through the command line. """ - - if(self.args.local): + if(self.args.verify): + self._run_verify() + elif(self.args.local): self._run_local() elif(self.args.map_id): self._run_map() else: - print("Please set either --local (-l) or --map (-m)! ") - sys.exit(1) + print("Please set either --local (-l), --map (-m), or --verify (-v)! ") + + def _run_verify(self): + args = self.args + + map_id = self.args.verify[0] + user1_id = self.args.verify[1] + user2_id = self.args.verify[2] + + user1_info = Loader.user_info(map_id, user1_id) + user2_info = Loader.user_info(map_id, user2_id) + replay1 = OnlineReplay.from_user_info(self.cacher, map_id, user1_info) + replay2 = OnlineReplay.from_user_info(self.cacher, map_id, user2_info) + + comparer = Comparer(args.threshold, args.silent, replay1, replays2=replay2, stddevs=args.stddevs) + comparer.compare(mode="double") def _run_local(self): args = self.args - # get all local user replays (used in every --local case) - replays1 = [LocalReplay.from_path(osr_path) for osr_path in PATH_REPLAYS_USER] + # get all local replays (used in every --local case) + replays1 = [LocalReplay.from_path(osr_path) for osr_path in self.PATH_REPLAYS] threshold = args.threshold stddevs = args.stddevs @@ -72,18 +92,9 @@ def _run_local(self): comparer = Comparer(threshold, args.silent, replays1, replays2=replays2, stddevs=stddevs) comparer.compare(mode="double") return - - if(args.single): - # checks every replay listed in PATH_REPLAYS_USER against every other replay there - comparer = Comparer(threshold, stddevs, args.silent, replays1) - comparer.compare(mode="single") - return else: - # checks every replay listed in PATH_REPLAYS_USER against every replay listed in PATH_REPLAYS_CHECK - replays2 = [LocalReplay.from_path(osr_path) for osr_path in PATH_REPLAYS_CHECK] - comparer = Comparer(threshold, args.silent, replays1, replays2=replays2, stddevs=stddevs) - comparer.compare(mode="double") - return + comparer = Comparer(threshold, args.silent, replays1, stddevs=stddevs) + comparer.compare(mode="single") def _run_map(self): @@ -109,5 +120,9 @@ def _run_map(self): return if __name__ == '__main__': - anticheat = Anticheat(argparser.parse_args()) - anticheat.run() + args = argparser.parse_args() + if(args.version): + print("Circleguard {}".format(VERSION)) + sys.exit(0) + circleguard = Circleguard(args) + circleguard.run() diff --git a/osu-ac/comparer.py b/circleguard/comparer.py similarity index 89% rename from osu-ac/comparer.py rename to circleguard/comparer.py index b7b8608f..a1a92dec 100644 --- a/osu-ac/comparer.py +++ b/circleguard/comparer.py @@ -5,7 +5,7 @@ from draw import Draw from replay import Replay from config import WHITELIST - +from exceptions import InvalidArgumentsException class Comparer: """ A class for managing a set of replay comparisons. @@ -42,9 +42,11 @@ def __init__(self, threshold, silent, replays1, replays2=None, stddevs=None): self.stddevs = stddevs self.silent = silent - self.replays1 = replays1 - self.replays2 = replays2 + # filter beatmaps we had no data for - see Loader.replay_data and OnlineReplay.from_map + self.replays1 = [replay for replay in replays1 if replay is not None] + if(replays2): + self.replays2 = [replay for replay in replays2 if replay is not None] def compare(self, mode): """ @@ -56,17 +58,18 @@ def compare(self, mode): String mode: One of either "double" or "single", determining how to choose which replays to compare. """ + if(not self.replays1): # if this is empty, bad things + print("No comparisons could be made. Make sure replay data is available for your args") + return + if(mode == "double"): - print("comparing first set of replays to second set of replays") iterator = itertools.product(self.replays1, self.replays2) elif (mode == "single"): - print("comparing first set of replays to itself") iterator = itertools.combinations(self.replays1, 2) else: - raise Exception("`mode` must be one of 'double' or 'single'") - - + raise InvalidArgumentsException("`mode` must be one of 'double' or 'single'") + print("Starting to compare replays") # automatically determine threshold based on standard deviations of similarities if stddevs is set if(self.stddevs): results = {} @@ -126,13 +129,13 @@ def _print_result(self, result, replay1, replay2): if(mean > self.threshold): return - # if they were both set online, we don't get dates from - first_score = None + # if they were both set locally, we don't get replay ids to compare + last_score = None if(replay1.replay_id and replay2.replay_id): - first_score = replay1.player_name if(replay1.replay_id < replay2.replay_id) else replay2.player_name + last_score = replay1.player_name if(replay1.replay_id > replay2.replay_id) else replay2.player_name print("{:.1f} similarity, {:.1f} std deviation ({} vs {}{})" - .format(mean, sigma, replay1.player_name, replay2.player_name, " - {} set first".format(first_score) if first_score else "")) + .format(mean, sigma, replay1.player_name, replay2.player_name, " - {} set later".format(last_score) if last_score else "")) if(self.silent): return diff --git a/circleguard/config.py b/circleguard/config.py new file mode 100644 index 00000000..a49aa154 --- /dev/null +++ b/circleguard/config.py @@ -0,0 +1,18 @@ +import pathlib + +from secret import API_KEY + +PATH_ROOT = pathlib.Path(__file__).parent +PATH_REPLAYS_STUB = PATH_ROOT / "replays" + +API_BASE = "https://osu.ppy.sh/api/" +API_REPLAY = API_BASE + "get_replay?k=" + API_KEY + "&m=0&b={}&u={}" +API_SCORES_ALL = API_BASE + "get_scores?k=" + API_KEY + "&m=0&b={}&limit={}" +API_SCORES_USER = API_BASE + "get_scores?k=" + API_KEY + "&m=0&b={}&u={}" + + # cookiezi, ryuk, rafis, azr8, toy, +WHITELIST = ["124493", "6304246", "2558286", "2562987", "2757689"] + +PATH_DB = PATH_ROOT / "db" / "cache.db" # /absolute/path/db/cache.db + +VERSION = "1.1" diff --git a/osu-ac/db/cache.db b/circleguard/db/cache.db similarity index 100% rename from osu-ac/db/cache.db rename to circleguard/db/cache.db diff --git a/osu-ac/draw.py b/circleguard/draw.py similarity index 99% rename from osu-ac/draw.py rename to circleguard/draw.py index d9a4f49c..46567d88 100644 --- a/osu-ac/draw.py +++ b/circleguard/draw.py @@ -68,6 +68,8 @@ def run(self): plot1 = plt.plot('x', 'y', "red", animated=True, label=self.replay1.player_name)[0] plot2 = plt.plot('', '', "blue", animated=True, label=self.replay2.player_name)[0] + fig.legend() + def init(): ax.set_xlim(0, 512) ax.set_ylim(0, 384) diff --git a/osu-ac/enums.py b/circleguard/enums.py similarity index 81% rename from osu-ac/enums.py rename to circleguard/enums.py index a777aec5..3102f296 100644 --- a/osu-ac/enums.py +++ b/circleguard/enums.py @@ -2,9 +2,10 @@ # strings taken from osu api error responses class Error(Enum): - NO_REPLAY = "Replay not available." - RATELIMITED = "Requesting too fast! Slow your operation, cap'n!" - UNKOWN = "Unkown error." + NO_REPLAY = "Replay not available." + RATELIMITED = "Requesting too fast! Slow your operation, cap'n!" + RETRIEVAL_FAILED = "Replay retrieval failed." + UNKOWN = "Unkown error." class Mod(Enum): NoMod = 0 diff --git a/circleguard/exceptions.py b/circleguard/exceptions.py new file mode 100644 index 00000000..fe79cb60 --- /dev/null +++ b/circleguard/exceptions.py @@ -0,0 +1,8 @@ +class CircleguardException(Exception): + """Base class for exceptions in the Circleguard program.""" + +class InvalidArgumentsException(CircleguardException): + """Indicates an invalid argument was passed to one of the flags.""" + +class APIException(CircleguardException): + """Indicates some error on the API's end that we were not prepared to handle.""" diff --git a/osu-ac/gui.py b/circleguard/gui.py similarity index 83% rename from osu-ac/gui.py rename to circleguard/gui.py index d8313cb0..2a44d3e5 100644 --- a/osu-ac/gui.py +++ b/circleguard/gui.py @@ -3,11 +3,11 @@ from types import SimpleNamespace import threading -from anticheat import Anticheat +from circleguard import Circleguard def run(): """ - Runs the anticheat with the options given in the gui. + Runs the circleguard with the options given in the gui. """ _map_id = map_id.get() @@ -19,22 +19,21 @@ def run(): _number = num.get() _cache = cache.get() - _single = single.get() - _silent = silent.get() + _silent = True # Visualizations do very very bad things when not called from the main thread, so when using gui, we just...force ignore them + _verify = verify.get() + def run_circleguard(): + circleguard = Circleguard(SimpleNamespace(map_id=_map_id, user_id=_user_id, local=_local, threshold=_threshold, stddevs=_stddevs, + number=_number, cache=_cache, silent=_silent, verify=_verify)) + circleguard.run() - def run_anticheat(): - anticheat = Anticheat(SimpleNamespace(map_id=_map_id, user_id=_user_id, local=_local, threshold=_threshold, stddevs=_stddevs, - number=_number, cache=_cache, single=_single, silent=_silent)) - anticheat.run() - - thread = threading.Thread(target=run_anticheat) + thread = threading.Thread(target=run_circleguard) thread.start() # Root and Frames configuration root = Tk() -root.title("Osu Anticheat") +root.title("Circleguard") root.columnconfigure(0, weight=1) root.rowconfigure(0, weight=1) # houses user input boxes and run button @@ -56,8 +55,7 @@ def run_anticheat(): cache = tkinter.BooleanVar(value=False) # unimplemented -single = tkinter.BooleanVar(value=False) -silent = tkinter.BooleanVar(value=False) +verify = tkinter.BooleanVar(value=False) # Make visual elements for main frame map_label = ttk.Label(main, text="Map id:") @@ -98,7 +96,7 @@ def run_anticheat(): top_plays_label1.grid(row=0, column=1) top_plays_entry = ttk.Entry(top_x_plays, width=4, textvariable=num) top_plays_entry.grid(row=0, column=2) -top_plays_label2 = ttk.Label(top_x_plays, text="leaderboard plays?\n(Between 1 and 100 inclusive)") +top_plays_label2 = ttk.Label(top_x_plays, text="leaderboard plays?\n(Between 2 and 100 inclusive)") top_plays_label2.grid(row=0, column=3) auto_threshold = ttk.Frame(options) diff --git a/osu-ac/investigator.py b/circleguard/investigator.py similarity index 100% rename from osu-ac/investigator.py rename to circleguard/investigator.py diff --git a/osu-ac/loader.py b/circleguard/loader.py similarity index 73% rename from osu-ac/loader.py rename to circleguard/loader.py index 514a8670..3510450b 100644 --- a/osu-ac/loader.py +++ b/circleguard/loader.py @@ -5,7 +5,7 @@ from enums import Error from config import API_SCORES_ALL, API_SCORES_USER, API_REPLAY - +from exceptions import CircleguardException, InvalidArgumentsException, APIException def api(function): """ @@ -43,36 +43,37 @@ def __init__(self): This class should never be instantiated. All methods are static. """ - raise Exception("This class is not meant to be instantiated. Use the static methods instead.") + raise CircleguardException("This class is not meant to be instantiated. Use the static methods instead.") @staticmethod @api def users_info(map_id, num=50): """ - Returns a dict mapping the user_id to a list containing their replay_id and the enabled mods for the top given number of replays. + Returns a dict mapping each user_id to a list containing [username, replay_id, enabled mods] + for the top given number of replays on the given map. - EX: {"1234567": ["295871732", "15"]} # numbers may not be accurate to true mod bits or user ids + EX: {"1234567": ["tybug", "295871732", 15]} # numbers may not be accurate to true mod bits or user ids Args: String map_id: The map id to get a list of users from. Integer num: The number of ids to fetch. Defaults to 50. """ - if(num > 100 or num < 1): - raise Exception("The number of top plays to fetch must be between 1 and 100 inclusive!") + if(num > 100 or num < 2): + raise InvalidArgumentsException("The number of top plays to fetch must be between 2 and 100 inclusive!") response = requests.get(API_SCORES_ALL.format(map_id, num)).json() if(Loader.check_response(response)): Loader.enforce_ratelimit() return Loader.users_info(map_id, num=num) - info = {x["user_id"]: [x["score_id"], int(x["enabled_mods"])] for x in response} # map user id to score id and mod bit + info = {x["user_id"]: [x["username"], x["score_id"], int(x["enabled_mods"])] for x in response} # map user id to username, score id and mod bit return info @staticmethod @api def user_info(map_id, user_id): """ - Returns a dict mapping a user_id to a list containing their replay_id and the enabled mods on a given map. + Returns a dict mapping a user_id to a list containing their [username, replay_id, enabled mods] on a given map. Args: String map_id: The map id to get the replay_id from. @@ -83,7 +84,8 @@ def user_info(map_id, user_id): if(Loader.check_response(response)): Loader.enforce_ratelimit() return Loader.user_info(map_id, user_id) - info = {x["user_id"]: [x["score_id"], int(x["enabled_mods"])] for x in response} # map user id to score id and mod bit, should only be one response + info = {x["user_id"]: [x["username"], x["score_id"], int(x["enabled_mods"])] for x in response} # map user id to username, score id and mod bit, + # should only be one response return info @staticmethod @@ -100,7 +102,7 @@ def replay_data(map_id, user_id): The lzma bytes (b64 decoded response) returned by the api, or None if the replay was not available. Raises: - Exception if the api response with an error we don't know. + APIException if the api responds with an error we don't know. """ print("Requesting replay by {} on map {}".format(user_id, map_id)) @@ -108,13 +110,16 @@ def replay_data(map_id, user_id): error = Loader.check_response(response) if(error == Error.NO_REPLAY): - print("Could not find any replay data for user {} on map {}".format(user_id, map_id)) + print("Could not find any replay data for user {} on map {}, skipping".format(user_id, map_id)) + return None + elif(error == Error.RETRIEVAL_FAILED): + print("Replay retrieval failed for user {} on map {}, skipping".format(user_id, map_id)) return None elif(error == Error.RATELIMITED): Loader.enforce_ratelimit() return Loader.replay_data(map_id, user_id) elif(error == Error.UNKOWN): - raise Exception("unkown error when requesting replay by {} on map {}. Please lodge an issue with the devs immediately".format(user_id, map_id)) + raise APIException("unkown error when requesting replay by {} on map {}. Please lodge an issue with the devs immediately".format(user_id, map_id)) return base64.b64decode(response["content"]) @@ -133,10 +138,9 @@ def check_response(response): """ if("error" in response): - if(response["error"] == Error.RATELIMITED.value): - return Error.RATELIMITED - elif(response["error"] == Error.NO_REPLAY.value): - return Error.NO_REPLAY + for error in Error: + if(response["error"] == error.value): + return error else: return Error.UNKOWN else: diff --git a/osu-ac/local_replay.py b/circleguard/local_replay.py similarity index 100% rename from osu-ac/local_replay.py rename to circleguard/local_replay.py diff --git a/osu-ac/online_replay.py b/circleguard/online_replay.py similarity index 80% rename from osu-ac/online_replay.py rename to circleguard/online_replay.py index 217a8f3d..8c01db63 100644 --- a/osu-ac/online_replay.py +++ b/circleguard/online_replay.py @@ -12,7 +12,7 @@ def check_cache(function): Decorator that checks if the replay by the given user_id on the given map_id is already cached. If so, returns a Replay instance from the cached string instead of requesting it from the api. - Note that cacher, map_id, user_id, and enabled_mods must be the first, second, third, and fifth arguments to the function respectively. + Note that cacher, map_id, user_id, replay_id, and enabled_mods must be the first, second, third, fifth, and sixth arguments to the function respectively. Returns: A Replay instance from the cached replay if it was cached, or the return value of the function if not. @@ -22,11 +22,12 @@ def wrapper(*args, **kwargs): cacher = args[0] map_id = args[1] user_id = args[2] - enabled_mods = args[4] + replay_id = args[4] + enabled_mods = args[5] lzma = cacher.check_cache(map_id, user_id) if(lzma): replay_data = osrparse.parse_replay(lzma, pure_lzma=True).play_data - return Replay(replay_data, user_id, enabled_mods) + return Replay(replay_data, user_id, enabled_mods, replay_id=replay_id) else: return function(*args, **kwargs) return wrapper @@ -58,20 +59,19 @@ def from_user_info(cacher, map_id, user_info): Args: Cacher cacher: A cacher object containing a database connection. String map_id: The map_id to download the replays from. - Dictionary user_info: A dict mapping a user_id to a list containing their replay_id and the enabled mods on a given map. + Dictionary user_info: A dict mapping user_ids to a list containing [username, replay_id, enabled mods] on the given map. See Loader.users_info Returns: A list of Replay instances from the given information, with entries with no replay data available excluded. """ - replays = [OnlineReplay.from_map(cacher, map_id, user_id, replay_info[0], replay_info[1]) for user_id, replay_info in user_info.items()] - replays = [replay for replay in replays if replay is not None] # filter beatmaps we had no data for - see Loader.replay_data and OnlineReplay.from_map + replays = [OnlineReplay.from_map(cacher, map_id, user_id, replay_info[0], replay_info[1], replay_info[2]) for user_id, replay_info in user_info.items()] return replays @staticmethod @check_cache - def from_map(cacher, map_id, user_id, replay_id, enabled_mods): + def from_map(cacher, map_id, user_id, username, replay_id, enabled_mods): """ Creates a Replay instance from a replay by the given user on the given map. @@ -93,4 +93,4 @@ def from_map(cacher, map_id, user_id, replay_id, enabled_mods): parsed_replay = osrparse.parse_replay(lzma_bytes, pure_lzma=True) replay_data = parsed_replay.play_data cacher.cache(map_id, user_id, lzma_bytes, replay_id) - return OnlineReplay(replay_data, user_id, enabled_mods, replay_id) + return OnlineReplay(replay_data, username, enabled_mods, replay_id) diff --git a/osu-ac/replay.py b/circleguard/replay.py similarity index 99% rename from osu-ac/replay.py rename to circleguard/replay.py index f4e0ed0a..2b03d702 100644 --- a/osu-ac/replay.py +++ b/circleguard/replay.py @@ -74,6 +74,7 @@ def bits(n): b = n & (~n+1) yield b n ^= b + bit_values_gen = bits(enabled_mods) self.enabled_mods = frozenset(Mod(mod_val) for mod_val in bit_values_gen) diff --git a/osu-ac/replays/compare/cheater.osr b/circleguard/replays/cheater.osr similarity index 100% rename from osu-ac/replays/compare/cheater.osr rename to circleguard/replays/cheater.osr diff --git a/osu-ac/replays/compare/elnabhan.osr b/circleguard/replays/elnabhan.osr similarity index 100% rename from osu-ac/replays/compare/elnabhan.osr rename to circleguard/replays/elnabhan.osr diff --git a/osu-ac/replays/user/woey.osr b/circleguard/replays/woey.osr similarity index 100% rename from osu-ac/replays/user/woey.osr rename to circleguard/replays/woey.osr diff --git a/osu-ac/config.py b/osu-ac/config.py deleted file mode 100644 index d48919aa..00000000 --- a/osu-ac/config.py +++ /dev/null @@ -1,27 +0,0 @@ -import os -from os.path import isfile, join -import pathlib - -from secret import API_KEY - -PATH_ROOT = pathlib.Path(__file__).parent -PATH_REPLAYS = PATH_ROOT / "replays" - -# names of replays to check -PATH_REPLAYS_USER_STUB = join(PATH_REPLAYS, "user") -PATH_REPLAYS_CHECK_STUB = join(PATH_REPLAYS, "compare") # path of replays to check against - -PATH_REPLAYS_USER = [join(PATH_REPLAYS_USER_STUB, f) for f in os.listdir(PATH_REPLAYS_USER_STUB) if isfile(join(PATH_REPLAYS_USER_STUB, f)) and f != ".DS_Store"] -# get all replays in path to check against -PATH_REPLAYS_CHECK = [join(PATH_REPLAYS_CHECK_STUB, f) for f in os.listdir(PATH_REPLAYS_CHECK_STUB) if isfile(join(PATH_REPLAYS_CHECK_STUB, f)) and f != ".DS_Store"] - - -API_BASE = "https://osu.ppy.sh/api/" -API_REPLAY = API_BASE + "get_replay?k=" + API_KEY + "&m=0&b={}&u={}" -API_SCORES_ALL = API_BASE + "get_scores?k=" + API_KEY + "&m=0&b={}&limit={}" -API_SCORES_USER = API_BASE + "get_scores?k=" + API_KEY + "&m=0&b={}&u={}" - - # cookiezi, ryuk, rafis, azr8, toy, -WHITELIST = ["124493", "6304246", "2558286", "2562987", "2757689"] - -PATH_DB = PATH_ROOT / "db" / "cache.db" # /absolute/path/db/cache.db diff --git a/requirements.txt b/requirements.txt index 5ce86cce..15754c02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ numpy>=1.16.0 matplotlib>=3.0.2 requests>=2.21.0 -https://github.com/osu-anticheat/osu-replay-parser/archive/master.zip -https://github.com/osu-anticheat/wtc-lzma-compressor/archive/master.zip +https://github.com/circleguard/osu-replay-parser/archive/v3.1.0.zip +https://github.com/circleguard/wtc-lzma-compressor/archive/v1.1.1.zip