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/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()