commit 714b5824ba46af894d9df5a008aad11e048f5443 Author: empty Date: Tue Dec 30 14:58:38 2025 +0800 feat: initialize interactive live-stream game backend MVP - Add FastAPI backend with WebSocket support - Implement ConnectionManager for client connections - Create GameEngine with async game loop (2s tick) - Add RuleBasedAgent for keyword-based responses - Define Pydantic schemas for GameEvent protocol - Create debug frontend dashboard for testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..cecd671 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,6 @@ +""" +The Island - Interactive Live-Stream Game Backend +A commercial-grade backend for real-time game interactions. +""" + +__version__ = "0.1.0" diff --git a/backend/app/agents.py b/backend/app/agents.py new file mode 100644 index 0000000..27c3294 --- /dev/null +++ b/backend/app/agents.py @@ -0,0 +1,82 @@ +""" +Agent classes for processing game inputs and generating responses. +Provides base abstraction and rule-based implementation. +""" + +from abc import ABC, abstractmethod +import logging + +logger = logging.getLogger(__name__) + + +class BaseAgent(ABC): + """ + Abstract base class for all game agents. + + Agents process input text and generate appropriate responses. + Subclasses must implement the process_input method. + """ + + def __init__(self, name: str = "BaseAgent") -> None: + """ + Initialize the agent. + + Args: + name: Display name for the agent + """ + self.name = name + + @abstractmethod + def process_input(self, text: str) -> str: + """ + Process input text and return a response. + + Args: + text: The input text to process + + Returns: + The agent's response string + """ + pass + + +class RuleBasedAgent(BaseAgent): + """ + A simple rule-based agent that responds based on keyword matching. + + This agent does not use LLMs - it returns static strings based on + detected keywords in the input text. + """ + + def __init__(self, name: str = "RuleBot") -> None: + """Initialize the rule-based agent.""" + super().__init__(name) + self._rules: dict[str, str] = { + "attack": "Defending! Shield activated!", + "heal": "Healing spell cast! +50 HP", + "run": "Too slow! You can't escape!", + "help": "Allies are on the way!", + "fire": "Water shield deployed!", + "magic": "Counter-spell activated!", + } + self._default_response = "I heard you! Processing command..." + + def process_input(self, text: str) -> str: + """ + Process input and return response based on keyword rules. + + Args: + text: The input text to analyze + + Returns: + Response string based on matched keywords + """ + text_lower = text.lower() + + for keyword, response in self._rules.items(): + if keyword in text_lower: + logger.info(f"Agent matched keyword: {keyword}") + return f"{self.name}: {response}" + + logger.info("Agent using default response") + return f"{self.name}: {self._default_response}" diff --git a/backend/app/engine.py b/backend/app/engine.py new file mode 100644 index 0000000..3694caf --- /dev/null +++ b/backend/app/engine.py @@ -0,0 +1,145 @@ +""" +Core Game Engine - The "Heartbeat" of the game. +Runs the main game loop and coordinates between comments and agents. +""" + +import asyncio +import logging +import random +import time +from typing import TYPE_CHECKING + +from .schemas import GameEvent, EventType +from .agents import BaseAgent, RuleBasedAgent + +if TYPE_CHECKING: + from .server import ConnectionManager + +logger = logging.getLogger(__name__) + + +class GameEngine: + """ + The core game engine that runs the main game loop. + + Simulates live comments, processes them through agents, + and broadcasts responses to connected clients. + """ + + def __init__(self, connection_manager: "ConnectionManager") -> None: + """ + Initialize the game engine. + + Args: + connection_manager: The WebSocket connection manager for broadcasting + """ + self._manager = connection_manager + self._agent: BaseAgent = RuleBasedAgent(name="Guardian") + self._running = False + self._tick_count = 0 + self._tick_interval = 2.0 # seconds + + # Mock comment templates + self._mock_users = ["User123", "GamerPro", "DragonSlayer", "NightOwl", "StarGazer"] + self._mock_actions = ["Attack!", "Heal me!", "Run away!", "Help!", "Fire spell!", "Magic blast!"] + + @property + def is_running(self) -> bool: + """Check if the engine is currently running.""" + return self._running + + def _generate_mock_comment(self) -> tuple[str, str]: + """ + Generate a mock live comment. + + Returns: + Tuple of (username, comment_text) + """ + user = random.choice(self._mock_users) + action = random.choice(self._mock_actions) + return user, action + + async def _broadcast_event(self, event_type: str, data: dict) -> None: + """ + Create and broadcast a game event. + + Args: + event_type: Type of the event + data: Event payload data + """ + event = GameEvent( + event_type=event_type, + timestamp=time.time(), + data=data + ) + await self._manager.broadcast(event) + + async def process_comment(self, user: str, message: str) -> None: + """ + Process a comment (real or mock) through the agent pipeline. + + Args: + user: Username of the commenter + message: The comment text + """ + # Broadcast the incoming comment + await self._broadcast_event( + EventType.COMMENT, + {"user": user, "message": message} + ) + + # Process through agent + full_comment = f"{user}: {message}" + response = self._agent.process_input(full_comment) + + # Broadcast agent response + await self._broadcast_event( + EventType.AGENT_RESPONSE, + {"agent": self._agent.name, "response": response} + ) + + async def _game_loop(self) -> None: + """ + The main game loop - runs continuously while engine is active. + + Every tick: + 1. Simulates a mock live comment + 2. Passes it to the agent + 3. Broadcasts the response + """ + logger.info("Game loop started") + + while self._running: + self._tick_count += 1 + + # Broadcast tick event + await self._broadcast_event( + EventType.TICK, + {"tick": self._tick_count} + ) + + # Generate and process mock comment + user, message = self._generate_mock_comment() + logger.info(f"Tick {self._tick_count}: {user} says '{message}'") + + await self.process_comment(user, message) + + # Wait for next tick + await asyncio.sleep(self._tick_interval) + + logger.info("Game loop stopped") + + async def start(self) -> None: + """Start the game engine loop as a background task.""" + if self._running: + logger.warning("Engine already running") + return + + self._running = True + asyncio.create_task(self._game_loop()) + logger.info("Game engine started") + + async def stop(self) -> None: + """Stop the game engine loop.""" + self._running = False + logger.info("Game engine stopping...") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e0e24f4 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,102 @@ +""" +FastAPI entry point for the interactive live-stream game backend. +Configures the application, WebSocket routes, and lifecycle events. +""" + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware + +from .server import ConnectionManager +from .engine import GameEngine +from .schemas import GameEvent, ClientMessage, EventType + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global instances +manager = ConnectionManager() +engine = GameEngine(manager) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Application lifespan manager. + Starts the game engine on startup and stops it on shutdown. + """ + logger.info("Starting application...") + await engine.start() + yield + logger.info("Shutting down application...") + await engine.stop() + + +# Create FastAPI application +app = FastAPI( + title="The Island - Live Stream Game Backend", + description="Commercial-grade interactive live-stream game backend", + version="0.1.0", + lifespan=lifespan +) + +# Configure CORS for frontend access +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/") +async def root(): + """Health check endpoint.""" + return { + "status": "running", + "service": "The Island Game Backend", + "connections": manager.connection_count, + "engine_running": engine.is_running + } + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """ + WebSocket endpoint for real-time game communication. + + Handles client connections and processes incoming messages. + """ + await manager.connect(websocket) + + # Send welcome message + welcome = GameEvent( + event_type=EventType.SYSTEM, + data={"message": "Connected to The Island!"} + ) + await manager.send_personal(websocket, welcome) + + try: + while True: + # Receive and parse client message + data = await websocket.receive_json() + message = ClientMessage(**data) + + # Handle mock comment action + if message.action == "send_comment": + user = message.payload.get("user", "Anonymous") + text = message.payload.get("message", "") + await engine.process_comment(user, text) + + except WebSocketDisconnect: + manager.disconnect(websocket) + except Exception as e: + logger.error(f"WebSocket error: {e}") + manager.disconnect(websocket) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..ab6cd61 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,53 @@ +""" +Pydantic models for the JSON protocol. +Defines standardized structures for game events and messages. +""" + +from enum import Enum +from typing import Any +from pydantic import BaseModel, Field +import time + + +class EventType(str, Enum): + """Enumeration of all possible game event types.""" + COMMENT = "comment" + AGENT_RESPONSE = "agent_response" + TICK = "tick" + SYSTEM = "system" + ERROR = "error" + + +class GameEvent(BaseModel): + """ + Standardized game event structure for WebSocket communication. + + Attributes: + event_type: The type of event (comment, agent_response, tick, etc.) + timestamp: Unix timestamp when the event was created + data: Arbitrary payload data for the event + """ + event_type: str = Field(..., description="Type of the game event") + timestamp: float = Field(default_factory=time.time, description="Unix timestamp") + data: dict[str, Any] = Field(default_factory=dict, description="Event payload") + + class Config: + json_schema_extra = { + "example": { + "event_type": "comment", + "timestamp": 1704067200.0, + "data": {"user": "User123", "message": "Attack!"} + } + } + + +class ClientMessage(BaseModel): + """ + Message structure for client-to-server communication. + + Attributes: + action: The action the client wants to perform + payload: Data associated with the action + """ + action: str = Field(..., description="Action to perform") + payload: dict[str, Any] = Field(default_factory=dict, description="Action payload") diff --git a/backend/app/server.py b/backend/app/server.py new file mode 100644 index 0000000..d208a38 --- /dev/null +++ b/backend/app/server.py @@ -0,0 +1,90 @@ +""" +WebSocket connection manager and server utilities. +Handles client connections, disconnections, and message broadcasting. +""" + +import logging +from typing import Any +from fastapi import WebSocket + +from .schemas import GameEvent + +logger = logging.getLogger(__name__) + + +class ConnectionManager: + """ + Manages WebSocket connections for real-time communication. + + Handles connection lifecycle and provides broadcast capabilities + to all connected clients. + """ + + def __init__(self) -> None: + """Initialize the connection manager with an empty connection list.""" + self._active_connections: list[WebSocket] = [] + + @property + def connection_count(self) -> int: + """Return the number of active connections.""" + return len(self._active_connections) + + async def connect(self, websocket: WebSocket) -> None: + """ + Accept and register a new WebSocket connection. + + Args: + websocket: The WebSocket connection to accept + """ + await websocket.accept() + self._active_connections.append(websocket) + logger.info(f"Client connected. Total connections: {self.connection_count}") + + def disconnect(self, websocket: WebSocket) -> None: + """ + Remove a WebSocket connection from the active list. + + Args: + websocket: The WebSocket connection to remove + """ + if websocket in self._active_connections: + self._active_connections.remove(websocket) + logger.info(f"Client disconnected. Total connections: {self.connection_count}") + + async def broadcast(self, event: GameEvent) -> None: + """ + Send a GameEvent to all connected clients. + + Args: + event: The GameEvent to broadcast + """ + if not self._active_connections: + return + + message = event.model_dump_json() + disconnected: list[WebSocket] = [] + + for connection in self._active_connections: + try: + await connection.send_text(message) + except Exception as e: + logger.warning(f"Failed to send to client: {e}") + disconnected.append(connection) + + # Clean up failed connections + for conn in disconnected: + self.disconnect(conn) + + async def send_personal(self, websocket: WebSocket, event: GameEvent) -> None: + """ + Send a GameEvent to a specific client. + + Args: + websocket: The target WebSocket connection + event: The GameEvent to send + """ + try: + await websocket.send_text(event.model_dump_json()) + except Exception as e: + logger.warning(f"Failed to send personal message: {e}") + self.disconnect(websocket) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9375952 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +websockets>=12.0 +pydantic>=2.5.0 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..ae4491c --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,157 @@ +/** + * The Island - Debug Client JavaScript + * Handles WebSocket connection and UI interactions + */ + +let ws = null; +const WS_URL = 'ws://localhost:8000/ws'; + +// DOM Elements +const statusDot = document.getElementById('statusDot'); +const statusText = document.getElementById('statusText'); +const connectBtn = document.getElementById('connectBtn'); +const eventLog = document.getElementById('eventLog'); +const usernameInput = document.getElementById('username'); +const messageInput = document.getElementById('message'); +const autoScrollCheckbox = document.getElementById('autoScroll'); + +/** + * Toggle WebSocket connection + */ +function toggleConnection() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } else { + connect(); + } +} + +/** + * Establish WebSocket connection + */ +function connect() { + statusText.textContent = 'Connecting...'; + connectBtn.disabled = true; + + ws = new WebSocket(WS_URL); + + ws.onopen = () => { + statusDot.classList.add('connected'); + statusText.textContent = 'Connected'; + connectBtn.textContent = 'Disconnect'; + connectBtn.disabled = false; + logEvent({ event_type: 'system', data: { message: 'WebSocket connected' } }); + }; + + ws.onclose = () => { + statusDot.classList.remove('connected'); + statusText.textContent = 'Disconnected'; + connectBtn.textContent = 'Connect'; + connectBtn.disabled = false; + logEvent({ event_type: 'system', data: { message: 'WebSocket disconnected' } }); + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + logEvent({ event_type: 'error', data: { message: 'Connection error' } }); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + logEvent(data); + } catch (e) { + console.error('Failed to parse message:', e); + } + }; +} + +/** + * Send a mock comment to the server + */ +function sendComment() { + if (!ws || ws.readyState !== WebSocket.OPEN) { + alert('Not connected to server'); + return; + } + + const user = usernameInput.value.trim() || 'Anonymous'; + const message = messageInput.value.trim(); + + if (!message) { + alert('Please enter a message'); + return; + } + + const payload = { + action: 'send_comment', + payload: { user, message } + }; + + ws.send(JSON.stringify(payload)); + messageInput.value = ''; +} + +/** + * Format timestamp for display + */ +function formatTime(timestamp) { + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString(); +} + +/** + * Format event data for display + */ +function formatEventData(eventType, data) { + switch (eventType) { + case 'comment': + return `${data.user}: ${data.message}`; + case 'agent_response': + return data.response; + case 'tick': + return `Tick #${data.tick}`; + case 'system': + case 'error': + return data.message; + default: + return JSON.stringify(data); + } +} + +/** + * Log an event to the display + */ +function logEvent(event) { + const eventType = event.event_type || 'unknown'; + const timestamp = event.timestamp || Date.now() / 1000; + const data = event.data || {}; + + const div = document.createElement('div'); + div.className = `event ${eventType}`; + div.innerHTML = ` + ${formatTime(timestamp)} + ${eventType} +
${formatEventData(eventType, data)}
+ `; + + eventLog.appendChild(div); + + if (autoScrollCheckbox.checked) { + eventLog.scrollTop = eventLog.scrollHeight; + } +} + +/** + * Clear the event log + */ +function clearLog() { + eventLog.innerHTML = ''; +} + +// Allow Enter key to send comment +messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + sendComment(); + } +}); diff --git a/frontend/debug_client.html b/frontend/debug_client.html new file mode 100644 index 0000000..7f4c209 --- /dev/null +++ b/frontend/debug_client.html @@ -0,0 +1,179 @@ + + + + + + The Island - Debug Client + + + +
+

The Island - Debug Client

+ +
+
+
+ Disconnected +
+ +
+ +
+
+

Send Mock Comment

+
+ + + +
+
+ +
+

Event Log

+
+ + +
+
+
+
+
+ + + + diff --git a/run.py b/run.py new file mode 100644 index 0000000..f1c1895 --- /dev/null +++ b/run.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +Startup script for The Island game backend. +Runs the FastAPI server with uvicorn and hot-reloading enabled. +""" + +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "backend.app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + )