Compare commits

...

2 Commits

Author SHA1 Message Date
David Rodenkirchen
ff5d715a4e add tournament data model 2026-01-28 20:14:40 +00:00
David Rodenkirchen
b505191156 add hover tooltips for seating plan 2025-11-12 23:36:33 +01:00
9 changed files with 724 additions and 80 deletions

Binary file not shown.

View File

@ -12,7 +12,7 @@ class SeatingPlanLegend(Component):
def build(self) -> Component:
return Column(
Text("Legende", style=TextStyle(fill=self.session.theme.neutral_color), justify="center", margin=1),
Row( # Disabled for upcoming LAN
Row(
Spacer(),
Rectangle(
content=Text("Normaler Platz", style=TextStyle(fill=self.session.theme.neutral_color, font_size=0.7), margin=0.2, justify="center"),
@ -93,7 +93,7 @@ class SeatingPlan(Component):
return seat if seat else Seat(seat_id="Z99", is_blocked=True, category="LUXUS", user=None)
"""
This seating plan is for the community center "Bottenhorn"
This seating plan is for the community center "Donsbach"
"""
def build(self) -> Component:
grid = Grid()
@ -153,73 +153,73 @@ class SeatingPlan(Component):
# Block A
grid.add(SeatPixel("A01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A01")), row=57, column=1, width=5, height=2)
grid.add(SeatPixel("A02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A02")), row=57, column=6, width=5, height=2)
grid.add(SeatPixel("A03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A03")), row=57, column=11, width=5, height=2)
grid.add(SeatPixel("A04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A04")), row=57, column=16, width=5, height=2)
grid.add(SeatPixel("A05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A05")), row=57, column=21, width=5, height=2)
grid.add(SeatPixel("A01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A01"), seat_orientation="bottom"), row=57, column=1, width=5, height=2)
grid.add(SeatPixel("A02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A02"), seat_orientation="bottom"), row=57, column=6, width=5, height=2)
grid.add(SeatPixel("A03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A03"), seat_orientation="bottom"), row=57, column=11, width=5, height=2)
grid.add(SeatPixel("A04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A04"), seat_orientation="bottom"), row=57, column=16, width=5, height=2)
grid.add(SeatPixel("A05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A05"), seat_orientation="bottom"), row=57, column=21, width=5, height=2)
grid.add(SeatPixel("A10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A10")), row=55, column=1, width=5, height=2)
grid.add(SeatPixel("A11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A11")), row=55, column=6, width=5, height=2)
grid.add(SeatPixel("A12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A12")), row=55, column=11, width=5, height=2)
grid.add(SeatPixel("A13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A13")), row=55, column=16, width=5, height=2)
grid.add(SeatPixel("A14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A14")), row=55, column=21, width=5, height=2)
grid.add(SeatPixel("A10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A10"), seat_orientation="top"), row=55, column=1, width=5, height=2)
grid.add(SeatPixel("A11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A11"), seat_orientation="top"), row=55, column=6, width=5, height=2)
grid.add(SeatPixel("A12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A12"), seat_orientation="top"), row=55, column=11, width=5, height=2)
grid.add(SeatPixel("A13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A13"), seat_orientation="top"), row=55, column=16, width=5, height=2)
grid.add(SeatPixel("A14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A14"), seat_orientation="top"), row=55, column=21, width=5, height=2)
# Block B
grid.add(SeatPixel("B01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B01")), row=50, column=1, width=3, height=2)
grid.add(SeatPixel("B02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B02")), row=50, column=4, width=3, height=2)
grid.add(SeatPixel("B03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B03")), row=50, column=7, width=3, height=2)
grid.add(SeatPixel("B04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B04")), row=50, column=10, width=3, height=2)
grid.add(SeatPixel("B05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B05")), row=50, column=13, width=3, height=2)
grid.add(SeatPixel("B06", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B06")), row=50, column=16, width=3, height=2)
grid.add(SeatPixel("B01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B01"), seat_orientation="bottom"), row=50, column=1, width=3, height=2)
grid.add(SeatPixel("B02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B02"), seat_orientation="bottom"), row=50, column=4, width=3, height=2)
grid.add(SeatPixel("B03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B03"), seat_orientation="bottom"), row=50, column=7, width=3, height=2)
grid.add(SeatPixel("B04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B04"), seat_orientation="bottom"), row=50, column=10, width=3, height=2)
grid.add(SeatPixel("B05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B05"), seat_orientation="bottom"), row=50, column=13, width=3, height=2)
grid.add(SeatPixel("B06", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B06"), seat_orientation="bottom"), row=50, column=16, width=3, height=2)
grid.add(SeatPixel("B10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B10")), row=48, column=1, width=3, height=2)
grid.add(SeatPixel("B11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B11")), row=48, column=4, width=3, height=2)
grid.add(SeatPixel("B12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B12")), row=48, column=7, width=3, height=2)
grid.add(SeatPixel("B13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B13")), row=48, column=10, width=3, height=2)
grid.add(SeatPixel("B14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B14")), row=48, column=13, width=3, height=2)
grid.add(SeatPixel("B15", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B15")), row=48, column=16, width=3, height=2)
grid.add(SeatPixel("B10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B10"), seat_orientation="top"), row=48, column=1, width=3, height=2)
grid.add(SeatPixel("B11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B11"), seat_orientation="top"), row=48, column=4, width=3, height=2)
grid.add(SeatPixel("B12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B12"), seat_orientation="top"), row=48, column=7, width=3, height=2)
grid.add(SeatPixel("B13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B13"), seat_orientation="top"), row=48, column=10, width=3, height=2)
grid.add(SeatPixel("B14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B14"), seat_orientation="top"), row=48, column=13, width=3, height=2)
grid.add(SeatPixel("B15", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B15"), seat_orientation="top"), row=48, column=16, width=3, height=2)
# Block C
grid.add(SeatPixel("C01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C01")), row=43, column=1, width=3, height=2)
grid.add(SeatPixel("C02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C02")), row=43, column=4, width=3, height=2)
grid.add(SeatPixel("C03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C03")), row=43, column=7, width=3, height=2)
grid.add(SeatPixel("C04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C04")), row=43, column=10, width=3, height=2)
grid.add(SeatPixel("C05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C05")), row=43, column=13, width=3, height=2)
grid.add(SeatPixel("C06", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C06")), row=43, column=16, width=3, height=2)
grid.add(SeatPixel("C01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C01"), seat_orientation="bottom"), row=43, column=1, width=3, height=2)
grid.add(SeatPixel("C02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C02"), seat_orientation="bottom"), row=43, column=4, width=3, height=2)
grid.add(SeatPixel("C03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C03"), seat_orientation="bottom"), row=43, column=7, width=3, height=2)
grid.add(SeatPixel("C04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C04"), seat_orientation="bottom"), row=43, column=10, width=3, height=2)
grid.add(SeatPixel("C05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C05"), seat_orientation="bottom"), row=43, column=13, width=3, height=2)
grid.add(SeatPixel("C06", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C06"), seat_orientation="bottom"), row=43, column=16, width=3, height=2)
grid.add(SeatPixel("C10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C10")), row=41, column=1, width=3, height=2)
grid.add(SeatPixel("C11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C11")), row=41, column=4, width=3, height=2)
grid.add(SeatPixel("C12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C12")), row=41, column=7, width=3, height=2)
grid.add(SeatPixel("C13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C13")), row=41, column=10, width=3, height=2)
grid.add(SeatPixel("C14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C14")), row=41, column=13, width=3, height=2)
grid.add(SeatPixel("C15", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C15")), row=41, column=16, width=3, height=2)
grid.add(SeatPixel("C10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C10"), seat_orientation="top"), row=41, column=1, width=3, height=2)
grid.add(SeatPixel("C11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C11"), seat_orientation="top"), row=41, column=4, width=3, height=2)
grid.add(SeatPixel("C12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C12"), seat_orientation="top"), row=41, column=7, width=3, height=2)
grid.add(SeatPixel("C13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C13"), seat_orientation="top"), row=41, column=10, width=3, height=2)
grid.add(SeatPixel("C14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C14"), seat_orientation="top"), row=41, column=13, width=3, height=2)
grid.add(SeatPixel("C15", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C15"), seat_orientation="top"), row=41, column=16, width=3, height=2)
# Block D
grid.add(SeatPixel("D01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D01")), row=34, column=1, width=5, height=2)
grid.add(SeatPixel("D02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D02")), row=34, column=6, width=5, height=2)
grid.add(SeatPixel("D03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D03")), row=34, column=11, width=5, height=2)
grid.add(SeatPixel("D04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D04")), row=34, column=16, width=5, height=2)
grid.add(SeatPixel("D05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D05")), row=34, column=21, width=5, height=2)
grid.add(SeatPixel("D01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D01"), seat_orientation="bottom"), row=34, column=1, width=5, height=2)
grid.add(SeatPixel("D02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D02"), seat_orientation="bottom"), row=34, column=6, width=5, height=2)
grid.add(SeatPixel("D03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D03"), seat_orientation="bottom"), row=34, column=11, width=5, height=2)
grid.add(SeatPixel("D04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D04"), seat_orientation="bottom"), row=34, column=16, width=5, height=2)
grid.add(SeatPixel("D05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D05"), seat_orientation="bottom"), row=34, column=21, width=5, height=2)
grid.add(SeatPixel("D10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D10")), row=32, column=1, width=5, height=2)
grid.add(SeatPixel("D11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D11")), row=32, column=6, width=5, height=2)
grid.add(SeatPixel("D12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D12")), row=32, column=11, width=5, height=2)
grid.add(SeatPixel("D13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D13")), row=32, column=16, width=5, height=2)
grid.add(SeatPixel("D14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D14")), row=32, column=21, width=5, height=2)
grid.add(SeatPixel("D10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D10"), seat_orientation="top"), row=32, column=1, width=5, height=2)
grid.add(SeatPixel("D11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D11"), seat_orientation="top"), row=32, column=6, width=5, height=2)
grid.add(SeatPixel("D12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D12"), seat_orientation="top"), row=32, column=11, width=5, height=2)
grid.add(SeatPixel("D13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D13"), seat_orientation="top"), row=32, column=16, width=5, height=2)
grid.add(SeatPixel("D14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D14"), seat_orientation="top"), row=32, column=21, width=5, height=2)
# Block E
grid.add(SeatPixel("E01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E01")), row=27, column=1, width=5, height=2)
grid.add(SeatPixel("E02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E02")), row=27, column=6, width=5, height=2)
grid.add(SeatPixel("E03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E03")), row=27, column=11, width=5, height=2)
grid.add(SeatPixel("E04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E04")), row=27, column=16, width=5, height=2)
grid.add(SeatPixel("E05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E05")), row=27, column=21, width=5, height=2)
grid.add(SeatPixel("E01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E01"), seat_orientation="bottom"), row=27, column=1, width=5, height=2)
grid.add(SeatPixel("E02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E02"), seat_orientation="bottom"), row=27, column=6, width=5, height=2)
grid.add(SeatPixel("E03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E03"), seat_orientation="bottom"), row=27, column=11, width=5, height=2)
grid.add(SeatPixel("E04", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E04"), seat_orientation="bottom"), row=27, column=16, width=5, height=2)
grid.add(SeatPixel("E05", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E05"), seat_orientation="bottom"), row=27, column=21, width=5, height=2)
grid.add(SeatPixel("E10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E10")), row=25, column=1, width=5, height=2)
grid.add(SeatPixel("E11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E11")), row=25, column=6, width=5, height=2)
grid.add(SeatPixel("E12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E12")), row=25, column=11, width=5, height=2)
grid.add(SeatPixel("E13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E13")), row=25, column=16, width=5, height=2)
grid.add(SeatPixel("E14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E14")), row=25, column=21, width=5, height=2)
grid.add(SeatPixel("E10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E10"), seat_orientation="top"), row=25, column=1, width=5, height=2)
grid.add(SeatPixel("E11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E11"), seat_orientation="top"), row=25, column=6, width=5, height=2)
grid.add(SeatPixel("E12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E12"), seat_orientation="top"), row=25, column=11, width=5, height=2)
grid.add(SeatPixel("E13", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E13"), seat_orientation="top"), row=25, column=16, width=5, height=2)
grid.add(SeatPixel("E14", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E14"), seat_orientation="top"), row=25, column=21, width=5, height=2)
# Stage
grid.add(PointerEventListener(

View File

@ -1,7 +1,7 @@
from functools import partial
from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, PointerEventListener, Column, Row
from typing import Optional, Callable
from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, PointerEventListener, Column, Row, PointerEvent, Tooltip
from typing import Optional, Callable, Literal
from src.ezgg_lan_manager.types.Seat import Seat
from src.ezgg_lan_manager.types.SessionStorage import SessionStorage
@ -11,6 +11,7 @@ class SeatPixel(Component):
seat_id: str
on_press_cb: Callable
seat: Seat
seat_orientation: Literal["top", "bottom"]
def determine_color(self) -> Color:
if self.seat.user is not None and self.seat.user.user_id == self.session[SessionStorage].user_id:
@ -20,26 +21,38 @@ class SeatPixel(Component):
return self.session.theme.success_color
def build(self) -> Component:
return PointerEventListener(
content=Rectangle(
content=Row(
Text(f"{self.seat_id}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, selectable=False)
),
min_width=1,
min_height=1,
fill=self.determine_color(),
stroke_width = 0.1,
hover_stroke_width = 0.1,
stroke_color=Color.from_hex("003300") if self.seat.category == "NORMAL" else Color.from_hex("66ff99"),
grow_x=True,
grow_y=True,
hover_fill=self.session.theme.hud_color,
transition_time=0.4,
ripple=True
),
on_press=partial(self.on_press_cb, self.seat_id)
text = Text(f"{self.seat_id}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, selectable=False)
rec = Rectangle(
content=Row(text),
min_width=1,
min_height=1,
fill=self.determine_color(),
stroke_width=0.1,
hover_stroke_width=0.1,
stroke_color=Color.from_hex("003300") if self.seat.category == "NORMAL" else Color.from_hex("66ff99"),
grow_x=True,
grow_y=True,
hover_fill=self.session.theme.hud_color,
transition_time=0.4,
ripple=True
)
if self.seat.user or self.seat.is_blocked:
return PointerEventListener(
content=Tooltip(
anchor=rec,
tip=self.seat.user.user_name if self.seat.user else "Gesperrt",
position=self.seat_orientation,
),
on_press=partial(self.on_press_cb, self.seat_id),
)
else:
return PointerEventListener(
content=rec,
on_press=partial(self.on_press_cb, self.seat_id),
)
class TextPixel(Component):
text: Optional[str] = None
icon_name: Optional[str] = None
@ -59,13 +72,14 @@ class TextPixel(Component):
fill=self.session.theme.primary_color,
stroke_width=0.0 if self.no_outline else 0.1,
stroke_color=self.session.theme.neutral_color,
hover_stroke_width = None if self.no_outline else 0.1,
hover_stroke_width=None if self.no_outline else 0.1,
grow_x=True,
grow_y=True,
hover_fill=None,
ripple=True
)
class WallPixel(Component):
def build(self) -> Component:
return Rectangle(
@ -76,6 +90,7 @@ class WallPixel(Component):
grow_y=True,
)
class DebugPixel(Component):
def build(self) -> Component:
return Rectangle(
@ -83,14 +98,15 @@ class DebugPixel(Component):
min_width=1,
min_height=1,
fill=self.session.theme.success_color,
hover_stroke_color = self.session.theme.hud_color,
hover_stroke_width = 0.1,
hover_stroke_color=self.session.theme.hud_color,
hover_stroke_width=0.1,
grow_x=True,
grow_y=True,
hover_fill=self.session.theme.secondary_color,
transition_time=0.1
)
class InvisiblePixel(Component):
def build(self) -> Component:
return Rectangle(
@ -101,4 +117,4 @@ class InvisiblePixel(Component):
hover_stroke_width=0.0,
grow_x=True,
grow_y=True
)
)

View File

@ -0,0 +1,39 @@
from typing import Optional
from src.ezgg_lan_manager.types.TournamentBase import TournamentError
class Game:
def __init__(self, id_: tuple[int, int], match_id: int, game_number: int, winner_id: Optional[int], score: Optional[tuple[int, int]], game_done: bool) -> None:
self._id = id_
self._match_id = match_id
self._game_number = game_number
self._winner_id = winner_id
self._score = score
self._done = game_done
@property
def id(self) -> tuple[int, int]:
return self._id
@property
def is_done(self) -> bool:
return self._done
@property
def winner(self) -> Optional[int]:
return self._winner_id
@property
def number(self) -> int:
return self._game_number
def finish(self, winner_id: int, score: tuple[int, int], force: bool = False) -> None:
""" NEVER call this outside Match or a Testsuite """
if self._done and not force:
raise TournamentError("Game is already finished")
self._winner_id = winner_id
self._score = score
self._done = True

View File

@ -0,0 +1,133 @@
from collections import Counter
from math import ceil
from typing import Literal, Optional, Callable
from src.ezgg_lan_manager.types.Game import Game
from src.ezgg_lan_manager.types.TournamentBase import MatchStatus, TournamentError, Bracket
class MatchParticipant:
def __init__(self, participant_id: int, slot_number: Literal[1, 2]) -> None:
self._participant_id = participant_id
if slot_number not in (1, 2):
raise TournamentError("Invalid slot number")
self.slot_number = slot_number
@property
def participant_id(self) -> int:
return self._participant_id
class Match:
def __init__(self,
match_id: int,
tournament_id: int,
round_number: int,
bracket: Bracket,
best_of: int,
status: MatchStatus,
next_match_win_lose_ids: tuple[Optional[int], Optional[int]],
match_has_ended_callback: Callable) -> None:
self._match_id = match_id
self._tournament_id = tournament_id
self._round_number = round_number
self._bracket = bracket
self._best_of = best_of
self._status = status
self._next_match_win_id = next_match_win_lose_ids[0]
self._next_match_lose_id = next_match_win_lose_ids[1]
self._match_has_ended_callback = match_has_ended_callback
self._participants: list[MatchParticipant] = []
self._games: tuple[Game] = self._prepare_games()
def _prepare_games(self) -> tuple[Game]:
games = []
for game_number in range(1, self._best_of + 1):
game_id = (self._match_id, game_number)
games.append(Game(game_id, self._match_id, game_number, None, None, False))
return tuple(games)
@property
def status(self) -> MatchStatus:
if self._status == MatchStatus.COMPLETED:
return self._status
return self._status if self.is_fully_seeded else MatchStatus.WAITING
@status.setter
def status(self, new_status: MatchStatus) -> None:
if new_status in (MatchStatus.COMPLETED, MatchStatus.PENDING) and not self.is_fully_seeded:
raise TournamentError("Can't complete/pend match that is not fully seeded")
if self._status == MatchStatus.COMPLETED and new_status != MatchStatus.CANCELED:
raise TournamentError("Can't change COMPLETED match back to another active status")
self._status = new_status
@property
def games(self) -> tuple[Game]:
return self._games
@property
def winner(self) -> Optional[int]:
wins_needed = ceil(self._best_of / 2)
counts = Counter(game.winner for game in self._games if game.is_done)
for participant_id, wins in counts.items():
if wins >= wins_needed:
return participant_id
return None
@property
def is_fully_seeded(self) -> bool:
slots = {p.slot_number for p in self._participants}
return slots == {1, 2}
@property
def match_id(self) -> int:
return self._match_id
@property
def participants(self) -> list[MatchParticipant]:
return self._participants
@property
def next_match_win_id(self) -> Optional[int]:
return self._next_match_win_id
@property
def next_match_lose_id(self) -> Optional[int]:
return self._next_match_lose_id
def assign_participant(self, participant_id: int, slot: Literal[1, 2]) -> None:
new_participant = MatchParticipant(participant_id, slot)
if len(self._participants) < 2 and not any(p.participant_id == participant_id for p in self._participants):
if len(self._participants) == 1 and self._participants[-1].slot_number == new_participant.slot_number:
raise TournamentError(f"Match with ID {self._match_id} encountered slot collision")
self._participants.append(new_participant)
return
raise TournamentError(f"Match with ID {self._match_id} already has the maximum number of participants")
def check_completion(self) -> None:
winner = self.winner
if winner is not None:
self._match_has_ended_callback(self)
self._status = MatchStatus.COMPLETED
def report_game_result(self, game_number: int, winner_id: int, score: tuple[int, int]) -> None:
if winner_id not in {p.participant_id for p in self._participants}:
raise TournamentError("Winner is not a participant of this match")
self._games[game_number - 1].finish(winner_id, score)
self.check_completion()
def cancel(self) -> None:
self._status = MatchStatus.CANCELED
def __repr__(self) -> str:
participants = ", ".join(
f"{p.participant_id} (slot {p.slot_number})" for p in self._participants
)
return (f"<Match id={self._match_id} round={self._round_number} bracket={self._bracket.name} "
f"status={self.status.name} participants=[{participants}] winner={self.winner}>")

View File

@ -0,0 +1,20 @@
from src.ezgg_lan_manager.types.TournamentBase import ParticipantType
class Participant:
def __init__(self, id_: int, display_name: str, participant_type: ParticipantType) -> None:
self._id = id_
self._participant_type = participant_type
self._display_name = display_name
@property
def id(self) -> int:
return self._id
@property
def participant_type(self) -> ParticipantType:
return self._participant_type
@property
def display_name(self) -> str:
return self._display_name

View File

@ -0,0 +1,319 @@
import uuid
from datetime import datetime
from typing import Optional
from math import ceil, log2
from src.ezgg_lan_manager.types.Match import Match
from src.ezgg_lan_manager.types.Participant import Participant
from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, TournamentError, Bracket, MatchStatus
class Tournament:
def __init__(self,
id_: int,
name: str,
game_title: GameTitle,
format_: TournamentFormat,
start_time: datetime,
status: TournamentStatus,
participants: list[Participant],
matches: Optional[tuple[Match]],
rounds: list[list[Match]]) -> None:
self._id = id_
self._name = name
self._game_title = game_title
self._format = format_
self._start_time = start_time
self._status = status
self._participants = participants
self._matches = matches
self._rounds = rounds
@property
def id(self) -> int:
return self._id
@property
def name(self) -> str:
return self._name
@property
def game_title(self) -> GameTitle:
return self._game_title
@property
def format(self) -> TournamentFormat:
return self._format
@property
def start_time(self) -> datetime:
return self._start_time
@property
def status(self) -> TournamentStatus:
return self._status
@status.setter
def status(self, new_status: TournamentStatus) -> None:
if new_status == TournamentStatus.OPEN and self._status == TournamentStatus.CLOSED and self._matches is not None:
# Deletes all tournament preparation !
self._matches = None
self._status = new_status
@property
def participants(self) -> list[Participant]:
return self._participants
@property
def matches(self) -> list[Match]:
return self._matches if self._matches else []
def add_participant(self, participant: Participant) -> None:
if participant.id in (p.id for p in self._participants):
raise TournamentError(f"Participant with ID {participant.id} already registered for tournament")
self._participants.append(participant)
def match_has_ended_callback(self, match: Match) -> None:
if self._matches is None:
return
winner = match.winner
next_match = next((m for m in self._matches if m.match_id == match.next_match_win_id), None)
if next_match is not None:
try:
next_match.assign_participant(winner, 1)
except TournamentError:
next_match.assign_participant(winner, 2)
else: # No next match = final round
pass
if match.next_match_lose_id is not None:
loser = next(p for p in match.participants if p.participant_id != winner)
next_match = next((m for m in self._matches if m.match_id == match.next_match_lose_id), None)
if next_match is not None:
try:
next_match.assign_participant(loser.participant_id, 1)
except TournamentError:
next_match.assign_participant(loser.participant_id, 2)
else: # No next match = final round
pass
def start(self) -> None:
""" This builds the tournament tree and sets it to ONGOING """
def parse_format(fmt: TournamentFormat) -> tuple[str, int]:
if fmt.name.startswith("SINGLE_ELIMINATION"):
bracket = "SINGLE"
elif fmt.name.startswith("DOUBLE_ELIMINATION"):
bracket = "DOUBLE"
else:
raise TournamentError(f"Unsupported tournament format: {fmt}")
if fmt.name.endswith("_BO_1"):
bo = 1
elif fmt.name.endswith("_BO_3"):
bo = 3
elif fmt.name.endswith("_BO_5"):
bo = 5
else:
raise TournamentError(f"Unsupported best-of in format: {fmt}")
return bracket, bo
if len(self._participants) < 2:
raise TournamentError("Cannot start tournament: not enough participants")
bracket_type, best_of = parse_format(self._format)
num_participants = len(self.participants)
match_id_counter = 1
if bracket_type == "SINGLE":
# --- single-elimination as before ---
num_rounds = ceil(log2(num_participants))
rounds: list[list[Match]] = []
for round_number in range(1, num_rounds + 1):
num_matches = 2 ** (num_rounds - round_number)
round_matches = []
for _ in range(num_matches):
match = Match(
match_id=match_id_counter,
tournament_id=self._id,
round_number=round_number,
bracket=Bracket.UPPER if round_number != num_rounds else Bracket.FINAL,
best_of=best_of,
status=MatchStatus.WAITING,
next_match_win_lose_ids=(None, None),
match_has_ended_callback=self.match_has_ended_callback
)
round_matches.append(match)
match_id_counter += 1
rounds.append(round_matches)
# Link winner IDs
for i in range(len(rounds) - 1):
current_round = rounds[i]
next_round = rounds[i + 1]
for idx, match in enumerate(current_round):
next_match = next_round[idx // 2]
match._next_match_win_id = next_match.match_id
# Assign participants to first round
participant_iter = iter(self.participants)
first_round = rounds[0]
for match in first_round:
try:
p1 = next(participant_iter)
match.assign_participant(p1.id, 1)
except StopIteration:
continue
try:
p2 = next(participant_iter)
match.assign_participant(p2.id, 2)
except StopIteration:
if not match.is_fully_seeded: # Auto-Bye
for game in match.games:
match.report_game_result(game.number, p1.id, (1, 0))
continue
if match.is_fully_seeded:
match.status = MatchStatus.PENDING
# Flatten all rounds
self._matches = [m for round_matches in rounds for m in round_matches]
elif bracket_type == "DOUBLE":
# --- double-elimination bracket generation ---
# ToDo: Rounds are not correctly persisted into self._rounds here. What data structure to use?
# ToDo: Bye-Handling not done
# Implementation Notice: Do not implement yet!
num_rounds_upper = ceil(log2(num_participants))
upper_rounds: list[list[Match]] = []
for round_number in range(1, num_rounds_upper + 1):
num_matches = 2 ** (num_rounds_upper - round_number)
round_matches = []
for _ in range(num_matches):
match = Match(
match_id=match_id_counter,
tournament_id=self._id,
round_number=round_number,
bracket=Bracket.UPPER,
best_of=best_of,
status=MatchStatus.WAITING,
next_match_win_lose_ids=(None, None), # will fill later
match_has_ended_callback=self.match_has_ended_callback
)
round_matches.append(match)
match_id_counter += 1
upper_rounds.append(round_matches)
# Lower bracket (Losers)
# Double-elim lower bracket has roughly (2*num_rounds_upper - 2) rounds
num_rounds_lower = 2 * (num_rounds_upper - 1)
lower_rounds: list[list[Match]] = []
for round_number in range(1, num_rounds_lower + 1):
num_matches = 2 ** (num_rounds_lower - round_number - 1) if round_number != 1 else 2 ** (num_rounds_upper - 1)
round_matches = []
for _ in range(num_matches):
match = Match(
match_id=match_id_counter,
tournament_id=self._id,
round_number=round_number,
bracket=Bracket.LOWER,
best_of=best_of,
status=MatchStatus.WAITING,
next_match_win_lose_ids=(None, None),
match_has_ended_callback=self.match_has_ended_callback
)
round_matches.append(match)
match_id_counter += 1
lower_rounds.append(round_matches)
# Link upper bracket winners to next upper-round matches
for i in range(len(upper_rounds) - 1):
for idx, match in enumerate(upper_rounds[i]):
next_match = upper_rounds[i + 1][idx // 2]
match._next_match_win_id = next_match.match_id
# Link upper bracket losers to lower bracket first rounds
lower_round1 = lower_rounds[0] if lower_rounds else []
for idx, match in enumerate(upper_rounds[0]):
if idx < len(lower_round1):
match._next_match_lose_id = lower_round1[idx].match_id
# Link lower bracket winners to next lower-round matches
for i in range(len(lower_rounds) - 1):
for idx, match in enumerate(lower_rounds[i]):
next_match = lower_rounds[i + 1][idx // 2]
match._next_match_win_id = next_match.match_id
# Final match
final_match = Match(
match_id=match_id_counter,
tournament_id=self._id,
round_number=max(num_rounds_upper, num_rounds_lower) + 1,
bracket=Bracket.FINAL,
best_of=best_of,
status=MatchStatus.WAITING,
next_match_win_lose_ids=(None, None),
match_has_ended_callback=self.match_has_ended_callback
)
match_id_counter += 1
# Last upper winner and last lower winner feed into final
if upper_rounds:
upper_last = upper_rounds[-1][0]
upper_last._next_match_win_id = final_match.match_id
if lower_rounds:
lower_last = lower_rounds[-1][0]
lower_last._next_match_win_id = final_match.match_id
# Flatten all matches
self._matches = [m for round_matches in upper_rounds + lower_rounds for m in round_matches] + [final_match]
# Assign participants to first upper round
participant_iter = iter(self._participants)
first_upper = upper_rounds[0]
for match in first_upper:
try:
p1 = next(participant_iter)
match.assign_participant(p1.id, 1)
except StopIteration:
continue
try:
p2 = next(participant_iter)
match.assign_participant(p2.id, 2)
except StopIteration:
if not match.is_fully_seeded: # Auto-Bye
for game in match.games:
match.report_game_result(game.number, p1.id, (1, 0))
match.check_completion()
continue
if match.is_fully_seeded:
match.status = MatchStatus.PENDING
else:
raise TournamentError(f"Unknown bracket type: {bracket_type}")
self._status = TournamentStatus.ONGOING
for match in self._matches:
match.check_completion()
def generate_new_tournament(name: str, game_title: GameTitle, format_: TournamentFormat, start_time: datetime, initial_status: TournamentStatus = TournamentStatus.CLOSED) -> Tournament:
id_ = uuid.uuid4().int
return Tournament(
id_,
name,
game_title,
format_,
start_time,
initial_status,
list(),
None,
list()
)

View File

@ -0,0 +1,51 @@
from dataclasses import dataclass
from enum import Enum
@dataclass
class GameTitle:
name: str
description: str
web_link: str
class TournamentFormat(Enum):
SINGLE_ELIMINATION_BO_1 = 1
SINGLE_ELIMINATION_BO_3 = 2
SINGLE_ELIMINATION_BO_5 = 3
DOUBLE_ELIMINATION_BO_1 = 4
DOUBLE_ELIMINATION_BO_3 = 5
DOUBLE_ELIMINATION_BO_5 = 6
class TournamentStatus(Enum):
CLOSED = 1
OPEN = 2
COMPLETED = 3
CANCELED = 4
INVITE_ONLY = 5 # For Show-matches
ONGOING = 6
class TournamentError(Exception):
def __init__(self, message: str) -> None:
super().__init__(message)
class ParticipantType(Enum):
PLAYER = 1
TEAM = 2 # ToDo: Teams are not yet supported
class Bracket(Enum):
UPPER = 1
LOWER = 2
FINAL = 3
class MatchStatus(Enum):
WAITING = 1 # Participants incomplete
PENDING = 2 # Match is ready to be played
DELAYED = 3 # Same as PENDING, but with flag for UI
COMPLETED = 4 # Match has been played
CANCELED = 5 # Match got canceled, "bye" for followup

View File

@ -0,0 +1,66 @@
import unittest
from datetime import datetime
from src.ezgg_lan_manager.types.TournamentBase import ParticipantType
from src.ezgg_lan_manager.types.Tournament import generate_new_tournament, GameTitle, TournamentFormat, TournamentStatus, TournamentError, Participant, MatchStatus
class TournamentDomainTests(unittest.TestCase):
def setUp(self):
# Generic Tournament config
self.name = "Tetris 1vs1"
self.game_title = GameTitle("Tetris99", "Some Description", "https://de.wikipedia.org/wiki/Tetris_99")
self.format_ = TournamentFormat.SINGLE_ELIMINATION_BO_3
self.start_time = datetime(year=2100, month=6, day=23, hour=16, minute=30, second=0)
self.initial_status = TournamentStatus.CLOSED
# Generic Participants
self.participant_a = Participant(1, "CoolUserName", ParticipantType.PLAYER)
self.participant_b = Participant(2, "CrazyUserName", ParticipantType.PLAYER)
self.participant_c = Participant(3, "FunnyUserName", ParticipantType.PLAYER)
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)
with self.assertRaises(TournamentError):
tournament_under_test.start()
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.add_participant(self.participant_a)
with self.assertRaises(TournamentError):
tournament_under_test.add_participant(self.participant_a)
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.add_participant(self.participant_a)
tournament_under_test.add_participant(self.participant_b)
tournament_under_test.add_participant(self.participant_c)
tournament_under_test.start()
# Assert Tournament was switched to ONGOING
self.assertEqual(TournamentStatus.ONGOING, tournament_under_test.status)
matches_in_tournament = sorted(tournament_under_test.matches, key=lambda m: m.match_id)
# First match
fm = matches_in_tournament[0]
self.assertEqual(fm.status, MatchStatus.PENDING)
self.assertEqual(fm.participants[0].participant_id, self.participant_a.id)
self.assertEqual(fm.participants[0].slot_number, 1)
self.assertEqual(fm.participants[1].participant_id, self.participant_b.id)
self.assertEqual(fm.participants[1].slot_number, 2)
# Second match (Bye)
sm = matches_in_tournament[1]
self.assertEqual(sm.status, MatchStatus.COMPLETED)
self.assertEqual(sm.participants[0].participant_id, self.participant_c.id)
self.assertEqual(sm.participants[0].slot_number, 1)
self.assertEqual(sm.participants[0].participant_id, sm.winner)
# Third match (Final)
sm = matches_in_tournament[2]
self.assertEqual(sm.status, MatchStatus.WAITING)
self.assertEqual(sm.participants[0].participant_id, self.participant_c.id)
self.assertEqual(sm.participants[0].slot_number, 1)
self.assertIsNone(sm.winner)