Add Tournaments UI #32

Merged
Typhus merged 10 commits from feature/add-tournaments-ui into main 2026-02-03 23:00:58 +00:00
7 changed files with 275 additions and 37 deletions
Showing only changes of commit 1137a9e7c7 - Show all commits

View File

@ -0,0 +1,33 @@
from typing import Optional
from rio import Component, Row, Text, TextStyle, Color
class TournamentDetailsInfoRow(Component):
key: str
value: str
color: Optional[Color] = None
def build(self) -> Component:
return Row(
Text(
text=self.key,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_bottom=0.5,
align_x=0
),
Text(
text=self.value,
style=TextStyle(
fill=self.color if self.color is not None else self.session.theme.background_color,
font_size=1
),
margin_bottom=0.5,
align_x=1
),
margin_left=4,
margin_right=4
)

View File

@ -1,16 +1,26 @@
from typing import Optional from typing import Optional, Union, Literal
from rio import Column, Component, event, TextStyle, Text, Rectangle, Row, Image, Icon, Tooltip, Spacer, Color, PointerEventListener, ProgressCircle from from_root import from_root
from rio import Column, Component, event, TextStyle, Text, Row, Image, Spacer, ProgressCircle, Button, Checkbox, ThemeContextSwitcher, Link
from src.ezgg_lan_manager import ConfigurationService, TournamentService from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ezgg_lan_manager.components.TournamentPageRow import TournamentPageRow from src.ezgg_lan_manager.components.TournamentDetailsInfoRow import TournamentDetailsInfoRow
from src.ezgg_lan_manager.types.DateUtil import weekday_to_display_text
from src.ezgg_lan_manager.types.SessionStorage import SessionStorage
from src.ezgg_lan_manager.types.Tournament import Tournament from src.ezgg_lan_manager.types.Tournament import Tournament
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus, tournament_status_to_display_text, tournament_format_to_display_texts
from src.ezgg_lan_manager.types.User import User
class TournamentDetailsPage(Component): class TournamentDetailsPage(Component):
tournament: Optional[Tournament] = None tournament: Optional[Union[Tournament, str]] = None
rules_accepted: bool = False
user: Optional[User] = None
loading: bool = False
# State for message above register button
message: str = ""
is_success: bool = False
@event.on_populate @event.on_populate
async def on_populate(self) -> None: async def on_populate(self) -> None:
@ -20,17 +30,39 @@ class TournamentDetailsPage(Component):
tournament_id = None tournament_id = None
if tournament_id is not None: if tournament_id is not None:
self.tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id) self.tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id)
if self.tournament is not None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}") await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}")
else: else:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere") await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere")
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
self.loading_done()
async def register_pressed(self) -> None:
self.loading = True
if not self.user:
return
self.is_success = True
self.message = f"Erfolgreich angemeldet!" # ToDo: Hook into Tournament Service
self.loading = False
async def unregister_pressed(self) -> None:
self.loading = True
if not self.user:
return
self.is_success = True
self.message = f"Erfolgreich abgemeldet!" # ToDo: Hook into Tournament Service
self.loading = False
def loading_done(self) -> None:
if self.tournament is None:
self.tournament = "Turnier konnte nicht gefunden werden"
self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate)
def build(self) -> Component: def build(self) -> Component:
if not self.tournament: if self.tournament is None:
return Column( content = Column(
MainViewContentBox(
Column(
Column(
ProgressCircle( ProgressCircle(
color="secondary", color="secondary",
align_x=0.5, align_x=0.5,
@ -38,26 +70,126 @@ class TournamentDetailsPage(Component):
margin_bottom=0 margin_bottom=0
), ),
min_height=10 min_height=10
),
Spacer(min_height=1)
) )
elif isinstance(self.tournament, str):
content = Row(
Text(
text=self.tournament,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
), ),
align_y = 0 margin_top=2,
margin_bottom=2,
align_x=0.5
) )
)
else:
tournament_status_color = self.session.theme.background_color
if self.tournament.status == TournamentStatus.OPEN:
tournament_status_color = self.session.theme.success_color
elif self.tournament.status == TournamentStatus.CLOSED:
tournament_status_color = self.session.theme.danger_color
elif self.tournament.status == TournamentStatus.ONGOING:
tournament_status_color = self.session.theme.warning_color
return Column(
MainViewContentBox( # ToDo: Integrate Teams logic
Column( ids_of_participants = [p.id for p in self.tournament.participants]
color_key: Literal["hud", "danger"] = "hud"
on_press_function = self.register_pressed
if self.user and self.user.user_id in ids_of_participants: # User already registered for tournament
button_text = "Abmelden"
button_sensitive_hook = True # User has already accepted the rules previously
color_key = "danger"
on_press_function = self.unregister_pressed
elif self.user and self.user.user_id not in ids_of_participants:
button_text = "Anmelden"
button_sensitive_hook = self.rules_accepted
else:
# This should NEVER happen
button_text = "Anmelden"
button_sensitive_hook = False
if self.tournament.status != TournamentStatus.OPEN or self.tournament.is_full:
button_sensitive_hook = False # Override button controls if tournament is not open anymore or full
if self.user:
accept_rules_row = Row(
ThemeContextSwitcher(content=Checkbox(is_on=self.bind().rules_accepted, margin_left=4), color=self.session.theme.hud_color),
Text("Ich akzeptiere die ", margin_left=1, style=TextStyle(fill=self.session.theme.background_color, font_size=0.8), overflow="nowrap", justify="right"),
Link(Text("Turnierregeln", margin_right=4, style=TextStyle(fill=self.session.theme.background_color, font_size=0.8, italic=True), overflow="nowrap", justify="left"), "https://google.de") # ToDo: Add rules
)
button = Button(
content=button_text,
shape="rectangle",
style="major",
color=color_key,
margin_left=2,
margin_right=2,
is_sensitive=button_sensitive_hook,
on_press=on_press_function,
is_loading=self.loading
)
else:
# No UI here if user not logged in
accept_rules_row, button = Spacer(), Spacer()
content = Column(
Row(
Image(image=from_root(f"src/ezgg_lan_manager/assets/img/games/{self.tournament.game_title.image_name}")),
Text( Text(
text=self.tournament.name, text=self.tournament.name,
style=TextStyle( style=TextStyle(
fill=self.session.theme.background_color, fill=self.session.theme.background_color,
font_size=1.2 font_size=1.2
), ),
margin_top=2, margin_top=1,
margin_bottom=2, margin_bottom=1,
align_x=0.5 align_x=0.5
), ),
margin_right=6,
margin_left=6
),
Spacer(min_height=1),
TournamentDetailsInfoRow("Status", tournament_status_to_display_text(self.tournament.status), tournament_status_color),
TournamentDetailsInfoRow("Startzeit", f"{weekday_to_display_text(self.tournament.start_time.weekday())}, {self.tournament.start_time.strftime('%H:%M')} Uhr"),
TournamentDetailsInfoRow("Format", tournament_format_to_display_texts(self.tournament.format)[0]),
TournamentDetailsInfoRow("Best of", tournament_format_to_display_texts(self.tournament.format)[1]),
TournamentDetailsInfoRow(
"Teilnehmer",
f"{len(self.tournament.participants)} / {self.tournament.max_participants}",
self.session.theme.danger_color if self.tournament.is_full else self.session.theme.background_color
),
Row(
Text(
text="Info",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=1,
margin_bottom=1,
align_x=0.5
)
),
# FixMe: Use rio.Markdown with correct TextStyle instead to allow basic text formatting from DB-side.
Text(self.tournament.description, margin_left=2, margin_right=2, style=TextStyle(fill=self.session.theme.background_color, font_size=1), overflow="wrap"),
Spacer(min_height=2),
accept_rules_row,
Spacer(min_height=0.5),
Text(self.message, margin_left=2, margin_right=2, style=TextStyle(fill=self.session.theme.success_color if self.is_success else self.session.theme.danger_color, font_size=1), overflow="wrap", justify="center"),
Spacer(min_height=0.5),
button
)
return Column(
MainViewContentBox(
Column(
Spacer(min_height=1),
content,
Spacer(min_height=1) Spacer(min_height=1)
) )
), ),

View File

@ -4,8 +4,17 @@ from typing import Optional
from src.ezgg_lan_manager.services.DatabaseService import DatabaseService from src.ezgg_lan_manager.services.DatabaseService import DatabaseService
from src.ezgg_lan_manager.services.UserService import UserService from src.ezgg_lan_manager.services.UserService import UserService
from src.ezgg_lan_manager.types.Participant import Participant
from src.ezgg_lan_manager.types.Tournament import Tournament from src.ezgg_lan_manager.types.Tournament import Tournament
from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, ParticipantType
DEV_LOREM_IPSUM = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Dieses LAN-Turnier bringt Spieler aus verschiedenen Regionen zusammen, um gemeinsam spannende Matches zu erleben. Tastaturen klappern, Monitore leuchten und die Stimmung ist von Anfang an von Vorfreude und Ehrgeiz geprägt.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In intensiven Partien zählen Strategie, Reaktion und Teamarbeit. Zwischen den Spielen wird gefachsimpelt, gelacht und neue Kontakte entstehen, während Server stabil laufen und das Netzwerk dauerhaft gefordert ist.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Wenn die Finalspiele beginnen, steigt die Spannung spürbar. Am Ende bleiben faire Wettkämpfe, gemeinsame Erinnerungen und das Gefühl, Teil einer starken Community gewesen zu sein.
"""
class TournamentService: class TournamentService:
@ -19,6 +28,7 @@ class TournamentService:
Tournament( Tournament(
0, 0,
"Teeworlds 2vs2", "Teeworlds 2vs2",
DEV_LOREM_IPSUM,
GameTitle( GameTitle(
"Teeworlds", "Teeworlds",
"Teeworlds is a free online multiplayer game, available for all major operating systems. Battle with up to 16 players in a variety of game modes.", "Teeworlds is a free online multiplayer game, available for all major operating systems. Battle with up to 16 players in a variety of game modes.",
@ -36,6 +46,7 @@ class TournamentService:
Tournament( Tournament(
1, 1,
"Rocket League 3vs3", "Rocket League 3vs3",
DEV_LOREM_IPSUM,
GameTitle( GameTitle(
"Rocket League", "Rocket League",
"Rocket League is a high-powered hybrid of arcade-style soccer and vehicular mayhem with easy-to-understand controls and fluid, physics-driven competition.", "Rocket League is a high-powered hybrid of arcade-style soccer and vehicular mayhem with easy-to-understand controls and fluid, physics-driven competition.",
@ -45,7 +56,7 @@ class TournamentService:
TournamentFormat.SINGLE_ELIMINATION_BO_3, TournamentFormat.SINGLE_ELIMINATION_BO_3,
datetime(2026, 5, 8, 18, 0, 0), datetime(2026, 5, 8, 18, 0, 0),
TournamentStatus.OPEN, TournamentStatus.OPEN,
[], [Participant(30, "Typhus", ParticipantType.PLAYER)],
None, None,
[], [],
8 8
@ -53,6 +64,7 @@ class TournamentService:
Tournament( Tournament(
2, 2,
"Worms Armageddon 1vs1", "Worms Armageddon 1vs1",
DEV_LOREM_IPSUM,
GameTitle( GameTitle(
"Worms Armageddon", "Worms Armageddon",
"2D turn-based artillery strategy game.", "2D turn-based artillery strategy game.",
@ -61,7 +73,7 @@ class TournamentService:
), ),
TournamentFormat.SINGLE_ELIMINATION_BO_1, TournamentFormat.SINGLE_ELIMINATION_BO_1,
datetime(2026, 5, 8, 18, 30, 0), datetime(2026, 5, 8, 18, 30, 0),
TournamentStatus.OPEN, TournamentStatus.CLOSED,
[], [],
None, None,
[], [],

View File

@ -0,0 +1,15 @@
def weekday_to_display_text(weekday: int) -> str:
if weekday == 0:
return "Montag"
elif weekday == 1:
return "Dienstag"
elif weekday == 2:
return "Mittwoch"
elif weekday == 3:
return "Donnerstag"
elif weekday == 4:
return "Freitag"
elif weekday == 5:
return "Samstag"
else:
return "Sonntag"

View File

@ -12,6 +12,7 @@ class Tournament:
def __init__(self, def __init__(self,
id_: int, id_: int,
name: str, name: str,
description: str,
game_title: GameTitle, game_title: GameTitle,
format_: TournamentFormat, format_: TournamentFormat,
start_time: datetime, start_time: datetime,
@ -22,6 +23,7 @@ class Tournament:
max_participants: int) -> None: max_participants: int) -> None:
self._id = id_ self._id = id_
self._name = name self._name = name
self._description = description
self._game_title = game_title self._game_title = game_title
self._format = format_ self._format = format_
self._start_time = start_time self._start_time = start_time
@ -75,6 +77,14 @@ class Tournament:
def max_participants(self) -> int: def max_participants(self) -> int:
return self._max_participants return self._max_participants
@property
def description(self) -> str:
return self._description
@property
def is_full(self) -> bool:
return len(self._participants) >= self._max_participants
def add_participant(self, participant: Participant) -> None: def add_participant(self, participant: Participant) -> None:
if participant.id in (p.id for p in self._participants): if participant.id in (p.id for p in self._participants):
raise TournamentError(f"Participant with ID {participant.id} already registered for tournament") raise TournamentError(f"Participant with ID {participant.id} already registered for tournament")
@ -310,16 +320,18 @@ class Tournament:
match.check_completion() match.check_completion()
def generate_new_tournament(name: str, game_title: GameTitle, format_: TournamentFormat, start_time: datetime, initial_status: TournamentStatus = TournamentStatus.CLOSED) -> Tournament: def generate_new_tournament(name: str, description: str, game_title: GameTitle, format_: TournamentFormat, start_time: datetime, max_participants: int, initial_status: TournamentStatus = TournamentStatus.CLOSED) -> Tournament:
id_ = uuid.uuid4().int id_ = uuid.uuid4().int
return Tournament( return Tournament(
id_, id_,
name, name,
description,
game_title, game_title,
format_, format_,
start_time, start_time,
initial_status, initial_status,
list(), list(),
None, None,
list() list(),
max_participants
) )

View File

@ -17,6 +17,23 @@ class TournamentFormat(Enum):
DOUBLE_ELIMINATION_BO_3 = 5 DOUBLE_ELIMINATION_BO_3 = 5
DOUBLE_ELIMINATION_BO_5 = 6 DOUBLE_ELIMINATION_BO_5 = 6
def tournament_format_to_display_texts(tournament_format: TournamentFormat) -> tuple[str, str]:
""" Returns tuple where idx 0 is SE/DE string and idx 1 is match count """
if tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_1:
return "Single Elimination", "1"
elif tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_3:
return "Single Elimination", "3"
elif tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_5:
return "Single Elimination", "5"
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_1:
return "Double Elimination", "1"
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_3:
return "Double Elimination", "3"
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_5:
return "Double Elimination", "5"
else:
raise RuntimeError(f"Unknown tournament status: {str(tournament_format)}")
class TournamentStatus(Enum): class TournamentStatus(Enum):
CLOSED = 1 CLOSED = 1
@ -26,6 +43,22 @@ class TournamentStatus(Enum):
INVITE_ONLY = 5 # For Show-matches INVITE_ONLY = 5 # For Show-matches
ONGOING = 6 ONGOING = 6
def tournament_status_to_display_text(tournament_status: TournamentStatus) -> str:
if tournament_status == TournamentStatus.OPEN:
return "Offen"
elif tournament_status == TournamentStatus.CLOSED:
return "Geschlossen"
elif tournament_status == TournamentStatus.ONGOING:
return "Läuft"
elif tournament_status == TournamentStatus.COMPLETED:
return "Abgeschlossen"
elif tournament_status == TournamentStatus.CANCELED:
return "Abgesagt"
elif tournament_status == TournamentStatus.INVITE_ONLY:
return "Invite-only"
else:
raise RuntimeError(f"Unknown tournament status: {str(tournament_status)}")
class TournamentError(Exception): class TournamentError(Exception):
def __init__(self, message: str) -> None: def __init__(self, message: str) -> None:

View File

@ -9,7 +9,8 @@ class TournamentDomainTests(unittest.TestCase):
def setUp(self): def setUp(self):
# Generic Tournament config # Generic Tournament config
self.name = "Tetris 1vs1" self.name = "Tetris 1vs1"
self.game_title = GameTitle("Tetris99", "Some Description", "https://de.wikipedia.org/wiki/Tetris_99") self.description = "Just play Tetris, yo"
self.game_title = GameTitle("Tetris99", "Some Description", "https://de.wikipedia.org/wiki/Tetris_99", "tetris.png")
self.format_ = TournamentFormat.SINGLE_ELIMINATION_BO_3 self.format_ = TournamentFormat.SINGLE_ELIMINATION_BO_3
self.start_time = datetime(year=2100, month=6, day=23, hour=16, minute=30, second=0) self.start_time = datetime(year=2100, month=6, day=23, hour=16, minute=30, second=0)
self.initial_status = TournamentStatus.CLOSED self.initial_status = TournamentStatus.CLOSED
@ -20,18 +21,18 @@ class TournamentDomainTests(unittest.TestCase):
self.participant_c = Participant(3, "FunnyUserName", ParticipantType.PLAYER) self.participant_c = Participant(3, "FunnyUserName", ParticipantType.PLAYER)
def test_tournament_without_participants_can_not_be_started(self) -> None: def test_tournament_without_participants_can_not_be_started(self) -> None:
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status) tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
with self.assertRaises(TournamentError): with self.assertRaises(TournamentError):
tournament_under_test.start() tournament_under_test.start()
def test_adding_the_same_participant_twice_leads_to_exception(self) -> None: def test_adding_the_same_participant_twice_leads_to_exception(self) -> None:
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status) tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
tournament_under_test.add_participant(self.participant_a) tournament_under_test.add_participant(self.participant_a)
with self.assertRaises(TournamentError): with self.assertRaises(TournamentError):
tournament_under_test.add_participant(self.participant_a) tournament_under_test.add_participant(self.participant_a)
def test_single_elimination_bo3_tournament_gets_generated_correctly(self) -> None: def test_single_elimination_bo3_tournament_gets_generated_correctly(self) -> None:
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status) tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
tournament_under_test.add_participant(self.participant_a) tournament_under_test.add_participant(self.participant_a)
tournament_under_test.add_participant(self.participant_b) tournament_under_test.add_participant(self.participant_b)