Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/dusted/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
}
}
)
Expand All @@ -53,18 +56,22 @@ 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):
if value:
return "true"
else:
return "false"

if isinstance(value, int):
return str(value)

return value


Expand Down
54 changes: 41 additions & 13 deletions src/dusted/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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],
Expand All @@ -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()

Expand Down Expand Up @@ -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."""

Expand Down
166 changes: 166 additions & 0 deletions src/dusted/publish_replay.py
Original file line number Diff line number Diff line change
@@ -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("<Return>", 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()
65 changes: 65 additions & 0 deletions tests/test_publish_replay.py
Original file line number Diff line number Diff line change
@@ -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,
)