Jofthomas commited on
Commit
627c094
·
verified ·
1 Parent(s): 8c86375

Create main.py

Browse files
Files changed (1) hide show
  1. main.py +665 -0
main.py ADDED
@@ -0,0 +1,665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py - FastAPI application for Pokemon Livestream
2
+
3
+ import asyncio
4
+ import os
5
+ import random
6
+ import time
7
+ import traceback
8
+ import logging
9
+ from typing import List, Dict, Optional, Set
10
+
11
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
12
+ from fastapi.responses import HTMLResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ # --- Imports for poke_env and agents ---
16
+ from poke_env.player import Player
17
+ from poke_env import AccountConfiguration, ServerConfiguration
18
+ from poke_env.environment.battle import Battle
19
+
20
+ # Import the actual agent classes
21
+ from agents import OpenAIAgent, GeminiAgent, MistralAgent
22
+
23
+ # --- Configuration ---
24
+ CUSTOM_SERVER_URL = "wss://jofthomas.com/showdown/websocket"
25
+ CUSTOM_ACTION_URL = 'https://play.pokemonshowdown.com/action.php?'
26
+ CUSTOM_BATTLE_VIEW_URL_TEMPLATE = "https://jofthomas.com/play.pokemonshowdown.com/testclient.html#{battle_id}"
27
+ custom_config = ServerConfiguration(CUSTOM_SERVER_URL, CUSTOM_ACTION_URL)
28
+ DEFAULT_BATTLE_FORMAT = "gen9randombattle"
29
+
30
+ # Define available agents with their corresponding classes
31
+ AGENT_CONFIGS = {
32
+ "OpenAIAgent": {"class": OpenAIAgent, "password_env_var": "OPENAI_AGENT_PASSWORD"},
33
+ "GeminiAgent": {"class": GeminiAgent, "password_env_var": "GEMINI_AGENT_PASSWORD"},
34
+ "MistralAgent": {"class": MistralAgent, "password_env_var": "MISTRAL_AGENT_PASSWORD"},
35
+ }
36
+
37
+ # Filter out agents with missing passwords
38
+ AVAILABLE_AGENT_NAMES = [
39
+ name for name, cfg in AGENT_CONFIGS.items()
40
+ if os.environ.get(cfg.get("password_env_var", ""))
41
+ ]
42
+
43
+ if not AVAILABLE_AGENT_NAMES:
44
+ print("FATAL ERROR: No agent configurations have their required password environment variables set. Exiting.")
45
+ exit(1)
46
+
47
+ # --- Global State Variables ---
48
+ active_agent_name: Optional[str] = None
49
+ active_agent_instance: Optional[Player] = None
50
+ active_agent_task: Optional[asyncio.Task] = None
51
+ current_battle_instance: Optional[Battle] = None
52
+ background_task_handle: Optional[asyncio.Task] = None
53
+
54
+ # --- Create FastAPI app ---
55
+ app = FastAPI(title="Pokemon Battle Livestream")
56
+
57
+ # --- WebSocket connection manager ---
58
+ class ConnectionManager:
59
+ def __init__(self):
60
+ self.active_connections: Set[WebSocket] = set()
61
+ self.current_html: str = create_idle_html("Initializing...", "Setting up Pokemon Battle Stream")
62
+
63
+ async def connect(self, websocket: WebSocket):
64
+ await websocket.accept()
65
+ self.active_connections.add(websocket)
66
+ # Send current state to newly connected client
67
+ await websocket.send_text(self.current_html)
68
+
69
+ def disconnect(self, websocket: WebSocket):
70
+ self.active_connections.remove(websocket)
71
+
72
+ async def update_all(self, html: str):
73
+ """Update the current HTML and broadcast to all clients"""
74
+ self.current_html = html
75
+ if self.active_connections:
76
+ # Only log if there are connections to update
77
+ print(f"Broadcasting update to {len(self.active_connections)} clients")
78
+
79
+ # Make a copy of the connections set to avoid modification during iteration
80
+ connections_copy = self.active_connections.copy()
81
+ for connection in connections_copy:
82
+ try:
83
+ await connection.send_text(html)
84
+ except Exception as e:
85
+ print(f"Error sending to client: {e}")
86
+ # Don't remove here - will be handled by disconnect route
87
+
88
+ manager = ConnectionManager()
89
+
90
+ # --- Helper Functions ---
91
+ def get_active_battle(agent: Player) -> Optional[Battle]:
92
+ """Returns the first non-finished battle for an agent."""
93
+ if agent and agent._battles:
94
+ active_battles = [b for b in agent._battles.values() if not b.finished]
95
+ if active_battles:
96
+ if active_battles[0].battle_tag:
97
+ return active_battles[0]
98
+ else:
99
+ print(f"WARN: Found active battle for {agent.username} but it has no battle_tag yet.")
100
+ return None
101
+ return None
102
+
103
+ def create_battle_iframe(battle_id: str) -> str:
104
+ """Creates the HTML for the battle iframe."""
105
+ print("CURRENT BATTLE ID: ", battle_id)
106
+ battle_url = f"https://jofthomas.com/play.pokemonshowdown.com/testclient.html#{battle_id}"
107
+
108
+ return f"""
109
+ <html>
110
+ <head>
111
+ <title>Pokemon Battle Livestream</title>
112
+ <style>
113
+ body, html {{
114
+ margin: 0;
115
+ padding: 0;
116
+ height: 100%;
117
+ overflow: hidden;
118
+ }}
119
+ iframe {{
120
+ position: fixed;
121
+ top: 0;
122
+ left: 0;
123
+ bottom: 0;
124
+ right: 0;
125
+ width: 100%;
126
+ height: 100%;
127
+ border: none;
128
+ margin: 0;
129
+ padding: 0;
130
+ overflow: hidden;
131
+ z-index: 999999;
132
+ }}
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <iframe
137
+ src="{battle_url}"
138
+ allowfullscreen
139
+ ></iframe>
140
+ </body>
141
+ </html>
142
+ """
143
+
144
+ def create_idle_html(status_message: str, instruction: str) -> str:
145
+ """Creates a visually appealing idle screen HTML."""
146
+ return f"""
147
+ <html>
148
+ <head>
149
+ <title>Pokemon Battle Livestream</title>
150
+ <style>
151
+ body, html {{
152
+ margin: 0;
153
+ padding: 0;
154
+ height: 100%;
155
+ overflow: hidden;
156
+ font-family: Arial, sans-serif;
157
+ }}
158
+ .container {{
159
+ position: fixed;
160
+ top: 0;
161
+ left: 0;
162
+ width: 100%;
163
+ height: 100%;
164
+ display: flex;
165
+ flex-direction: column;
166
+ justify-content: center;
167
+ align-items: center;
168
+ background-image: url('/static/pokemon_huggingface.png');
169
+ background-size: cover;
170
+ background-position: center;
171
+ z-index: 999999;
172
+ }}
173
+ .message-box {{
174
+ background-color: rgba(0, 0, 0, 0.65);
175
+ padding: 30px;
176
+ border-radius: 15px;
177
+ max-width: 80%;
178
+ text-align: center;
179
+ }}
180
+ .status {{
181
+ font-size: 2.5em;
182
+ margin-bottom: 20px;
183
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
184
+ color: white;
185
+ }}
186
+ .instruction {{
187
+ font-size: 1.5em;
188
+ text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8);
189
+ color: white;
190
+ }}
191
+ </style>
192
+ </head>
193
+ <body>
194
+ <div class="container">
195
+ <div class="message-box">
196
+ <p class="status">{status_message}</p>
197
+ <p class="instruction">{instruction}</p>
198
+ </div>
199
+ </div>
200
+ </body>
201
+ </html>
202
+ """
203
+
204
+ def create_error_html(error_msg: str) -> str:
205
+ """Creates HTML to display an error message."""
206
+ return f"""
207
+ <html>
208
+ <head>
209
+ <title>Pokemon Battle Livestream - Error</title>
210
+ <style>
211
+ body, html {{
212
+ margin: 0;
213
+ padding: 0;
214
+ height: 100%;
215
+ overflow: hidden;
216
+ font-family: Arial, sans-serif;
217
+ }}
218
+ .container {{
219
+ position: fixed;
220
+ top: 0;
221
+ left: 0;
222
+ width: 100%;
223
+ height: 100%;
224
+ display: flex;
225
+ flex-direction: column;
226
+ justify-content: center;
227
+ align-items: center;
228
+ background-color: #330000;
229
+ z-index: 999999;
230
+ }}
231
+ .message-box {{
232
+ background-color: rgba(200, 0, 0, 0.7);
233
+ padding: 30px;
234
+ border-radius: 15px;
235
+ max-width: 80%;
236
+ text-align: center;
237
+ }}
238
+ .title {{
239
+ font-size: 2em;
240
+ margin-bottom: 20px;
241
+ color: white;
242
+ }}
243
+ .message {{
244
+ font-size: 1.2em;
245
+ color: #ffdddd;
246
+ }}
247
+ </style>
248
+ </head>
249
+ <body>
250
+ <div class="container">
251
+ <div class="message-box">
252
+ <p class="title">An Error Occurred</p>
253
+ <p class="message">{error_msg}</p>
254
+ </div>
255
+ </div>
256
+ </body>
257
+ </html>
258
+ """
259
+
260
+ async def update_display_html(new_html: str) -> None:
261
+ """Updates the current display HTML and broadcasts to all clients."""
262
+ await manager.update_all(new_html)
263
+ print("HTML Display UPDATED and broadcasted.")
264
+
265
+ # --- Agent Lifecycle Management ---
266
+ async def select_and_activate_new_agent():
267
+ """Selects a random available agent, instantiates it, and starts its listening task."""
268
+ global active_agent_name, active_agent_instance, active_agent_task
269
+
270
+ if not AVAILABLE_AGENT_NAMES:
271
+ print("Lifecycle: No available agents with passwords set.")
272
+ await update_display_html(create_error_html("No agents available (check password env vars)."))
273
+ return False
274
+
275
+ selected_name = random.choice(AVAILABLE_AGENT_NAMES)
276
+ config = AGENT_CONFIGS[selected_name]
277
+ AgentClass = config["class"]
278
+ password_env_var = config["password_env_var"]
279
+ agent_password = os.environ.get(password_env_var)
280
+
281
+ print(f"Lifecycle: Activating agent '{selected_name}'...")
282
+ await update_display_html(create_idle_html("Selecting Next Agent...", f"Preparing {selected_name}..."))
283
+
284
+ try:
285
+ account_config = AccountConfiguration(selected_name, agent_password)
286
+ agent = AgentClass(
287
+ account_configuration=account_config,
288
+ server_configuration=custom_config,
289
+ battle_format=DEFAULT_BATTLE_FORMAT,
290
+ log_level=logging.INFO,
291
+ max_concurrent_battles=1
292
+ )
293
+
294
+ # Start the task to accept exactly one battle challenge
295
+ task = asyncio.create_task(agent.accept_challenges(None, 1), name=f"accept_challenge_{selected_name}")
296
+ task.add_done_callback(log_task_exception)
297
+
298
+ # Update global state
299
+ active_agent_name = selected_name
300
+ active_agent_instance = agent
301
+ active_agent_task = task
302
+
303
+ print(f"Lifecycle: Agent '{selected_name}' is active and listening for 1 challenge.")
304
+ await update_display_html(create_idle_html(f"Agent <strong>{selected_name}</strong> is ready!",
305
+ f"Please challenge <strong>{selected_name}</strong> to a <strong>{DEFAULT_BATTLE_FORMAT}</strong> battle."))
306
+ return True
307
+
308
+ except Exception as e:
309
+ error_msg = f"Failed to activate agent '{selected_name}': {e}"
310
+ print(error_msg)
311
+ traceback.print_exc()
312
+
313
+ # Clear state if activation failed
314
+ active_agent_name = None
315
+ active_agent_instance = None
316
+ active_agent_task = None
317
+ return False
318
+
319
+ async def check_for_new_battle():
320
+ """Checks if the active agent has started a battle."""
321
+ global active_agent_instance, current_battle_instance
322
+
323
+ if active_agent_instance:
324
+ battle = get_active_battle(active_agent_instance)
325
+ if battle and battle.battle_tag:
326
+ print(f"Lifecycle: Agent '{active_agent_name}' started battle: {battle.battle_tag}")
327
+ current_battle_instance = battle
328
+
329
+ # Prevent accepting more challenges
330
+ if active_agent_task and not active_agent_task.done():
331
+ print(f"Lifecycle: Cancelling accept_challenges task for {active_agent_name} as battle started.")
332
+ active_agent_task.cancel()
333
+
334
+ async def deactivate_current_agent(reason: str = "cycle"):
335
+ """Cleans up the currently active agent and resets state."""
336
+ global active_agent_name, active_agent_instance, active_agent_task, current_battle_instance
337
+
338
+ print(f"Lifecycle: Deactivating agent '{active_agent_name}' (Reason: {reason})...")
339
+
340
+ # Different messages based on deactivation reason
341
+ if reason == "battle_end":
342
+ await update_display_html(create_idle_html("Battle Finished", "Preparing to select next agent..."))
343
+ elif reason == "cycle":
344
+ await update_display_html(create_idle_html("Cycling Agents", "Preparing for next selection..."))
345
+ else:
346
+ await update_display_html(create_idle_html(f"Agent Reset ({reason})", "Preparing for next selection..."))
347
+
348
+ agent = active_agent_instance
349
+ task = active_agent_task
350
+
351
+ # Store a local copy of the battle instance before clearing it
352
+ last_battle_instance = current_battle_instance
353
+
354
+ # Clear state first to prevent race conditions
355
+ active_agent_name = None
356
+ active_agent_instance = None
357
+ active_agent_task = None
358
+ current_battle_instance = None
359
+
360
+ # Cancel the accept_challenges task if it's still running
361
+ if task and not task.done():
362
+ print(f"Lifecycle: Cancelling task for {agent.username if agent else 'unknown agent'}...")
363
+ task.cancel()
364
+ try:
365
+ await asyncio.wait_for(task, timeout=2.0)
366
+ except asyncio.CancelledError:
367
+ print(f"Lifecycle: Task cancellation confirmed.")
368
+ except asyncio.TimeoutError:
369
+ print(f"Lifecycle: Task did not confirm cancellation within timeout.")
370
+ except Exception as e:
371
+ print(f"Lifecycle: Error during task cancellation wait: {e}")
372
+
373
+ # Disconnect the player
374
+ if agent:
375
+ print(f"Lifecycle: Disconnecting player {agent.username}...")
376
+ try:
377
+ if hasattr(agent, '_websocket') and agent._websocket and agent._websocket.open:
378
+ await agent.disconnect()
379
+ print(f"Lifecycle: Player {agent.username} disconnected.")
380
+ else:
381
+ print(f"Lifecycle: Player {agent.username} already disconnected or websocket not ready.")
382
+ except Exception as e:
383
+ print(f"ERROR during agent disconnect ({agent.username}): {e}")
384
+
385
+ # Add a brief delay after deactivation to ensure clean state
386
+ await asyncio.sleep(3)
387
+ print(f"Lifecycle: Agent deactivation complete.")
388
+
389
+ # --- Main Background Task ---
390
+ async def manage_agent_lifecycle():
391
+ """Runs the main loop selecting, running, and cleaning up agents sequentially."""
392
+ global active_agent_instance, active_agent_task, current_battle_instance
393
+
394
+ print("Background lifecycle manager started.")
395
+ REFRESH_INTERVAL_SECONDS = 3
396
+ loop_counter = 0
397
+
398
+ while True:
399
+ loop_counter += 1
400
+ try:
401
+ print(f"\n--- Lifecycle Check #{loop_counter} [{time.strftime('%H:%M:%S')}] ---")
402
+
403
+ # ==================================
404
+ # State 1: No agent active
405
+ # ==================================
406
+ if active_agent_instance is None:
407
+ print(f"[{loop_counter}] State 1: No active agent. Selecting...")
408
+ activated = await select_and_activate_new_agent()
409
+ if not activated:
410
+ print(f"[{loop_counter}] State 1: Activation failed. Waiting before retry.")
411
+ await asyncio.sleep(10)
412
+ continue
413
+ else:
414
+ print(f"[{loop_counter}] State 1: Agent '{active_agent_name}' activated.")
415
+
416
+ # ==================================
417
+ # State 2: Agent is active
418
+ # ==================================
419
+ else:
420
+ print(f"[{loop_counter}] State 2: Agent '{active_agent_name}' active.")
421
+
422
+ # --- Sub-state: Monitoring an ongoing battle ---
423
+ if current_battle_instance is not None:
424
+ battle_tag = current_battle_instance.battle_tag
425
+ print(f"[{loop_counter}] State 2: Monitoring battle {battle_tag}")
426
+
427
+ # Get the potentially updated battle object from the agent's internal list
428
+ battle_obj = active_agent_instance._battles.get(battle_tag)
429
+
430
+ if battle_obj and battle_obj.finished:
431
+ # Battle is finished, deactivate agent
432
+ print(f"[{loop_counter}] Battle {battle_tag} is FINISHED. Deactivating agent.")
433
+ await deactivate_current_agent(reason="battle_end")
434
+ await asyncio.sleep(5) # Brief cooldown between battles
435
+ continue
436
+
437
+ elif not battle_obj:
438
+ # Battle object missing unexpectedly, deactivate agent
439
+ print(f"[{loop_counter}] WARNING: Battle object for {battle_tag} MISSING from agent's list! Deactivating.")
440
+ await deactivate_current_agent(reason="battle_object_missing")
441
+ continue
442
+ else:
443
+ # Battle is ongoing and battle object exists
444
+ pass
445
+
446
+ # --- Sub-state: Checking for a new battle / Agent is idle ---
447
+ elif current_battle_instance is None:
448
+ print(f"[{loop_counter}] State 2: Checking for new battle...")
449
+ await check_for_new_battle()
450
+
451
+ # --- If a NEW battle was just detected by check_for_new_battle() ---
452
+ if current_battle_instance:
453
+ battle_tag = current_battle_instance.battle_tag
454
+ print(f"[{loop_counter}] State 2: *** NEW BATTLE DETECTED: {battle_tag} ***")
455
+
456
+ # Check if the battle ID has the non-public/suffixed format
457
+ parts = battle_tag.split('-')
458
+ is_suffixed_format = len(parts) > 3 and parts[2].isdigit()
459
+
460
+ if is_suffixed_format:
461
+ # Non-public format detected, forfeit immediately
462
+ print(f"[{loop_counter}] Detected non-public battle format ({battle_tag}). Forfeiting.")
463
+ await update_display_html(create_idle_html(f"Forfeiting non-public battle...", f"Agent {active_agent_name} detected private room."))
464
+ try:
465
+ # Send forfeit command TO THE SPECIFIC BATTLE ROOM
466
+ await active_agent_instance.send_message("/forfeit", battle_tag)
467
+ print(f"[{loop_counter}] Sent /forfeit command for {battle_tag}.")
468
+ await asyncio.sleep(1) # Give a moment for forfeit to process server-side
469
+ except Exception as forfeit_err:
470
+ print(f"[{loop_counter}] ERROR sending forfeit command for {battle_tag}: {forfeit_err}")
471
+ # Deactivate the agent as the battle is effectively over
472
+ await deactivate_current_agent(reason="forfeited_private_battle")
473
+ continue
474
+
475
+ else:
476
+ # Public battle format detected, display the battle iframe
477
+ print(f"[{loop_counter}] Public battle format detected. Displaying battle.")
478
+ await update_display_html(create_battle_iframe(battle_tag))
479
+
480
+ # --- If no new battle was found, agent remains idle ---
481
+ else:
482
+ print(f"[{loop_counter}] State 2: No new battle found. Agent '{active_agent_name}' remains idle.")
483
+ expected_idle_html = create_idle_html(f"Agent <strong>{active_agent_name}</strong> is ready!",
484
+ f"Please challenge <strong>{active_agent_name}</strong> to a <strong>{DEFAULT_BATTLE_FORMAT}</strong> battle.")
485
+ await update_display_html(expected_idle_html)
486
+
487
+ # --- Global Exception Handling for the main loop ---
488
+ except Exception as e:
489
+ print(f"ERROR in main lifecycle loop #{loop_counter}: {e}")
490
+ traceback.print_exc()
491
+ if active_agent_instance:
492
+ # If an error happened while an agent was active, try to deactivate it
493
+ await deactivate_current_agent(reason="main_loop_error")
494
+ else:
495
+ # If error happened before agent activation, show generic error
496
+ await update_display_html(create_error_html(f"Error in lifecycle manager: {e}"))
497
+ # Wait longer after a major error before trying again
498
+ await asyncio.sleep(10)
499
+
500
+ # --- Sleep at the end of each loop iteration (if no 'continue' was hit) ---
501
+ print(f"[{loop_counter}] Sleeping {REFRESH_INTERVAL_SECONDS}s before next check.")
502
+ await asyncio.sleep(REFRESH_INTERVAL_SECONDS)
503
+
504
+ def log_task_exception(task: asyncio.Task):
505
+ """Callback to log exceptions from background tasks."""
506
+ try:
507
+ if task.cancelled():
508
+ print(f"Task {task.get_name()} was cancelled.")
509
+ return
510
+ task.result() # Raises exception if task failed
511
+ print(f"Task {task.get_name()} finished cleanly.")
512
+ except asyncio.CancelledError:
513
+ pass
514
+ except Exception as e:
515
+ print(f"Exception in background task {task.get_name()}: {e}")
516
+ traceback.print_exc()
517
+
518
+ # --- API Routes ---
519
+ @app.get("/", response_class=HTMLResponse)
520
+ async def get_homepage():
521
+ """Serves the main HTML page with WebSocket connection."""
522
+ return """
523
+ <!DOCTYPE html>
524
+ <html>
525
+ <head>
526
+ <title>Pokemon Battle Livestream</title>
527
+ <style>
528
+ body, html {
529
+ margin: 0;
530
+ padding: 0;
531
+ height: 100%;
532
+ overflow: hidden;
533
+ }
534
+ #stream-container {
535
+ position: fixed;
536
+ top: 0;
537
+ left: 0;
538
+ bottom: 0;
539
+ right: 0;
540
+ width: 100%;
541
+ height: 100%;
542
+ }
543
+ </style>
544
+ </head>
545
+ <body>
546
+ <div id="stream-container"></div>
547
+
548
+ <script>
549
+ const streamContainer = document.getElementById('stream-container');
550
+
551
+ // Create WebSocket connection
552
+ const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
553
+
554
+ // Connection opened
555
+ ws.addEventListener('open', (event) => {
556
+ console.log('Connected to WebSocket server');
557
+ });
558
+
559
+ // Listen for messages
560
+ ws.addEventListener('message', (event) => {
561
+ console.log('Received update from server');
562
+ // Set inner HTML directly - complete document replacement
563
+ const parser = new DOMParser();
564
+ const doc = parser.parseFromString(event.data, 'text/html');
565
+ const bodyContent = doc.body.innerHTML;
566
+
567
+ // Update just the body content to avoid page refresh flicker
568
+ if (bodyContent) {
569
+ streamContainer.innerHTML = bodyContent;
570
+ }
571
+ });
572
+
573
+ // Handle connection close/errors
574
+ ws.addEventListener('close', (event) => {
575
+ console.log('WebSocket connection closed');
576
+ setTimeout(() => {
577
+ console.log('Attempting to reconnect...');
578
+ location.reload();
579
+ }, 3000);
580
+ });
581
+
582
+ ws.addEventListener('error', (event) => {
583
+ console.error('WebSocket error:', event);
584
+ });
585
+ </script>
586
+ </body>
587
+ </html>
588
+ """
589
+
590
+ @app.websocket("/ws")
591
+ async def websocket_endpoint(websocket: WebSocket):
592
+ await manager.connect(websocket)
593
+ try:
594
+ while True:
595
+ # Just keep the connection open
596
+ # The updates are pushed from the lifecycle manager
597
+ await websocket.receive_text()
598
+ except WebSocketDisconnect:
599
+ manager.disconnect(websocket)
600
+ except Exception as e:
601
+ print(f"WebSocket error: {e}")
602
+ manager.disconnect(websocket)
603
+
604
+ @app.on_event("startup")
605
+ async def startup_event():
606
+ """Start background tasks when the application starts."""
607
+ global background_task_handle
608
+
609
+ # Mount static files directory for images
610
+ app.mount("/static", StaticFiles(directory="static"), name="static")
611
+
612
+ print("🚀 Starting background tasks")
613
+ background_task_handle = asyncio.create_task(manage_agent_lifecycle(), name="lifecycle_manager")
614
+ background_task_handle.add_done_callback(log_task_exception)
615
+ print("✅ Background tasks started")
616
+
617
+ @app.on_event("shutdown")
618
+ async def shutdown_event():
619
+ """Clean up tasks when shutting down."""
620
+ global background_task_handle, active_agent_instance
621
+
622
+ print("\nShutting down app. Cleaning up...")
623
+
624
+ # Cancel background task
625
+ if background_task_handle and not background_task_handle.done():
626
+ background_task_handle.cancel()
627
+ try:
628
+ await asyncio.wait_for(background_task_handle, timeout=5.0)
629
+ except (asyncio.CancelledError, asyncio.TimeoutError):
630
+ pass
631
+
632
+ # Disconnect agent if active
633
+ if active_agent_instance:
634
+ try:
635
+ if hasattr(active_agent_instance, '_websocket') and active_agent_instance._websocket and active_agent_instance._websocket.open:
636
+ await active_agent_instance.disconnect()
637
+ except Exception as e:
638
+ print(f"Error during agent disconnect on shutdown: {e}")
639
+
640
+ print("Cleanup complete. Shutting down.")
641
+
642
+ # For direct script execution
643
+ if __name__ == "__main__":
644
+ import uvicorn
645
+
646
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
647
+ logging.getLogger('poke_env').setLevel(logging.WARNING)
648
+
649
+ print("Starting Pokemon Battle Livestream Server...")
650
+ print("="*50)
651
+
652
+ if not AVAILABLE_AGENT_NAMES:
653
+ print("FATAL: No agents found with configured passwords. Please set environment variables:")
654
+ for name, cfg in AGENT_CONFIGS.items():
655
+ print(f"- {cfg.get('password_env_var', 'N/A')} (for agent: {name})")
656
+ print("="*50)
657
+ exit("Exiting due to missing agent passwords.")
658
+ else:
659
+ print("Found available agents:")
660
+ for name in AVAILABLE_AGENT_NAMES:
661
+ print(f"- {name}")
662
+ print("="*50)
663
+
664
+ # Run with uvicorn
665
+ uvicorn.run(app, host="0.0.0.0", port=8000)