diff --git a/.pylintrc b/.pylintrc index b944a49..02f4c8e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -15,4 +15,9 @@ ignore=builtin_games # R0912: too-many-branches - Too many branches # C0114: missing-module-docstring - Missing module docstring # R0915: too-many-statements - Too many statements -disable=W1203,W0718,C0415,R0913,R1705,R0917,R0912,C0114,R0915 +# C0302: too-many-lines - Too many lines in module +# R0914: too-many-locals - Too many local variables +# R1723: no-else-break - Unnecessary "else" after "break", remove the "else" and de-indent the code inside it +# W0702: bare-except - No exception type(s) specified +# R1724: Unnecessary "else" after "continue", remove the "else" and de-indent the code inside it (no-else-continue) +disable=W1203,W0718,C0415,R0913,R1705,R0917,R0912,C0114,R0915,C0302,R0914,R1723,W0702,R1724 diff --git a/lemonade_arcade/lemonade_client.py b/lemonade_arcade/lemonade_client.py index 1cbeb9b..9614c4a 100644 --- a/lemonade_arcade/lemonade_client.py +++ b/lemonade_arcade/lemonade_client.py @@ -474,7 +474,10 @@ async def start_lemonade_server(self): # Use the unified function to start the server process = await self.execute_lemonade_server_command( - ["serve"], use_popen=True, stdout_file=stdout_file, stderr_file=stderr_file + ["serve", "--ctx-size", "16384"], + use_popen=True, + stdout_file=stdout_file, + stderr_file=stderr_file, ) if process is None: diff --git a/lemonade_arcade/main.py b/lemonade_arcade/main.py index 72c347b..40b9c7f 100644 --- a/lemonade_arcade/main.py +++ b/lemonade_arcade/main.py @@ -16,6 +16,7 @@ import httpx import uvicorn +from openai import AsyncOpenAI from fastapi import FastAPI, HTTPException, Request from fastapi.responses import ( JSONResponse, @@ -24,6 +25,7 @@ ) from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from starlette.responses import Response import lemonade_arcade.lemonade_client as lc @@ -39,16 +41,9 @@ else: REQUIRED_MODEL = "Qwen3-Coder-30B-A3B-Instruct-GGUF" -# Set up logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) +# Logger will be configured by CLI or set to INFO if run directly logger = logging.getLogger("lemonade_arcade.main") -# Suppress noisy httpcore debug messages -logging.getLogger("httpcore").setLevel(logging.WARNING) -logging.getLogger("httpx").setLevel(logging.WARNING) - def get_resource_path(relative_path): """Get absolute path to resource, works for dev and for PyInstaller""" @@ -73,7 +68,23 @@ def get_resource_path(relative_path): STATIC_DIR = get_resource_path("static") TEMPLATES_DIR = get_resource_path("templates") -app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +class NoCacheStaticFiles(StaticFiles): + """Custom StaticFiles class with no-cache headers""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def file_response(self, *args, **kwargs) -> Response: + response = super().file_response(*args, **kwargs) + # Add no-cache headers for all static files + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + +app.mount("/static", NoCacheStaticFiles(directory=str(STATIC_DIR)), name="static") templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @@ -132,26 +143,54 @@ def save_metadata(self): except Exception as e: print(f"Error saving metadata: {e}") - def launch_game(self, game_id: str): - """Launch a game in a separate process.""" - logger.debug(f"Attempting to launch game {game_id}") + async def launch_game(self, game_id: str, max_retries: int = 1) -> tuple[bool, str]: + """Launch a game in a separate process and capture any immediate errors. + If the game fails and it's a user-generated game, attempt to fix it using LLM. + This is a simple wrapper around launch_game_with_streaming for non-streaming cases. - # Check if it's a built-in game - if game_id in self.BUILTIN_GAMES: - # For built-in games, use the file from the builtin_games directory - builtin_games_dir = get_resource_path("builtin_games") - game_file = Path(builtin_games_dir) / self.BUILTIN_GAMES[game_id]["file"] - logger.debug(f"Looking for built-in game file at: {game_file}") - else: - # For user-generated games, use the standard games directory - game_file = self.games_dir / f"{game_id}.py" - logger.debug(f"Looking for user game file at: {game_file}") + Args: + game_id: Unique identifier for the game + max_retries: Maximum number of automatic retry attempts (default: 1) + + Returns: + tuple: (success: bool, message: str) + """ + # For non-streaming cases, we'll collect the final result from the streaming version + + final_result = None + + async for stream_item in self.launch_game_with_streaming( + game_id, max_retries=max_retries + ): + # Parse the stream item to get the final result + if '"type": "complete"' in stream_item: + # Extract the message from the JSON + try: + data = json.loads(stream_item.replace("data: ", "").strip()) + final_result = ( + True, + data.get("message", "Game launched successfully"), + ) + except: + final_result = (True, "Game launched successfully") + break + elif '"type": "error"' in stream_item: + # Extract the error message from the JSON + try: + data = json.loads(stream_item.replace("data: ", "").strip()) + final_result = (False, data.get("message", "Game failed to launch")) + except: + final_result = (False, "Game failed to launch") + break - if not game_file.exists(): - logger.error(f"Game file not found: {game_file}") - raise FileNotFoundError(f"Game file not found: {game_file}") + if final_result is None: + final_result = (False, "Unexpected error during game launch") - # Launch the game + return final_result + + def _attempt_game_launch(self, game_id: str, game_file: Path) -> tuple[bool, str]: + """Attempt to launch a game and return success status and any error message.""" + # Launch the game with error capture try: # In PyInstaller environment, use the same executable with the game file as argument # This ensures the game runs with the same DLL configuration @@ -164,26 +203,158 @@ def launch_game(self, game_id: str): cmd = [sys.executable, str(game_file)] logger.debug(f"Development mode - Launching: {' '.join(cmd)}") + # Launch with pipes to capture output # pylint: disable=consider-using-with - process = subprocess.Popen(cmd) - self.running_games[game_id] = process - logger.debug(f"Game {game_id} launched successfully with PID {process.pid}") - return True + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + start_time = time.time() + logger.debug(f"Game {game_id} subprocess started with PID {process.pid}") + + # Give the process a moment to start and check for immediate errors + try: + stdout, stderr = process.communicate(timeout=2) + end_time = time.time() + duration = end_time - start_time + # Process exited within 2 seconds - this is likely an error for pygame games + # Even if return code is 0, pygame games should keep running + logger.debug( + f"Game {game_id} subprocess (PID {process.pid}) EXITED after {duration:.3f} " + f"seconds with return code {process.returncode}" + ) + + # Filter out pygame warnings from stderr to get actual errors + stderr_lines = stderr.strip().split("\n") if stderr else [] + actual_errors = [] + + for line in stderr_lines: + # Skip pygame deprecation warnings and other noise + if any( + skip_phrase in line + for skip_phrase in [ + "UserWarning", + "pkg_resources is deprecated", + "from pkg_resources import", + "pygame community", + "https://www.pygame.org", + ] + ): + continue + # Only include lines that look like actual errors + # (have common error indicators) + if line.strip() and any( + error_indicator in line + for error_indicator in [ + "Error", + "Exception", + "Traceback", + 'File "', + "line ", + "NameError", + "ImportError", + "SyntaxError", + "AttributeError", + "TypeError", + "ValueError", + ] + ): + actual_errors.append(line) + + filtered_stderr = "\n".join(actual_errors).strip() + + # Debug logging to see what we captured + print(f"DEBUG: filtered_stderr length: {len(filtered_stderr)}") + print(f"DEBUG: filtered_stderr content: '{filtered_stderr}'") + print(f"DEBUG: process.returncode: {process.returncode}") + + if filtered_stderr: + error_msg = filtered_stderr + print("DEBUG: Using filtered stderr as error message") + elif process.returncode != 0: + # Non-zero exit but no clear error message + error_msg = ( + f"Game exited with code {process.returncode} " + "but no error message was captured" + ) + print("DEBUG: Using non-zero exit code message") + else: + # Return code 0 but game exited immediately - likely missing game loop + error_msg = ( + "Game completed successfully but exited immediately. " + "This usually means the game is missing a proper game loop " + "(while True loop) " + "or has a logical error that causes it to finish execution quickly." + ) + print("DEBUG: Using missing game loop message") + + if process.returncode != 0: + logger.error( + f"Game {game_id} failed with return code {process.returncode}: {error_msg}" + ) + print( + f"\n=== Game {game_id} Failed (Return Code: {process.returncode}) ===" + ) + else: + logger.error( + f"Game {game_id} exited immediately (return code 0) - " + "likely missing game loop or other issue: {error_msg}" + ) + print( + f"\n=== Game {game_id} Exited Immediately (Return Code: 0) ===" + ) + + # Print subprocess output to terminal for debugging + if stdout: + print("STDOUT:") + print(stdout) + if stderr: + print("STDERR:") + print(stderr) + if not stdout and not stderr: + print("No output captured") + print("=" * 60) + + return False, error_msg + except subprocess.TimeoutExpired: + # Timeout is good - means the game is still running + end_time = time.time() + duration = end_time - start_time + self.running_games[game_id] = process + logger.debug( + f"Game {game_id} subprocess (PID {process.pid}) STILL RUNNING after " + f"{duration:.3f} seconds timeout - this is GOOD for pygame games" + ) + return True, "Game launched successfully" + except Exception as e: logger.error(f"Error launching game {game_id}: {e}") - return False + return False, str(e) def stop_game(self, game_id: str): """Stop a running game.""" if game_id in self.running_games: try: process = self.running_games[game_id] + logger.debug( + f"MANUALLY STOPPING game {game_id} subprocess (PID {process.pid})" + ) process.terminate() # Wait a bit for graceful termination try: process.wait(timeout=5) + logger.debug( + f"Game {game_id} subprocess (PID {process.pid}) terminated gracefully" + ) except subprocess.TimeoutExpired: + logger.debug( + f"Game {game_id} subprocess (PID {process.pid}) " + "did not terminate gracefully, killing..." + ) process.kill() + logger.debug( + f"Game {game_id} subprocess (PID {process.pid}) killed" + ) except Exception as e: print(f"Error stopping game {game_id}: {e}") finally: @@ -194,11 +365,241 @@ def cleanup_finished_games(self): finished = [] for game_id, process in self.running_games.items(): if process.poll() is not None: # Process has finished + return_code = process.returncode + logger.debug( + f"Game {game_id} subprocess (PID {process.pid})" + f"FINISHED with return code {return_code} - cleaning up" + ) finished.append(game_id) for game_id in finished: del self.running_games[game_id] + async def create_and_launch_game_with_streaming( + self, game_id: str, python_code: str, prompt: str + ): + """ + Create a new game and launch it with streaming status and content updates. + This is an async generator that yields streaming messages. + """ + try: + yield f"data: {json.dumps({'type': 'status', 'message': 'Creating title...'})}\n\n" + + # Save the game file + game_file = self.games_dir / f"{game_id}.py" + logger.debug(f"Saving game to: {game_file}") + with open(game_file, "w", encoding="utf-8") as f: + f.write(python_code) + logger.debug("Game file saved successfully") + + # Generate a proper title for the game + logger.debug("Generating game title") + game_title = await generate_game_title(prompt) + + # Save metadata + self.game_metadata[game_id] = { + "title": game_title, + "created": time.time(), + "prompt": prompt, + } + self.save_metadata() + logger.debug(f"Saved metadata for game: {game_title}") + + yield f"data: {json.dumps({'type': 'status', 'message': 'Launching game...'})}\n\n" + + # Use the launch_game_with_streaming method for retry logic and streaming + async for stream_item in self.launch_game_with_streaming( + game_id, game_title + ): + yield stream_item + + except Exception as e: + logger.exception(f"Error in game creation: {e}") + yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" + + async def launch_game_with_streaming( + self, game_id: str, game_title: str = None, max_retries: int = 1 + ): + """ + Launch a game with retry logic and streaming status/content updates. + This is an async generator that yields streaming messages. + """ + logger.debug(f"Attempting to launch game {game_id}") + + if game_title is None: + game_title = self.game_metadata.get(game_id, {}).get("title", game_id) + + retry_count = 0 + + while retry_count <= max_retries: + # Check if it's a built-in game + if game_id in self.BUILTIN_GAMES: + # For built-in games, use the file from the builtin_games directory + builtin_games_dir = get_resource_path("builtin_games") + game_file = ( + Path(builtin_games_dir) / self.BUILTIN_GAMES[game_id]["file"] + ) + logger.debug(f"Looking for built-in game file at: {game_file}") + else: + # For user-generated games, use the standard games directory + game_file = self.games_dir / f"{game_id}.py" + logger.debug(f"Looking for user game file at: {game_file}") + + if not game_file.exists(): + logger.error(f"Game file not found: {game_file}") + error_msg = f"Game file not found: {game_file}" + yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n" + return + + # Try to launch the game + success, error_message = self._attempt_game_launch(game_id, game_file) + + if success: + message = f"Game '{game_title}' created and launched successfully!" + complete_data = { + "type": "complete", + "game_id": game_id, + "message": message, + } + yield f"data: {json.dumps(complete_data)}\n\n" + return + + # Game failed - check if we should attempt to fix it + if ( + retry_count < max_retries + and game_id not in self.BUILTIN_GAMES + and game_id in self.game_metadata + ): + + logger.info( + f"Game {game_id} failed, attempting automatic retry {retry_count + 1}" + ) + + # Send status update + status_msg = "Game hit an error, trying to fix it..." + yield f"data: {json.dumps({'type': 'status', 'message': status_msg})}\n\n" + + # Add a content separator to clearly mark the start of the fix attempt + error_separator = ( + f"\n\n---\n\n# â ī¸ ERROR ENCOUNTERED\n\n" + f"> đ§ **The generated game encountered an error during launch.** \n" + f"> **Attempting to automatically fix the code...**\n\n" + f"**Error Details:**\n```\n{error_message}\n```\n\n---\n\n" + f"## đ ī¸ Fix Attempt:\n\n" + ) + yield f"data: {json.dumps({'type': 'content', 'content': error_separator})}\n\n" + + # Try to fix the code using LLM with streaming + try: + # Read the current game code + with open(game_file, "r", encoding="utf-8") as f: + current_code = f.read() + + logger.debug(f"Attempting to fix game {game_id} code using LLM") + + # Try to fix the code using LLM and stream the output + fixed_code = None + async for result in generate_game_code_with_llm( + "debug", current_code, error_message + ): + if result is None: + # Error occurred in the LLM function + logger.error( + "Error in generate_game_code_with_llm during debug" + ) + break + elif isinstance(result, str): + # Check if this looks like final extracted code + # Trust the extract_python_code function - if it's substantial + # and contains pygame, it's likely final code + if ( + len(result) > 500 # Must be substantial + and "pygame" + in result.lower() # Must be a pygame program + and not result.startswith( + "```" + ) # Should not start with markdown + and ( + "\n" in result or len(result) > 1000 + ) # Must be multi-line or very long + ): + # This is likely the final extracted code + fixed_code = result + logger.debug( + f"Received fixed code, length: {len(fixed_code)}" + ) + break + else: + # This is a content chunk, stream it directly + content_data = {"type": "content", "content": result} + yield f"data: {json.dumps(content_data)}\n\n" + + if fixed_code: + # Save the fixed code + with open(game_file, "w", encoding="utf-8") as f: + f.write(fixed_code) + logger.info(f"Fixed code saved for game {game_id}") + retry_count += 1 + continue + else: + logger.error(f"Could not get fixed code for game {game_id}") + error_msg = ( + f"Game '{game_title}' failed to launch and could not be " + f"automatically fixed: {error_message}" + ) + # pylint: disable=line-too-long + final_error_content = f"\n\n---\n\n> â **FINAL ERROR** \n> {error_msg}\n\n---\n\n" + content_data = { + "type": "content", + "content": final_error_content, + } + yield f"data: {json.dumps(content_data)}\n\n" + error_msg = "Game launch failed after fix attempt" + error_data = {"type": "error", "message": error_msg} + yield f"data: {json.dumps(error_data)}\n\n" + return + + except Exception as e: + logger.error(f"Error attempting to fix game {game_id}: {e}") + error_msg = f"Error during automatic fix: {str(e)}" + # pylint: disable=line-too-long + exception_error_content = f"\n\n---\n\n> â **FIX ATTEMPT FAILED** \n> {error_msg}\n\n---\n\n" + content_data = { + "type": "content", + "content": exception_error_content, + } + yield f"data: {json.dumps(content_data)}\n\n" + error_msg = "Game launch failed during fix attempt" + error_data = {"type": "error", "message": error_msg} + yield f"data: {json.dumps(error_data)}\n\n" + return + else: + # No more retries or built-in game failed + error_msg = f"Game '{game_title}' failed to launch: {error_message}" + no_retry_error_content = ( + f"\n\n---\n\n> â **LAUNCH FAILED** \n> {error_msg}\n\n---\n\n" + ) + content_data = {"type": "content", "content": no_retry_error_content} + yield f"data: {json.dumps(content_data)}\n\n" + yield f"data: {json.dumps({'type': 'error', 'message': 'Game launch failed'})}\n\n" + return + + # Max retries exceeded + error_msg = ( + f"Game '{game_title}' failed to launch after {max_retries} " + f"automatic fix attempts: {error_message}" + ) + max_retry_error_content = ( + f"\n\n---\n\n> â **MAX RETRIES EXCEEDED** \n> {error_msg}\n\n---\n\n" + ) + content_data = {"type": "content", "content": max_retry_error_content} + yield f"data: {json.dumps(content_data)}\n\n" + error_data = { + "type": "error", + "message": "Game launch failed after max retries", + } + yield f"data: {json.dumps(error_data)}\n\n" + arcade_games = ArcadeGames() @@ -277,7 +678,10 @@ def extract_python_code(llm_response: str) -> Optional[str]: match = re.search(pattern, llm_response, re.DOTALL) if match: code = match.group(1).strip() - logger.debug(f"Found code block with pattern {i+1}, length: {len(code)}") + pattern_num = i + 1 + logger.debug( + f"Found code block with pattern {pattern_num}, length: {len(code)}" + ) # Basic validation - should contain pygame if "pygame" in code.lower(): logger.debug("Code contains pygame, validation passed") @@ -289,6 +693,172 @@ def extract_python_code(llm_response: str) -> Optional[str]: return None +async def generate_game_code_with_llm( + mode: str, content: str, error_message: str = None +): + """Unified function to generate or fix game code using LLM. + + Args: + mode: "create" for new games, "debug" for fixing existing games + content: For "create" mode: user's game prompt. For "debug" mode: the buggy code + error_message: Only for "debug" mode: the error that occurred + + Returns: + Optional[str]: The generated/fixed code, or None if failed + """ + + if mode == "create": + # pylint: disable=line-too-long + system_prompt = """You are an expert Python game developer. Generate a complete, working Python game using pygame based on the user's description. + +Rules: +1. Use ONLY the pygame library - no external images, sounds, or files +2. Create everything (graphics, colors, shapes) using pygame's built-in drawing functions +3. Make the game fully playable and fun +4. Include proper game mechanics (win/lose conditions, scoring if appropriate) +5. Use proper pygame event handling and game loop +6. Add comments explaining key parts of the code +7. Make sure the game window closes properly when the user clicks the X button +8. Use reasonable colors and make the game visually appealing with pygame primitives + +Generate ONLY the Python code in a single code block. Do not include any explanations outside the code block.""" + + user_prompt = f"Create a game: {content}" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + elif mode == "debug": + # Extract error type from error message + error_type = None + if "UnboundLocalError" in error_message: + error_type = "UnboundLocalError" + elif "NameError" in error_message: + error_type = "NameError" + elif "AttributeError" in error_message: + error_type = "AttributeError" + elif "TypeError" in error_message: + error_type = "TypeError" + elif "IndexError" in error_message: + error_type = "IndexError" + + # Build error-specific guidance + error_guidance = "" + if error_type == "UnboundLocalError": + # pylint: disable=line-too-long + error_guidance = """UnboundLocalError Fix: +Add 'global variable_name' at the start of the function that's trying to modify a global variable.""" + elif error_type == "NameError": + error_guidance = """NameError Fix: +Either define the missing variable or fix the typo in the variable name.""" + elif error_type == "AttributeError": + error_guidance = """AttributeError Fix: +Use the correct method/attribute name or check the object type.""" + elif error_type == "TypeError": + error_guidance = """TypeError Fix: +Fix the function arguments or type mismatch.""" + elif error_type == "IndexError": + error_guidance = """IndexError Fix: +Check list/array bounds before accessing.""" + + # pylint: disable=line-too-long + system_prompt = f"""You are debugging someone else's pygame code that has at least one error. + +{error_guidance} + +Output format: +1. One sentence explaining the fix(s) +2. Complete CORRECTED code in a python code block + +IMPORTANT: The code you output must have the fix(s) applied - it must be different from the broken input code.""" + + # Try to extract line number from error message + line_match = re.search(r"line (\d+)", error_message) + line_ref = ( + f"line {line_match.group(1)}" if line_match else "the problematic line" + ) + + user_prompt = f"""The code you just saw has at least this error, if not more: +{error_message} + +Look at {line_ref} in the code above and fix the error(s). +Output the COMPLETE code with your fix(s) applied.""" + + # In debug mode, place the Python code in the assistant role + assistant_content = f"""```python +{content} +```""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "assistant", "content": assistant_content}, + {"role": "user", "content": user_prompt}, + ] + else: + logger.error(f"Invalid mode: {mode}") + yield None + return # Early return without value is allowed + + # Debug logging for OpenAI messages structure + logger.debug(f"=== OpenAI Messages Debug for {mode} mode ===") + logger.debug(f"Number of messages: {len(messages)}") + for i, message in enumerate(messages): + role = message["role"] + content = message["content"] + content_length = len(content) + logger.debug( + f"Message {i+1} - Role: {role}, Content length: {content_length} chars" + ) + # Log first 200 chars and last 100 chars to see structure without overwhelming logs + if content_length <= 300: + logger.debug(f"Message {i+1} - Full content: {repr(content)}") + else: + logger.debug(f"Message {i+1} - Content start: {repr(content[:200])}") + logger.debug(f"Message {i+1} - Content end: {repr(content[-100:])}") + logger.debug("=== End OpenAI Messages Debug ===") + + try: + # Create OpenAI client pointing to Lemonade Server + openai_client = AsyncOpenAI( + base_url=f"{lemonade_handle.url}/api/v1", + api_key="dummy", + timeout=600.0, + ) + + response = await openai_client.chat.completions.create( + model=REQUIRED_MODEL, + messages=messages, + stream=True, # Always stream for both create and debug modes + max_tokens=4000, + ) + + # Handle streaming response for both create and debug modes + full_response = "" + async for chunk in response: + if chunk.choices and len(chunk.choices) > 0: + delta = chunk.choices[0].delta + if delta.content is not None: + content_chunk = delta.content + full_response += content_chunk + # Yield the content chunk for streaming to LLM Output sidecar + yield content_chunk + + # After all chunks, extract and yield the final code + python_code = extract_python_code(full_response) + if python_code: + logger.debug(f"Successfully extracted code for {mode} mode") + yield python_code # Yield the final extracted code + else: + logger.error(f"Could not extract code from LLM response in {mode} mode") + yield None + + except Exception as e: + logger.error(f"Error calling LLM for {mode}: {e}") + yield None + + def generate_game_id(): """Generate a unique game ID.""" return str(uuid.uuid4())[:8] @@ -519,149 +1089,63 @@ async def generate(): yield f"data: {json.dumps({'type': 'status', 'message': 'Connecting to LLM...'})}\n\n" logger.debug("Sent 'Connecting to LLM...' status") - # Prepare the system prompt for game generation - # pylint: disable=line-too-long - system_prompt = """You are an expert Python game developer. Generate a complete, working Python game using pygame based on the user's description. - -Rules: -1. Use ONLY the pygame library - no external images, sounds, or files -2. Create everything (graphics, colors, shapes) using pygame's built-in drawing functions -3. Make the game fully playable and fun -4. Include proper game mechanics (win/lose conditions, scoring if appropriate) -5. Use proper pygame event handling and game loop -6. Add comments explaining key parts of the code -7. Make sure the game window closes properly when the user clicks the X button -8. Use reasonable colors and make the game visually appealing with pygame primitives - -Generate ONLY the Python code in a single code block. Do not include any explanations outside the code block.""" - - # Create chat messages - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": f"Create a game: {prompt}"}, - ] - - logger.debug( - f"Prepared messages for LLM, system prompt length: {len(system_prompt)}" - ) - - # Stream response from Lemonade Server - logger.debug( - f"Starting request to {lemonade_handle.url}/api/v1/chat/completions" - ) - async with httpx.AsyncClient(timeout=600.0) as client: - async with client.stream( - "POST", - f"{lemonade_handle.url}/api/v1/chat/completions", - json={ - "model": REQUIRED_MODEL, - "messages": messages, - "stream": True, - "max_tokens": 4000, - "temperature": 0.7, - }, - headers={"Content-Type": "application/json"}, - ) as response: - - logger.debug( - f"Received response with status code: {response.status_code}" - ) - - if response.status_code != 200: - logger.error( - f"LLM request failed with status {response.status_code}" - ) - yield f"data: {json.dumps({'type': 'error', 'message': 'Failed to connect to LLM'})}\n\n" - return - - yield f"data: {json.dumps({'type': 'status', 'message': 'Generating code...'})}\n\n" - logger.debug("Sent 'Generating code...' status") - - full_response = "" - line_count = 0 - async for line in response.aiter_lines(): - line_count += 1 - logger.debug(f"Processing line {line_count}: {line[:100]}...") - - if line.startswith("data: "): - try: - chunk_data = json.loads(line[6:]) - logger.debug(f"Parsed chunk data: {chunk_data}") - - if ( - "choices" in chunk_data - and len(chunk_data["choices"]) > 0 - ): - delta = chunk_data["choices"][0].get("delta", {}) - if ( - "content" in delta - and delta["content"] is not None - ): - content = delta["content"] - full_response += content - logger.debug( - f"Added content to response, total length: {len(full_response)}" - ) - yield f"data: {json.dumps({'type': 'content', 'content': content})}\n\n" - except json.JSONDecodeError as e: - logger.warning( - f"Failed to parse JSON from line: {line} - Error: {e}" - ) - continue - - logger.debug( - f"Finished processing stream, total lines: {line_count}, response length: {len(full_response)}" - ) - - # Extract Python code - yield f"data: {json.dumps({'type': 'status', 'message': 'Extracting code...'})}\n\n" - logger.debug("Starting code extraction") - - python_code = extract_python_code(full_response) + # Use the centralized function to generate game code + yield f"data: {json.dumps({'type': 'status', 'message': 'Generating code...'})}\n\n" + + python_code = None + async for result in generate_game_code_with_llm("create", prompt): + if result is None: + # Error occurred in the LLM function + logger.error("Error in generate_game_code_with_llm") + error_data = {"type": "error", "message": "Failed to generate code"} + yield f"data: {json.dumps(error_data)}\n\n" + return + elif isinstance(result, str): + # Check if this looks like final extracted code + # Trust the extract_python_code function - if it's substantial + # and contains pygame, it's likely final code + if ( + len(result) > 500 # Must be substantial + and "pygame" in result.lower() # Must be a pygame program + and not result.startswith( + "```" + ) # Should not start with markdown + and ( + "\n" in result or len(result) > 1000 + ) # Must be multi-line or very long + ): + # This is likely the final extracted code + python_code = result + logger.debug(f"Received final code, length: {len(python_code)}") + break + else: + # This is a content chunk, stream it to the client + content_data = {"type": "content", "content": result} + yield f"data: {json.dumps(content_data)}\n\n" + + # Verify we got the code if not python_code: logger.error( - f"Could not extract Python code from response. Response length: {len(full_response)}" + "Could not get Python code from generate_game_code_with_llm" ) - logger.debug(f"Full response: {full_response}") - yield f"data: {json.dumps({'type': 'error', 'message': 'Could not extract valid Python code from response'})}\n\n" + error_msg = "Could not extract valid Python code from response" + error_data = {"type": "error", "message": error_msg} + yield f"data: {json.dumps(error_data)}\n\n" return + yield f"data: {json.dumps({'type': 'status', 'message': 'Extracting code...'})}\n\n" + logger.debug("Code extraction completed") + logger.debug( f"Successfully extracted Python code, length: {len(python_code)}" ) - # Save the game - game_file = arcade_games.games_dir / f"{game_id}.py" - logger.debug(f"Saving game to: {game_file}") - with open(game_file, "w", encoding="utf-8") as f: - f.write(python_code) - logger.debug("Game file saved successfully") - - # Generate a proper title for the game - yield f"data: {json.dumps({'type': 'status', 'message': 'Creating title...'})}\n\n" - logger.debug("Generating game title") - - game_title = await generate_game_title(prompt) - - # Save metadata - arcade_games.game_metadata[game_id] = { - "title": game_title, - "created": time.time(), - "prompt": prompt, - } - arcade_games.save_metadata() - logger.debug(f"Saved metadata for game: {game_title}") - - yield f"data: {json.dumps({'type': 'status', 'message': 'Launching game...'})}\n\n" - logger.debug("Starting game launch") - - # Launch the game - if arcade_games.launch_game(game_id): - logger.debug(f"Game {game_id} launched successfully") - yield f"data: {json.dumps({'type': 'complete', 'game_id': game_id, 'message': 'Game created and launched!'})}\n\n" - else: - logger.error(f"Failed to launch game {game_id}") - yield f"data: {json.dumps({'type': 'complete', 'message': 'Game created but failed to launch'})}\n\n" + # Create and launch the game using ArcadeGames + # We'll use async generator delegation to stream from create_and_launch_game + async for stream_item in arcade_games.create_and_launch_game_with_streaming( + game_id, python_code, prompt + ): + yield stream_item except Exception as e: logger.exception(f"Error in game creation: {e}") @@ -680,7 +1164,7 @@ async def generate(): @app.post("/api/launch-game/{game_id}") async def launch_game_endpoint(game_id: str): - """Launch a specific game.""" + """Launch a specific game with streaming support for error fixes.""" arcade_games.cleanup_finished_games() if arcade_games.running_games: @@ -689,11 +1173,30 @@ async def launch_game_endpoint(game_id: str): if game_id not in arcade_games.game_metadata: raise HTTPException(status_code=404, detail="Game not found") - success = arcade_games.launch_game(game_id) - if not success: - raise HTTPException(status_code=500, detail="Failed to launch game") + # Get game title for better messaging + game_title = arcade_games.game_metadata.get(game_id, {}).get("title", game_id) - return JSONResponse({"success": True}) + async def generate(): + try: + # Stream the launch process with potential error fixing + async for stream_item in arcade_games.launch_game_with_streaming( + game_id, game_title, max_retries=1 + ): + yield stream_item + + except Exception as e: + logger.exception(f"Error in game launch: {e}") + yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" + + return StreamingResponse( + generate(), + media_type="text/plain", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "text/plain; charset=utf-8", + }, + ) @app.get("/api/game-status/{game_id}") @@ -816,6 +1319,16 @@ def run_game_file(game_file_path): def main(): """Main entry point for the application.""" + # Configure logging if not already configured (when run directly, not via CLI) + if not logging.getLogger().handlers: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + # Suppress noisy httpcore debug messages + logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + # Check if we're being called to run a specific game file if len(sys.argv) == 2 and sys.argv[1].endswith(".py"): # Game mode: run the specified game file diff --git a/lemonade_arcade/static/app.js b/lemonade_arcade/static/app.js index 311d1d8..14547e6 100644 --- a/lemonade_arcade/static/app.js +++ b/lemonade_arcade/static/app.js @@ -1333,7 +1333,9 @@ async function createGame() { document.getElementById('gamesGrid').style.display = 'grid'; } } else if (data.type === 'error') { - document.getElementById('llmOutput').innerHTML = `
`; + // Append error message to existing content instead of replacing it + fullResponse += `\n\n---\n\n`; + setLLMOutput(fullResponse, true); // Hide spinner on error isGenerating = false; document.getElementById('createBtn').disabled = false; @@ -1361,8 +1363,9 @@ async function createGame() { } } } catch (error) { - const outputElement = document.getElementById('llmOutput'); - outputElement.innerHTML = ``; + // Append error message to existing content instead of replacing it + fullResponse += `\n\n---\n\n`; + setLLMOutput(fullResponse, true); // Hide spinner on error isGenerating = false; document.getElementById('createBtn').disabled = false; @@ -1379,33 +1382,127 @@ async function launchGame(gameId) { return; } + // Set generation state for proper server status handling + isGenerating = true; + isServerKnownOnline = true; + lastServerStatusTime = Date.now(); + // Show spinner for launching document.getElementById('gameSpinner').classList.add('active'); document.getElementById('gamesGrid').style.display = 'none'; document.getElementById('spinnerStatus').textContent = 'Launching game...'; + setLLMOutput('', false); // Clear output try { const response = await fetch(`/api/launch-game/${gameId}`, { method: 'POST' }); - if (response.ok) { - runningGameId = gameId; - renderGames(); - document.getElementById('spinnerStatus').textContent = 'Playing game...'; - startGameStatusCheck(); - } else { - alert('Failed to launch game'); - // Hide spinner on failure + if (!response.ok) { + throw new Error('Failed to launch game'); + } + + // Server responded successfully, update status immediately + isServerKnownOnline = true; + lastServerStatusTime = Date.now(); + const indicator = document.getElementById('statusIndicator'); + const statusText = document.getElementById('statusText'); + indicator.className = 'status-indicator status-online'; + statusText.innerHTML = 'đ Lemonade Server Online'; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + + let streamCompleted = false; + let streamHadError = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.type === 'content') { + fullResponse += data.content; + setLLMOutput(fullResponse, true); // Render as markdown + } else if (data.type === 'status') { + document.getElementById('spinnerStatus').textContent = data.message; + } else if (data.type === 'complete') { + // Game launched successfully + streamCompleted = true; + if (data.game_id) { + runningGameId = data.game_id; + } else { + runningGameId = gameId; // Use the original gameId if not provided + } + renderGames(); + // Keep spinner active for playing state + document.getElementById('spinnerStatus').textContent = 'Playing game...'; + startGameStatusCheck(); + // Don't hide spinner here - game is now running + } else if (data.type === 'error') { + // Append error message to existing content instead of replacing it + streamHadError = true; + fullResponse += `\n\n---\n\n`; + setLLMOutput(fullResponse, true); + // Hide spinner on error + isGenerating = false; + document.getElementById('gameSpinner').classList.remove('active'); + document.getElementById('gamesGrid').style.display = 'grid'; + } + } catch (e) { + // Handle potential streaming chunks from SSE format + // Check if it's a streaming chunk that needs different parsing + if (line.trim() === 'data: [DONE]' || line.trim() === '[DONE]') continue; + + // Try to parse as OpenAI streaming format + try { + const streamData = JSON.parse(line.slice(6)); + if (streamData.choices && streamData.choices[0] && streamData.choices[0].delta && streamData.choices[0].delta.content) { + const content = streamData.choices[0].delta.content; + fullResponse += content; + setLLMOutput(fullResponse, true); // Render as markdown + } + } catch (e2) { + // Ignore JSON parse errors for partial chunks + } + } + } + } + } + + // Handle case where stream ended without explicit completion message + if (!streamCompleted && !streamHadError) { + // If we got content, show it; otherwise show an error + if (fullResponse.trim()) { + fullResponse += `\n\n---\n\n`; + setLLMOutput(fullResponse, true); + } else { + // No content received, treat as error + setLLMOutput('â **Launch failed:** No response received from server', true); + } + // Reset UI state + isGenerating = false; document.getElementById('gameSpinner').classList.remove('active'); document.getElementById('gamesGrid').style.display = 'grid'; } } catch (error) { - alert('Error launching game: ' + error.message); + // Append error message to existing content instead of replacing it + let fullResponse = ''; + fullResponse += `\n\n---\n\n`; + setLLMOutput(fullResponse, true); // Hide spinner on error + isGenerating = false; document.getElementById('gameSpinner').classList.remove('active'); document.getElementById('gamesGrid').style.display = 'grid'; } + // Note: We don't use finally here because state is managed by the streaming events } // Delete a game @@ -1529,3 +1626,5 @@ document.addEventListener('DOMContentLoaded', function() { } }, 15000); // Check every 15 seconds }); + + diff --git a/lemonade_arcade/static/style.css b/lemonade_arcade/static/style.css index bbb142e..81eae13 100644 --- a/lemonade_arcade/static/style.css +++ b/lemonade_arcade/static/style.css @@ -563,6 +563,48 @@ body { margin: 0; } +.error-bubble { + background: linear-gradient(135deg, #ff4444, #cc3333); + border: 2px solid #ff6666; + border-radius: 15px; + color: white; + padding: 15px 20px; + margin: 15px 0; + text-align: center; + font-weight: bold; + box-shadow: 0 4px 12px rgba(255, 68, 68, 0.3); + animation: errorPulse 2s ease-in-out infinite; + position: relative; + overflow: hidden; +} + +.error-bubble::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + animation: errorShimmer 3s ease-in-out infinite; +} + +@keyframes errorPulse { + 0%, 100% { + box-shadow: 0 4px 12px rgba(255, 68, 68, 0.3); + transform: scale(1); + } + 50% { + box-shadow: 0 6px 18px rgba(255, 68, 68, 0.5); + transform: scale(1.02); + } +} + +@keyframes errorShimmer { + 0% { left: -100%; } + 100% { left: 100%; } +} + .get-lemonade-link { color: #00ffff; text-decoration: none; @@ -1095,3 +1137,5 @@ body { .builtin-games-error { color: #ff4444; } + + diff --git a/setup.py b/setup.py index 52c4b79..6400160 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ "httpx>=0.25.0", "jinja2>=3.1.0", "python-multipart>=0.0.6", + "openai>=1.0.0", ], entry_points={ "console_scripts": [