diff --git a/src/dusted/config.py b/src/dusted/config.py index d7c32a5..7d2ea9d 100644 --- a/src/dusted/config.py +++ b/src/dusted/config.py @@ -15,6 +15,7 @@ class Config: dustforce_path: str = r"C:\Program Files (x86)\Steam\steamapps\common\Dustforce" show_level: bool = True window_geometry: str = "" + dustkid_id: int | None = None @classmethod def read(cls) -> Config: @@ -27,6 +28,7 @@ def read(cls) -> Config: dustforce_path=parser.get("DEFAULT", "dustforce_path"), show_level=parser.getboolean("DEFAULT", "show_level"), window_geometry=parser.get("DEFAULT", "window_geometry"), + dustkid_id=parser.getint("DEFAULT", "dustkid_id", fallback=None), ) def write(self) -> None: @@ -38,6 +40,7 @@ def write(self) -> None: "DEFAULT": { key: self._stringify_value(value) for key, value in dataclasses.asdict(self).items() + if value is not None } } ) @@ -53,11 +56,11 @@ def _defaults(cls) -> dict[str, str]: return { field.name: cls._stringify_value(field.default) for field in dataclasses.fields(cls) - if field.default is not dataclasses.MISSING + if field.default is not dataclasses.MISSING and field.default is not None } @staticmethod - def _stringify_value(value: str | bool) -> str: + def _stringify_value(value: str | bool | int) -> str: """Convert a value into a string that the parser can deal with.""" if isinstance(value, bool): @@ -65,6 +68,10 @@ def _stringify_value(value: str | bool) -> str: return "true" else: return "false" + + if isinstance(value, int): + return str(value) + return value diff --git a/src/dusted/gui.py b/src/dusted/gui.py index b6ee2b4..ecc0ab0 100644 --- a/src/dusted/gui.py +++ b/src/dusted/gui.py @@ -21,6 +21,7 @@ from dusted.jump_to_frame import JumpToFrameDialog from dusted.level import Level from dusted.level_view import LevelView +from dusted.publish_replay import PublishReplayDialog from dusted.replay_diagnostics import ReplayDiagnostics from dusted.replay_metadata import ReplayMetadata, ReplayMetadataDialog from dusted.undo_stack import UndoStack @@ -47,7 +48,9 @@ def __init__(self): super().__init__() # Log exceptions - self.report_callback_exception = lambda *args: log.error("", exc_info=args) + self.report_callback_exception = lambda *args: log.exception( + "Uncaught exception" + ) self.level = Level("downhill") self.character = Character.DUSTMAN @@ -99,6 +102,10 @@ def __init__(self): label="Export as nexus script...", command=self.export_as_nexus_script, ) + file_menu.add_command( + label="Publish to dustkid...", + command=self.publish_to_dustkid, + ) self.edit_menu = tk.Menu(menu_bar, tearoff=0) menu_bar.add_cascade(label="Edit", underline=0, menu=self.edit_menu) @@ -279,17 +286,8 @@ def load_state_and_watch(self): if self.save_file(): dustforce.watch_replay_load_state(self.file) - def save_file(self, save_as: bool = False): - if not self.file or save_as: - self.file = tkinter.filedialog.asksaveasfilename( - defaultextension=".dfreplay", - filetypes=[("replay files", "*.dfreplay")], - title="Save replay", - ) - if not self.file: - return False - elif not self.undo_stack.is_modified: - return True + def _current_replay(self) -> Replay: + """Return a replay instance created from the current application state.""" intent_streams = { IntentStream.X: [intents.x for intents in self.inputs], @@ -301,12 +299,31 @@ def save_file(self, save_as: bool = False): IntentStream.HEAVY: [intents.heavy for intents in self.inputs], IntentStream.TAUNT: [intents.taunt for intents in self.inputs], } - replay = Replay( + return Replay( username=b"TAS", level=self.level.get().encode(), players=[PlayerData(self.character, intent_streams)], ) + def save_file(self, save_as: bool = False) -> bool: + """ + Save the current replay to a file. + + :return: True if the file was saved. + """ + + if not self.file or save_as: + self.file = tkinter.filedialog.asksaveasfilename( + defaultextension=".dfreplay", + filetypes=[("replay files", "*.dfreplay")], + title="Save replay", + ) + if not self.file: + return False + elif not self.undo_stack.is_modified: + return True + + replay = self._current_replay() utils.write_replay_to_file(self.file, replay) self.undo_stack.set_unmodified() @@ -347,6 +364,17 @@ def export_as_nexus_script(self) -> None: with open(filepath, "w", encoding="utf-8") as file: file.write(nexus_script) + def publish_to_dustkid(self) -> None: + """Publish the current replay to dustkid.""" + + if self.undo_stack.is_modified: + tkinter.messagebox.showwarning( + message="There are unsaved changes. Save or undo the changes before publishing." + ) + return + + PublishReplayDialog(self, self._current_replay()) + def jump_to_previous_diagnostic(self) -> None: """Move the cursor to the next diagnostic.""" diff --git a/src/dusted/publish_replay.py b/src/dusted/publish_replay.py new file mode 100644 index 0000000..646186e --- /dev/null +++ b/src/dusted/publish_replay.py @@ -0,0 +1,166 @@ +import io +import logging +import tkinter as tk +import tkinter.messagebox +from enum import Enum + +import requests +from dustmaker.dfwriter import DFWriter +from dustmaker.replay import Replay + +from dusted.config import config +from dusted.dialog import Dialog + +log = logging.getLogger(__name__) + + +class ValidationError(Enum): + DUSTKID_ID = "Invalid dustkid ID, must be a non-negative integer." + TIME = "Invalid time, must be an non-negative integer number of milliseconds." + COMPLETION = "Invalid completion, must be one of S, A, B, C, D, X." + FINESSE = "Invalid finesse, must be one of S, A, B, C, D, X." + + +class Score(Enum): + S = 5 + A = 4 + B = 3 + C = 2 + D = 1 + X = 0 + + +def publish_to_dustkid( + replay: Replay, + completion: Score, + finesse: Score, + time_ms: int, + dustkid_id: int, +) -> None: + """Publish a replay file to dustkid.""" + + # Strip the DF_RPL2 (username) header. + replay.username = b"" + + replay_file = io.BytesIO() + with DFWriter(replay_file) as writer: + writer.write_replay(replay) + replay_data = replay_file.getvalue() + + url = "https://dustkid.com/backend8/add_score.php" + data = { + "level": replay.level, + "character": replay.players[0].character.value, + "score1": completion.value, + "score2": finesse.value, + "time": time_ms, + "user": dustkid_id, + "replay": replay_data, + "tool": "dusted", + } + response = requests.post(url, data=data) + + response.raise_for_status() + + if response.content == b"FAIL1": + raise RuntimeError("Dustkid didn't like that") + + +class PublishReplayDialog(Dialog): + def __init__(self, parent: tk.Misc, replay: Replay) -> None: + super().__init__(parent) + self._replay = replay + + dustkid_id_label = tk.Label(self, text="Dustkid ID:") + dustkid_id_label.grid(row=0, column=0, sticky="e") + self.dustkid_id_var = tk.StringVar(self) + dustkid_id_input = tk.Entry(self, textvariable=self.dustkid_id_var) + dustkid_id_input.grid(row=0, column=1, sticky="ew") + + time_label = tk.Label(self, text="Time (in milliseconds):") + time_label.grid(row=1, column=0, sticky="e") + self.time_var = tk.StringVar(self) + time_input = tk.Entry(self, textvariable=self.time_var) + time_input.grid(row=1, column=1, sticky="ew") + + completion_label = tk.Label(self, text="Completion:") + completion_label.grid(row=2, column=0, sticky="e") + self.completion_var = tk.StringVar(self) + completion_input = tk.Entry(self, textvariable=self.completion_var) + completion_input.grid(row=2, column=1, sticky="ew") + + finesse_label = tk.Label(self, text="Finesse:") + finesse_label.grid(row=3, column=0, sticky="e") + self.finesse_var = tk.StringVar(self) + finesse_input = tk.Entry(self, textvariable=self.finesse_var) + finesse_input.grid(row=3, column=1, sticky="ew") + + button = tk.Button(self, text="Publish", command=self.publish) + button.grid(row=4, columnspan=2) + + if config.dustkid_id is not None: + self.dustkid_id_var.set(str(config.dustkid_id)) + + self.bind("", lambda e: self.publish()) + + def publish(self) -> None: + """Validate the form and publish the replay.""" + + errors = [] + + raw_dustkid_id = self.dustkid_id_var.get() + try: + dustkid_id = int(raw_dustkid_id) + except ValueError: + errors.append(ValidationError.DUSTKID_ID) + else: + if dustkid_id < 0: + errors.append(ValidationError.DUSTKID_ID) + + raw_time = self.time_var.get() + try: + time = int(raw_time) + except ValueError: + errors.append(ValidationError.TIME) + else: + if time < 0: + errors.append(ValidationError.TIME) + + raw_completion = self.completion_var.get() + try: + completion = Score[raw_completion.upper()] + except KeyError: + errors.append(ValidationError.COMPLETION) + + raw_finesse = self.finesse_var.get() + try: + finesse = Score[raw_finesse.upper()] + except KeyError: + errors.append(ValidationError.FINESSE) + + if errors: + tkinter.messagebox.showerror( + message="\n".join(error.value for error in errors) + ) + return + + try: + publish_to_dustkid( + replay=self._replay, + completion=completion, + finesse=finesse, + time_ms=time, + dustkid_id=dustkid_id, + ) + except Exception as error: + log.exception("Publishing replay failed") + tkinter.messagebox.showerror(message=f"Publishing replay failed:\n{error}") + return + + if config.dustkid_id != dustkid_id: + config.dustkid_id = dustkid_id + config.write() + + tkinter.messagebox.showinfo(message="Replay published successfully!") + + self.destroy() diff --git a/tests/test_publish_replay.py b/tests/test_publish_replay.py new file mode 100644 index 0000000..4e8ec44 --- /dev/null +++ b/tests/test_publish_replay.py @@ -0,0 +1,65 @@ +from unittest import TestCase, mock +from unittest.mock import Mock, patch + +from dustmaker.replay import Character, PlayerData, Replay +from requests import Response + +from dusted.publish_replay import Score, publish_to_dustkid + +FAILURE_RESPONSE = b"FAIL1" +SUCCESS_RESPONSE = b"DF_GSC1\x02\x00\x00\x00\n\x00\x03\x00\n\x00\x03\x00'\x00\x00\x00\x1a\x00\x00\x00\xfc\x17\x02\x00\x00\x00\x00\x00\x06\x00Noelle\x01\x00\x00\x00'D\x05\x00|\xc0\x97\x00\x01\x00\x05\x05\x910\x00\x00\x00\x00\x00\x00\x06\x00Ukkiez\x02\x00\x00\x00\xe9\xfa\x00\x00\x04I\x87\x00\x00\x00\x05\x05\xb20\x00\x00\x00\x00\x00\x00\x06\x00Xander\x03\x00\x00\x00>)\x04\x00\xe3\x98\x89\x00\x00\x00\x05\x05\xb20\x00\x00\x00\x00\x00\x00\x07\x00indapop\x04\x00\x00\x00n\x9b\x02\x00\x7f4\x92\x00\x01\x00\x05\x05\xc30\x00\x00\x00\x00\x00\x00\n\x00magmapeach\x05\x00\x00\x00?|\x02\x00W-\x93\x00\x00\x00\x05\x05'1\x00\x00\x00\x00\x00\x00\x08\x00naijamuh\x06\x00\x00\x00\xd6\xcf\x03\x00\xab5\x93\x00\x00\x00\x05\x0581\x00\x00\x00\x00\x00\x00\x05\x00plant\x07\x00\x00\x00\xee,\x05\x00\x96X\x8e\x00\x00\x00\x05\x05Y1\x00\x00\x00\x00\x00\x00\x07\x00Zaandaa\x08\x00\x00\x00\x1a8\x00\x00x\x0fz\x00\x00\x00\x05\x05j1\x00\x00\x00\x00\x00\x00\x04\x00Tess\t\x00\x00\x00x\xe7\x03\x00i\xc6\x8a\x00\x00\x00\x05\x05j1\x00\x00\x00\x00\x00\x00\t\x00Yoda Cage\n\x00\x00\x00(\xbb\x02\x003\x15\x93\x00\x00\x00\x05\x05j1\x00\x00\x00\x00\x00\x00\x03\x00TMC&\x00\x00\x00\xb5A\x02\x00\x96\xaa|\x00\x00\x00\x05\x05\n3\x00\x00\x00\x00\x00\x00\n\x00Alexspeedy'\x00\x00\x00=x\x04\x00\xf7\xe1\x96\x00\x00\x00\x05\x05\n3\x00\x00\x00\x00\x00\x00\x07\x00Skyhawk(\x00\x00\x00Z*\x01\x00zCr\x00\x00\x00\x05\x05<3\x00\x00\x00\x00\x00\x00\x06\x00Xander\x01\x00\x00\x00>)\x04\x00\xf5\x9c\x89\x00\x00\x00\x05\x02\x92-\x00\x00\x00\x00\x00\x00\x06\x00Ukkiez\x02\x00\x00\x00\xe9\xfa\x00\x00\x98\x95\x8a\x00\x00\x00\x05\x02\x92-\x00\x00\x00\x00\x00\x00\x07\x00indapop\x03\x00\x00\x00n\x9b\x02\x00J\x08\x80\x00\x00\x00\x05\x02\x18.\x00\x00\x00\x00\x00\x00\x08\x00naijamuh\x04\x00\x00\x00\xd6\xcf\x03\x00\xc4S\x97\x00\x00\x00\x05\x029.\x00\x00\x00\x00\x00\x00\x04\x00Tess\x05\x00\x00\x00x\xe7\x03\x00|\xbe\x8a\x00\x00\x00\x05\x02J.\x00\x00\x00\x00\x00\x00\x07\x00Zaandaa\x06\x00\x00\x00\x1a8\x00\x00C\xc6\x7f\x00\x00\x00\x05\x02k.\x00\x00\x00\x00\x00\x00\t\x00YodaCage\x07\x00\x00\x00(\xbb\x02\x00\x8e\xac{\x00\x00\x00\x05\x02\xbe.\x00\x00\x00\x00\x00\x00\x0f\x00hvge archie fan\x08\x00\x00\x00L\xcc\x02\x00S4\x88\x00\x01\x00\x05\x02\x01/\x00\x00\x00\x00\x00\x00\x05\x00Wazzy\t\x00\x00\x00\x8c\x83\x01\x00\x05\xa3\x8a\x00\x00\x00\x05\x02\"/\x00\x00\x00\x00\x00\x00\x0e\x00DevTwoThousand\n\x00\x00\x00\xcc\xf9\x01\x00v\x08\x8b\x00\x00\x00\x05\x023/\x00\x00\x00\x00\x00\x00\n\x00NoLifeJoel\x19\x00\x00\x00[y\x02\x00\x16=o\x00\x00\x00\x05\x02\xc30\x00\x00\x00\x00\x00\x00\n\x00Alexspeedy\x1a\x00\x00\x00=x\x04\x00\xca\x9f\x8a\x00\x00\x00\x05\x02\xc30\x00\x00\x00\x00\x00\x00\x0e\x00arbitraryasian\x1b\x00\x00\x00\xc2I\x02\x00R#k\x00\x00\x00\x05\x02\xd40\x00\x00" + + +class TestPublishReplay(TestCase): + def setUp(self) -> None: + patcher = patch("dusted.publish_replay.requests") + self.mock_requests = patcher.start() + self.addCleanup(patcher.stop) + + def make_replay(self, level: str, character: Character) -> Replay: + return Replay( + username=b"TAS", + level=level.encode(), + players=[PlayerData(character=character, intents={})], + ) + + def test_success(self): + mock_response = Mock(spec_set=Response) + mock_response.content = SUCCESS_RESPONSE + self.mock_requests.post.return_value = mock_response + + publish_to_dustkid( + replay=self.make_replay("downhill", Character.DUSTKID), + completion=Score.S, + finesse=Score.C, + time_ms=12_345, + dustkid_id=292925, + ) + + self.mock_requests.post.assert_called_once_with( + "https://dustkid.com/backend8/add_score.php", + data={ + "level": b"downhill", + "character": 2, + "score1": 5, + "score2": 2, + "time": 12_345, + "user": 292925, + "replay": mock.ANY, + "tool": "dusted", + }, + ) + + def test_failure(self): + mock_response = Mock(spec_set=Response) + mock_response.content = FAILURE_RESPONSE + self.mock_requests.post.return_value = mock_response + + with self.assertRaises(RuntimeError): + publish_to_dustkid( + replay=self.make_replay("downhill", Character.DUSTKID), + completion=Score.S, + finesse=Score.C, + time_ms=12_345, + dustkid_id=292925, + )