riotskillissue 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- riotskillissue/cli.py +57 -1
- riotskillissue/tui.py +733 -0
- riotskillissue-0.1.4.dist-info/METADATA +152 -0
- {riotskillissue-0.1.2.dist-info → riotskillissue-0.1.4.dist-info}/RECORD +7 -6
- riotskillissue-0.1.4.dist-info/licenses/LICENSE +21 -0
- riotskillissue-0.1.2.dist-info/METADATA +0 -168
- riotskillissue-0.1.2.dist-info/licenses/LICENSE +0 -676
- {riotskillissue-0.1.2.dist-info → riotskillissue-0.1.4.dist-info}/WHEEL +0 -0
- {riotskillissue-0.1.2.dist-info → riotskillissue-0.1.4.dist-info}/entry_points.txt +0 -0
riotskillissue/tui.py
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live Game TUI — Terminal User Interface for spectating League of Legends matches.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
riotskillissue-cli live "GameName#TagLine" --region euw1
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from textual.app import App, ComposeResult
|
|
14
|
+
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
15
|
+
from textual.reactive import reactive
|
|
16
|
+
from textual.timer import Timer
|
|
17
|
+
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
|
|
18
|
+
from textual.binding import Binding
|
|
19
|
+
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.align import Align
|
|
24
|
+
from rich.console import Group
|
|
25
|
+
|
|
26
|
+
from .core.client import RiotClient, RiotClientConfig
|
|
27
|
+
from .core.types import Region, Platform, REGION_TO_PLATFORM
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Static Mappings ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
SUMMONER_SPELLS: Dict[int, str] = {
|
|
33
|
+
1: "Cleanse",
|
|
34
|
+
3: "Exhaust",
|
|
35
|
+
4: "Flash",
|
|
36
|
+
6: "Ghost",
|
|
37
|
+
7: "Heal",
|
|
38
|
+
11: "Smite",
|
|
39
|
+
12: "Teleport",
|
|
40
|
+
13: "Clarity",
|
|
41
|
+
14: "Ignite",
|
|
42
|
+
21: "Barrier",
|
|
43
|
+
30: "To the King!",
|
|
44
|
+
31: "Poro Toss",
|
|
45
|
+
32: "Mark",
|
|
46
|
+
39: "Mark",
|
|
47
|
+
54: "Placeholder",
|
|
48
|
+
55: "Placeholder",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
QUEUE_TYPES: Dict[int, str] = {
|
|
52
|
+
0: "Custom",
|
|
53
|
+
400: "Normal Draft",
|
|
54
|
+
420: "Ranked Solo/Duo",
|
|
55
|
+
430: "Normal Blind",
|
|
56
|
+
440: "Ranked Flex",
|
|
57
|
+
450: "ARAM",
|
|
58
|
+
490: "Quickplay",
|
|
59
|
+
700: "Clash",
|
|
60
|
+
720: "ARAM Clash",
|
|
61
|
+
830: "Co-op vs. AI (Intro)",
|
|
62
|
+
840: "Co-op vs. AI (Beginner)",
|
|
63
|
+
850: "Co-op vs. AI (Intermediate)",
|
|
64
|
+
900: "ARURF",
|
|
65
|
+
1020: "One for All",
|
|
66
|
+
1090: "TFT Normal",
|
|
67
|
+
1100: "TFT Ranked",
|
|
68
|
+
1300: "Nexus Blitz",
|
|
69
|
+
1400: "Ultimate Spellbook",
|
|
70
|
+
1700: "Arena",
|
|
71
|
+
1710: "Arena",
|
|
72
|
+
1900: "Pick URF",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
TIER_ORDER = {
|
|
76
|
+
"IRON": 0, "BRONZE": 1, "SILVER": 2, "GOLD": 3,
|
|
77
|
+
"PLATINUM": 4, "EMERALD": 5, "DIAMOND": 6,
|
|
78
|
+
"MASTER": 7, "GRANDMASTER": 8, "CHALLENGER": 9,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
TIER_COLORS = {
|
|
82
|
+
"IRON": "#8B8682",
|
|
83
|
+
"BRONZE": "#CD7F32",
|
|
84
|
+
"SILVER": "#C0C0C0",
|
|
85
|
+
"GOLD": "#FFD700",
|
|
86
|
+
"PLATINUM": "#40E0D0",
|
|
87
|
+
"EMERALD": "#50C878",
|
|
88
|
+
"DIAMOND": "#B9F2FF",
|
|
89
|
+
"MASTER": "#9B59B6",
|
|
90
|
+
"GRANDMASTER": "#E74C3C",
|
|
91
|
+
"CHALLENGER": "#F39C12",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _spell_name(spell_id: int) -> str:
|
|
96
|
+
return SUMMONER_SPELLS.get(spell_id, f"?({spell_id})")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _queue_name(queue_id: int) -> str:
|
|
100
|
+
return QUEUE_TYPES.get(queue_id, f"Queue {queue_id}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _format_duration(seconds: int) -> str:
|
|
104
|
+
"""Format game duration as MM:SS."""
|
|
105
|
+
minutes = seconds // 60
|
|
106
|
+
secs = seconds % 60
|
|
107
|
+
return f"{minutes}:{secs:02d}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rank_string(entry: Optional[Dict[str, Any]]) -> str:
|
|
111
|
+
"""Format a league entry into a rank string like 'Diamond II 45 LP'."""
|
|
112
|
+
if not entry:
|
|
113
|
+
return "Unranked"
|
|
114
|
+
tier = entry.get("tier", "?")
|
|
115
|
+
rank = entry.get("rank", "")
|
|
116
|
+
lp = entry.get("leaguePoints", 0)
|
|
117
|
+
return f"{tier.capitalize()} {rank} {lp} LP"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _rank_color(entry: Optional[Dict[str, Any]]) -> str:
|
|
121
|
+
if not entry:
|
|
122
|
+
return "#808080"
|
|
123
|
+
tier = entry.get("tier", "").upper()
|
|
124
|
+
return TIER_COLORS.get(tier, "#FFFFFF")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _win_rate(entry: Optional[Dict[str, Any]]) -> str:
|
|
128
|
+
if not entry:
|
|
129
|
+
return ""
|
|
130
|
+
wins = entry.get("wins", 0)
|
|
131
|
+
losses = entry.get("losses", 0)
|
|
132
|
+
total = wins + losses
|
|
133
|
+
if total == 0:
|
|
134
|
+
return "0 games"
|
|
135
|
+
wr = (wins / total) * 100
|
|
136
|
+
return f"{wins}W {losses}L ({wr:.0f}%)"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Data Fetching ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async def fetch_live_game_data(
|
|
142
|
+
api_key: str,
|
|
143
|
+
game_name: str,
|
|
144
|
+
tag_line: str,
|
|
145
|
+
region: str,
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Fetch all data needed for the live game TUI.
|
|
149
|
+
Returns a dict with game info, player data, champion names, and ranks.
|
|
150
|
+
"""
|
|
151
|
+
region_lower = region.lower()
|
|
152
|
+
|
|
153
|
+
# Map region to platform cluster for account API
|
|
154
|
+
try:
|
|
155
|
+
region_enum = Region(region_lower)
|
|
156
|
+
platform = REGION_TO_PLATFORM[region_enum]
|
|
157
|
+
cluster = platform.value
|
|
158
|
+
except (ValueError, KeyError):
|
|
159
|
+
cluster = "americas"
|
|
160
|
+
|
|
161
|
+
config = RiotClientConfig(api_key=api_key)
|
|
162
|
+
async with RiotClient(config=config) as client:
|
|
163
|
+
# 1) Resolve Riot ID → PUUID
|
|
164
|
+
account = await client.account.get_by_riot_id(
|
|
165
|
+
region=cluster, gameName=game_name, tagLine=tag_line
|
|
166
|
+
)
|
|
167
|
+
puuid = account.puuid
|
|
168
|
+
|
|
169
|
+
# 2) Get live game
|
|
170
|
+
try:
|
|
171
|
+
game = await client.spectator.get_current_game_info_by_puuid(
|
|
172
|
+
region=region_lower, encryptedPUUID=puuid
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
error_msg = str(e)
|
|
176
|
+
if "404" in error_msg or "not found" in error_msg.lower() or "Data not found" in error_msg:
|
|
177
|
+
return {"error": "not_in_game", "player": f"{game_name}#{tag_line}"}
|
|
178
|
+
raise
|
|
179
|
+
|
|
180
|
+
# 3) Resolve champion data & ranks for all participants concurrently
|
|
181
|
+
participants = game.participants or []
|
|
182
|
+
|
|
183
|
+
async def resolve_participant(p: Any) -> Dict[str, Any]:
|
|
184
|
+
"""Resolve champion name and rank for a single participant."""
|
|
185
|
+
champ_data = await client.static.get_champion(p.championId)
|
|
186
|
+
champ_name = champ_data["name"] if champ_data else f"Champion {p.championId}"
|
|
187
|
+
champ_image = champ_data.get("image", {}).get("full", "") if champ_data else ""
|
|
188
|
+
|
|
189
|
+
# Get rank
|
|
190
|
+
rank_entry = None
|
|
191
|
+
try:
|
|
192
|
+
entries = await client.league.get_league_entries_by_puuid(
|
|
193
|
+
region=region_lower, encryptedPUUID=p.puuid
|
|
194
|
+
)
|
|
195
|
+
# Find Solo/Duo rank first, then Flex
|
|
196
|
+
for e in entries:
|
|
197
|
+
qt = getattr(e, "queueType", None) or e.get("queueType", "")
|
|
198
|
+
if qt == "RANKED_SOLO_5x5":
|
|
199
|
+
rank_entry = e
|
|
200
|
+
break
|
|
201
|
+
if not rank_entry:
|
|
202
|
+
for e in entries:
|
|
203
|
+
qt = getattr(e, "queueType", None) or e.get("queueType", "")
|
|
204
|
+
if qt == "RANKED_FLEX_SR":
|
|
205
|
+
rank_entry = e
|
|
206
|
+
break
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# Convert model to dict if needed
|
|
211
|
+
rank_dict = None
|
|
212
|
+
if rank_entry:
|
|
213
|
+
if hasattr(rank_entry, "model_dump"):
|
|
214
|
+
rank_dict = rank_entry.model_dump()
|
|
215
|
+
elif hasattr(rank_entry, "__dict__"):
|
|
216
|
+
rank_dict = vars(rank_entry)
|
|
217
|
+
elif isinstance(rank_entry, dict):
|
|
218
|
+
rank_dict = rank_entry
|
|
219
|
+
|
|
220
|
+
riot_id = getattr(p, "riotId", None) or "Unknown"
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"champion": champ_name,
|
|
224
|
+
"champion_image": champ_image,
|
|
225
|
+
"champion_id": p.championId,
|
|
226
|
+
"riot_id": riot_id,
|
|
227
|
+
"puuid": p.puuid,
|
|
228
|
+
"team_id": p.teamId,
|
|
229
|
+
"spell1": _spell_name(p.spell1Id),
|
|
230
|
+
"spell2": _spell_name(p.spell2Id),
|
|
231
|
+
"is_bot": getattr(p, "bot", False),
|
|
232
|
+
"rank": rank_dict,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Fetch all participant data concurrently
|
|
236
|
+
player_data = await asyncio.gather(
|
|
237
|
+
*[resolve_participant(p) for p in participants],
|
|
238
|
+
return_exceptions=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Filter out any exceptions
|
|
242
|
+
resolved_players: List[Dict[str, Any]] = []
|
|
243
|
+
for pd in player_data:
|
|
244
|
+
if isinstance(pd, Exception):
|
|
245
|
+
resolved_players.append({
|
|
246
|
+
"champion": "Unknown",
|
|
247
|
+
"champion_image": "",
|
|
248
|
+
"champion_id": 0,
|
|
249
|
+
"riot_id": "Error",
|
|
250
|
+
"puuid": "",
|
|
251
|
+
"team_id": 0,
|
|
252
|
+
"spell1": "?",
|
|
253
|
+
"spell2": "?",
|
|
254
|
+
"is_bot": False,
|
|
255
|
+
"rank": None,
|
|
256
|
+
})
|
|
257
|
+
else:
|
|
258
|
+
resolved_players.append(pd)
|
|
259
|
+
|
|
260
|
+
# Split teams
|
|
261
|
+
blue_team = [p for p in resolved_players if p["team_id"] == 100]
|
|
262
|
+
red_team = [p for p in resolved_players if p["team_id"] == 200]
|
|
263
|
+
|
|
264
|
+
# Banned champions
|
|
265
|
+
bans_raw = getattr(game, "bannedChampions", []) or []
|
|
266
|
+
bans = []
|
|
267
|
+
for b in bans_raw:
|
|
268
|
+
cid = getattr(b, "championId", -1)
|
|
269
|
+
if cid > 0:
|
|
270
|
+
cd = await client.static.get_champion(cid)
|
|
271
|
+
bans.append(cd["name"] if cd else f"#{cid}")
|
|
272
|
+
|
|
273
|
+
game_length = getattr(game, "gameLength", 0) or 0
|
|
274
|
+
queue_id = getattr(game, "gameQueueConfigId", 0) or 0
|
|
275
|
+
game_mode = getattr(game, "gameMode", "CLASSIC")
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
"error": None,
|
|
279
|
+
"player": f"{game_name}#{tag_line}",
|
|
280
|
+
"game_mode": game_mode,
|
|
281
|
+
"queue": _queue_name(queue_id),
|
|
282
|
+
"queue_id": queue_id,
|
|
283
|
+
"game_length": game_length,
|
|
284
|
+
"blue_team": blue_team,
|
|
285
|
+
"red_team": red_team,
|
|
286
|
+
"bans": bans,
|
|
287
|
+
"game_id": getattr(game, "gameId", "?"),
|
|
288
|
+
"platform_id": getattr(game, "platformId", region_lower.upper()),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── TUI Widgets ──────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
class GameHeader(Static):
|
|
295
|
+
"""Displays game info header (mode, duration, etc.)."""
|
|
296
|
+
|
|
297
|
+
def render_header(self, data: Dict[str, Any]) -> Table:
|
|
298
|
+
grid = Table.grid(padding=(0, 2))
|
|
299
|
+
grid.add_column(justify="center")
|
|
300
|
+
grid.add_column(justify="center")
|
|
301
|
+
grid.add_column(justify="center")
|
|
302
|
+
|
|
303
|
+
queue = data.get("queue", "Unknown")
|
|
304
|
+
duration = _format_duration(data.get("game_length", 0))
|
|
305
|
+
game_id = data.get("game_id", "?")
|
|
306
|
+
platform = data.get("platform_id", "?")
|
|
307
|
+
|
|
308
|
+
grid.add_row(
|
|
309
|
+
Text(f"⏱ {duration}", style="bold white"),
|
|
310
|
+
Text(f"🎮 {queue}", style="bold cyan"),
|
|
311
|
+
Text(f"📍 {platform}", style="dim"),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return grid
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class TeamTable(Static):
|
|
318
|
+
"""Renders a team as a Rich table."""
|
|
319
|
+
|
|
320
|
+
def build_table(
|
|
321
|
+
self, team_name: str, players: List[Dict[str, Any]], color: str
|
|
322
|
+
) -> Table:
|
|
323
|
+
table = Table(
|
|
324
|
+
title=f" {team_name}",
|
|
325
|
+
title_style=f"bold {color}",
|
|
326
|
+
border_style=color,
|
|
327
|
+
expand=True,
|
|
328
|
+
show_edge=True,
|
|
329
|
+
pad_edge=True,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
table.add_column("Champion", style="bold white", min_width=14)
|
|
333
|
+
table.add_column("Player", style="white", min_width=18)
|
|
334
|
+
table.add_column("Rank", min_width=20)
|
|
335
|
+
table.add_column("Win Rate", min_width=16)
|
|
336
|
+
table.add_column("Spells", style="yellow", min_width=14)
|
|
337
|
+
|
|
338
|
+
for p in players:
|
|
339
|
+
rank_text = Text(
|
|
340
|
+
_rank_string(p.get("rank")),
|
|
341
|
+
style=_rank_color(p.get("rank")),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
wr_text = _win_rate(p.get("rank"))
|
|
345
|
+
wr_style = ""
|
|
346
|
+
if wr_text:
|
|
347
|
+
# Color win rate
|
|
348
|
+
try:
|
|
349
|
+
pct = float(wr_text.split("(")[1].rstrip("%)"))
|
|
350
|
+
if pct >= 55:
|
|
351
|
+
wr_style = "green"
|
|
352
|
+
elif pct <= 45:
|
|
353
|
+
wr_style = "red"
|
|
354
|
+
else:
|
|
355
|
+
wr_style = "white"
|
|
356
|
+
except (IndexError, ValueError):
|
|
357
|
+
wr_style = "white"
|
|
358
|
+
|
|
359
|
+
spells = f"{p['spell1']} / {p['spell2']}"
|
|
360
|
+
bot_tag = " 🤖" if p.get("is_bot") else ""
|
|
361
|
+
|
|
362
|
+
table.add_row(
|
|
363
|
+
Text(p["champion"], style="bold"),
|
|
364
|
+
Text(f"{p['riot_id']}{bot_tag}"),
|
|
365
|
+
rank_text,
|
|
366
|
+
Text(wr_text, style=wr_style),
|
|
367
|
+
spells,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return table
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class BansWidget(Static):
|
|
374
|
+
"""Displays banned champions."""
|
|
375
|
+
|
|
376
|
+
def build_bans(self, bans: List[str]) -> Text:
|
|
377
|
+
if not bans:
|
|
378
|
+
return Text("No bans", style="dim")
|
|
379
|
+
text = Text("🚫 Bans: ", style="bold red")
|
|
380
|
+
for i, ban in enumerate(bans):
|
|
381
|
+
text.append(ban, style="white")
|
|
382
|
+
if i < len(bans) - 1:
|
|
383
|
+
text.append(" · ", style="dim")
|
|
384
|
+
return text
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class NotInGameWidget(Static):
|
|
388
|
+
"""Shown when the player is not currently in a game."""
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class ErrorWidget(Static):
|
|
393
|
+
"""Shown when an error occurs."""
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class LiveGameContent(Static):
|
|
398
|
+
"""Main content area that composes the game display."""
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ── Main TUI App ─────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
LIVE_GAME_CSS = """
|
|
405
|
+
Screen {
|
|
406
|
+
background: $surface;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#main-container {
|
|
410
|
+
width: 100%;
|
|
411
|
+
height: 100%;
|
|
412
|
+
padding: 1 2;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#loading-container {
|
|
416
|
+
width: 100%;
|
|
417
|
+
height: 100%;
|
|
418
|
+
align: center middle;
|
|
419
|
+
content-align: center middle;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#loading-label {
|
|
423
|
+
text-align: center;
|
|
424
|
+
width: 100%;
|
|
425
|
+
margin-bottom: 1;
|
|
426
|
+
color: $text;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#game-header {
|
|
430
|
+
width: 100%;
|
|
431
|
+
height: auto;
|
|
432
|
+
content-align: center middle;
|
|
433
|
+
margin-bottom: 1;
|
|
434
|
+
padding: 1;
|
|
435
|
+
border: solid $primary;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#team-blue {
|
|
439
|
+
width: 100%;
|
|
440
|
+
height: auto;
|
|
441
|
+
margin-bottom: 1;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#team-red {
|
|
445
|
+
width: 100%;
|
|
446
|
+
height: auto;
|
|
447
|
+
margin-bottom: 1;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#bans-widget {
|
|
451
|
+
width: 100%;
|
|
452
|
+
height: auto;
|
|
453
|
+
padding: 0 1;
|
|
454
|
+
margin-bottom: 1;
|
|
455
|
+
content-align: center middle;
|
|
456
|
+
text-align: center;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#status-bar {
|
|
460
|
+
dock: bottom;
|
|
461
|
+
width: 100%;
|
|
462
|
+
height: 1;
|
|
463
|
+
background: $primary-background;
|
|
464
|
+
color: $text;
|
|
465
|
+
padding: 0 2;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#not-in-game {
|
|
469
|
+
width: 100%;
|
|
470
|
+
height: 100%;
|
|
471
|
+
align: center middle;
|
|
472
|
+
content-align: center middle;
|
|
473
|
+
padding: 4;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#error-widget {
|
|
477
|
+
width: 100%;
|
|
478
|
+
height: 100%;
|
|
479
|
+
align: center middle;
|
|
480
|
+
content-align: center middle;
|
|
481
|
+
padding: 4;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.team-label {
|
|
485
|
+
text-align: center;
|
|
486
|
+
width: 100%;
|
|
487
|
+
text-style: bold;
|
|
488
|
+
margin-bottom: 0;
|
|
489
|
+
}
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class LiveGameApp(App):
|
|
494
|
+
"""TUI application for viewing live League of Legends matches."""
|
|
495
|
+
|
|
496
|
+
CSS = LIVE_GAME_CSS. \
|
|
497
|
+
replace(" ", " ") # keep as-is
|
|
498
|
+
|
|
499
|
+
TITLE = "RiotSkillIssue — Live Game"
|
|
500
|
+
SUB_TITLE = "League of Legends"
|
|
501
|
+
|
|
502
|
+
BINDINGS = [
|
|
503
|
+
Binding("q", "quit", "Quit", show=True),
|
|
504
|
+
Binding("escape", "quit", "Quit", show=False),
|
|
505
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
# Reactive data
|
|
509
|
+
game_data: reactive[Optional[Dict[str, Any]]] = reactive(None)
|
|
510
|
+
is_loading: reactive[bool] = reactive(True)
|
|
511
|
+
last_error: reactive[Optional[str]] = reactive(None)
|
|
512
|
+
refresh_countdown: reactive[int] = reactive(30)
|
|
513
|
+
|
|
514
|
+
def __init__(
|
|
515
|
+
self,
|
|
516
|
+
api_key: str,
|
|
517
|
+
game_name: str,
|
|
518
|
+
tag_line: str,
|
|
519
|
+
region: str = "euw1",
|
|
520
|
+
auto_refresh: int = 30,
|
|
521
|
+
**kwargs: Any,
|
|
522
|
+
):
|
|
523
|
+
super().__init__(**kwargs)
|
|
524
|
+
self.api_key = api_key
|
|
525
|
+
self.game_name = game_name
|
|
526
|
+
self.tag_line = tag_line
|
|
527
|
+
self.region = region
|
|
528
|
+
self.auto_refresh_interval = auto_refresh
|
|
529
|
+
self.refresh_countdown = auto_refresh
|
|
530
|
+
self._refresh_timer: Optional[Timer] = None
|
|
531
|
+
self._countdown_timer: Optional[Timer] = None
|
|
532
|
+
|
|
533
|
+
def compose(self) -> ComposeResult:
|
|
534
|
+
yield Header()
|
|
535
|
+
yield VerticalScroll(id="main-container")
|
|
536
|
+
yield Footer()
|
|
537
|
+
|
|
538
|
+
async def on_mount(self) -> None:
|
|
539
|
+
"""Called when the app is mounted — start first data fetch."""
|
|
540
|
+
await self._load_data()
|
|
541
|
+
# Start auto-refresh
|
|
542
|
+
self._refresh_timer = self.set_interval(
|
|
543
|
+
self.auto_refresh_interval, self._auto_refresh
|
|
544
|
+
)
|
|
545
|
+
self._countdown_timer = self.set_interval(1, self._tick_countdown)
|
|
546
|
+
|
|
547
|
+
async def _auto_refresh(self) -> None:
|
|
548
|
+
"""Auto-refresh callback."""
|
|
549
|
+
self.refresh_countdown = self.auto_refresh_interval
|
|
550
|
+
await self._load_data()
|
|
551
|
+
|
|
552
|
+
def _tick_countdown(self) -> None:
|
|
553
|
+
"""Count down the refresh timer."""
|
|
554
|
+
if self.refresh_countdown > 0:
|
|
555
|
+
self.refresh_countdown -= 1
|
|
556
|
+
|
|
557
|
+
async def action_refresh(self) -> None:
|
|
558
|
+
"""Manual refresh triggered by 'r' key."""
|
|
559
|
+
self.refresh_countdown = self.auto_refresh_interval
|
|
560
|
+
await self._load_data()
|
|
561
|
+
|
|
562
|
+
async def _load_data(self) -> None:
|
|
563
|
+
"""Fetch live game data and update the display."""
|
|
564
|
+
self.is_loading = True
|
|
565
|
+
self.last_error = None
|
|
566
|
+
|
|
567
|
+
container = self.query_one("#main-container")
|
|
568
|
+
|
|
569
|
+
# Show loading state
|
|
570
|
+
await container.remove_children()
|
|
571
|
+
await container.mount(
|
|
572
|
+
Vertical(
|
|
573
|
+
Label("🔄 Fetching live game data...", id="loading-label"),
|
|
574
|
+
LoadingIndicator(),
|
|
575
|
+
id="loading-container",
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
data = await fetch_live_game_data(
|
|
581
|
+
api_key=self.api_key,
|
|
582
|
+
game_name=self.game_name,
|
|
583
|
+
tag_line=self.tag_line,
|
|
584
|
+
region=self.region,
|
|
585
|
+
)
|
|
586
|
+
self.game_data = data
|
|
587
|
+
self.is_loading = False
|
|
588
|
+
await self._render_game(data)
|
|
589
|
+
except Exception as e:
|
|
590
|
+
self.is_loading = False
|
|
591
|
+
self.last_error = str(e)
|
|
592
|
+
await self._render_error(str(e))
|
|
593
|
+
|
|
594
|
+
async def _render_game(self, data: Dict[str, Any]) -> None:
|
|
595
|
+
"""Render the game data into the TUI."""
|
|
596
|
+
container = self.query_one("#main-container")
|
|
597
|
+
await container.remove_children()
|
|
598
|
+
|
|
599
|
+
# Not in game
|
|
600
|
+
if data.get("error") == "not_in_game":
|
|
601
|
+
player = data.get("player", "?")
|
|
602
|
+
widget = NotInGameWidget(id="not-in-game")
|
|
603
|
+
widget.update(
|
|
604
|
+
Panel(
|
|
605
|
+
Align.center(
|
|
606
|
+
Group(
|
|
607
|
+
Text(f"\n🎮 {player}", style="bold white", justify="center"),
|
|
608
|
+
Text(""),
|
|
609
|
+
Text(
|
|
610
|
+
"Not currently in a game.",
|
|
611
|
+
style="bold yellow",
|
|
612
|
+
justify="center",
|
|
613
|
+
),
|
|
614
|
+
Text(""),
|
|
615
|
+
Text(
|
|
616
|
+
"The TUI will auto-refresh and show the game once it starts.",
|
|
617
|
+
style="dim",
|
|
618
|
+
justify="center",
|
|
619
|
+
),
|
|
620
|
+
Text(
|
|
621
|
+
f"Next refresh in {self.refresh_countdown}s • Press [R] to refresh now",
|
|
622
|
+
style="dim italic",
|
|
623
|
+
justify="center",
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
),
|
|
627
|
+
title="Live Game",
|
|
628
|
+
border_style="yellow",
|
|
629
|
+
padding=(2, 4),
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
await container.mount(widget)
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
# ── Game Header ──
|
|
636
|
+
header = GameHeader(id="game-header")
|
|
637
|
+
|
|
638
|
+
header_table = header.render_header(data)
|
|
639
|
+
game_title = Text(
|
|
640
|
+
f"🎮 LIVE GAME — {data['queue']}",
|
|
641
|
+
style="bold cyan",
|
|
642
|
+
justify="center",
|
|
643
|
+
)
|
|
644
|
+
countdown_text = Text(
|
|
645
|
+
f"Auto-refresh in {self.refresh_countdown}s",
|
|
646
|
+
style="dim italic",
|
|
647
|
+
justify="center",
|
|
648
|
+
)
|
|
649
|
+
header.update(
|
|
650
|
+
Panel(
|
|
651
|
+
Align.center(Group(game_title, Text(""), header_table, countdown_text)),
|
|
652
|
+
border_style="cyan",
|
|
653
|
+
padding=(0, 2),
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
await container.mount(header)
|
|
657
|
+
|
|
658
|
+
# ── Blue Team ──
|
|
659
|
+
blue_widget = TeamTable(id="team-blue")
|
|
660
|
+
blue_table = blue_widget.build_table(
|
|
661
|
+
"🔵 BLUE TEAM", data.get("blue_team", []), "#3498DB"
|
|
662
|
+
)
|
|
663
|
+
blue_widget.update(blue_table)
|
|
664
|
+
await container.mount(blue_widget)
|
|
665
|
+
|
|
666
|
+
# ── Bans ──
|
|
667
|
+
bans_widget = BansWidget(id="bans-widget")
|
|
668
|
+
bans_text = bans_widget.build_bans(data.get("bans", []))
|
|
669
|
+
bans_widget.update(bans_text)
|
|
670
|
+
await container.mount(bans_widget)
|
|
671
|
+
|
|
672
|
+
# ── Red Team ──
|
|
673
|
+
red_widget = TeamTable(id="team-red")
|
|
674
|
+
red_table = red_widget.build_table(
|
|
675
|
+
"🔴 RED TEAM", data.get("red_team", []), "#E74C3C"
|
|
676
|
+
)
|
|
677
|
+
red_widget.update(red_table)
|
|
678
|
+
await container.mount(red_widget)
|
|
679
|
+
|
|
680
|
+
async def _render_error(self, error: str) -> None:
|
|
681
|
+
"""Render an error message."""
|
|
682
|
+
container = self.query_one("#main-container")
|
|
683
|
+
await container.remove_children()
|
|
684
|
+
|
|
685
|
+
widget = ErrorWidget(id="error-widget")
|
|
686
|
+
widget.update(
|
|
687
|
+
Panel(
|
|
688
|
+
Align.center(
|
|
689
|
+
Group(
|
|
690
|
+
Text("❌ Error", style="bold red", justify="center"),
|
|
691
|
+
Text(""),
|
|
692
|
+
Text(str(error), style="white", justify="center"),
|
|
693
|
+
Text(""),
|
|
694
|
+
Text(
|
|
695
|
+
"Press [R] to retry • Press [Q] to quit",
|
|
696
|
+
style="dim italic",
|
|
697
|
+
justify="center",
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
),
|
|
701
|
+
title="Error",
|
|
702
|
+
border_style="red",
|
|
703
|
+
padding=(2, 4),
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
await container.mount(widget)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def run_tui(
|
|
710
|
+
api_key: str,
|
|
711
|
+
game_name: str,
|
|
712
|
+
tag_line: str,
|
|
713
|
+
region: str = "euw1",
|
|
714
|
+
auto_refresh: int = 30,
|
|
715
|
+
) -> None:
|
|
716
|
+
"""
|
|
717
|
+
Launch the Live Game TUI.
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
api_key: Riot API key.
|
|
721
|
+
game_name: Player's game name (e.g. "Agurin").
|
|
722
|
+
tag_line: Player's tag line (e.g. "EUW").
|
|
723
|
+
region: Regional server (e.g. "euw1", "na1").
|
|
724
|
+
auto_refresh: Auto-refresh interval in seconds (default: 30).
|
|
725
|
+
"""
|
|
726
|
+
app = LiveGameApp(
|
|
727
|
+
api_key=api_key,
|
|
728
|
+
game_name=game_name,
|
|
729
|
+
tag_line=tag_line,
|
|
730
|
+
region=region,
|
|
731
|
+
auto_refresh=auto_refresh,
|
|
732
|
+
)
|
|
733
|
+
app.run()
|