From cf1739b7f89d9a15b560a923bfe0ecfa8d29bf88 Mon Sep 17 00:00:00 2001 From: empty Date: Tue, 30 Dec 2025 17:15:35 +0800 Subject: [PATCH] feat: pivot to island survival simulation with SQLite persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 - "The Island" transformation: - Add SQLAlchemy + SQLite for data persistence (database.py) - Rewrite models.py with User, Agent, WorldState ORM models - Refactor engine.py for survival mechanics (energy decay, starvation) - Implement feed command (10 gold -> +20 energy) - Auto-seed 3 NPCs on startup (Jack/Luna/Bob) - Update frontend with agent card view and Chinese UI - Remove old Boss/Player RPG mechanics - Add .gitignore for database and cache files - Fix SQLAlchemy session detachment issue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 32 +++ backend/app/agents.py | 82 -------- backend/app/database.py | 61 ++++++ backend/app/engine.py | 419 +++++++++++++++++++++---------------- backend/app/models.py | 113 +++++----- backend/app/schemas.py | 15 +- backend/requirements.txt | 2 + frontend/app.js | 265 +++++++++++------------ frontend/debug_client.html | 300 +++++++++++++------------- 9 files changed, 676 insertions(+), 613 deletions(-) create mode 100644 .gitignore delete mode 100644 backend/app/agents.py create mode 100644 backend/app/database.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..107685b --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg +*.egg-info/ +.eggs/ + +# Virtual environment +venv/ +.venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/backend/app/agents.py b/backend/app/agents.py deleted file mode 100644 index 27c3294..0000000 --- a/backend/app/agents.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -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/database.py b/backend/app/database.py new file mode 100644 index 0000000..f039903 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,61 @@ +""" +Database configuration for The Island. +SQLite + SQLAlchemy ORM setup. +""" + +from pathlib import Path +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +# Database file path (in project root) +DB_PATH = Path(__file__).parent.parent.parent / "island.db" +DATABASE_URL = f"sqlite:///{DB_PATH}" + +# Create engine with SQLite optimizations +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False}, # Required for SQLite with FastAPI + echo=False # Set True for SQL debugging +) + +# Session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base class for ORM models +Base = declarative_base() + + +def init_db(): + """Initialize database tables.""" + Base.metadata.create_all(bind=engine) + + +def get_db(): + """ + Dependency for getting database session. + Use with FastAPI's Depends() or as context manager. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@contextmanager +def get_db_session(): + """ + Context manager for database session. + Usage: with get_db_session() as db: ... + """ + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/backend/app/engine.py b/backend/app/engine.py index 3db97de..05101a3 100644 --- a/backend/app/engine.py +++ b/backend/app/engine.py @@ -1,6 +1,6 @@ """ -Core Game Engine - The "Heartbeat" of the game. -Manages game state, processes commands, and coordinates broadcasts. +Core Game Engine - The Island Survival Simulation. +Manages survival mechanics, agent states, and user interactions. """ import asyncio @@ -10,35 +10,43 @@ import time from typing import TYPE_CHECKING from .schemas import GameEvent, EventType -from .models import Player, Boss +from .database import init_db, get_db_session +from .models import User, Agent, WorldState if TYPE_CHECKING: from .server import ConnectionManager logger = logging.getLogger(__name__) -# Command patterns for string matching -ATTACK_PATTERN = re.compile(r"(attack|攻击|打|砍|杀)", re.IGNORECASE) -HEAL_PATTERN = re.compile(r"(heal|治疗|回血|加血|恢复)", re.IGNORECASE) -STATUS_PATTERN = re.compile(r"(status|查询|状态|信息)", re.IGNORECASE) +# Command patterns +FEED_PATTERN = re.compile(r"feed\s+(\w+)", re.IGNORECASE) +CHECK_PATTERN = re.compile(r"(check|查询|状态)", re.IGNORECASE) # Game constants -ATTACK_DAMAGE = 10 -ATTACK_GOLD_REWARD = 10 -HEAL_AMOUNT = 10 -BOSS_COUNTER_DAMAGE = 15 # Boss反击伤害 +TICK_INTERVAL = 5.0 # Seconds between ticks +ENERGY_DECAY_PER_TICK = 2 # Energy lost per tick +HP_DECAY_WHEN_STARVING = 5 # HP lost when energy is 0 +FEED_COST = 10 # Gold cost to feed an agent +FEED_ENERGY_RESTORE = 20 # Energy restored when fed +INITIAL_USER_GOLD = 100 # Starting gold for new users + +# Initial NPC data +INITIAL_AGENTS = [ + {"name": "Jack", "personality": "勇敢"}, + {"name": "Luna", "personality": "狡猾"}, + {"name": "Bob", "personality": "老实"}, +] class GameEngine: """ - The core game engine that manages RPG state and game loop. - - Manages players, boss, and processes commands through string matching. + The core game engine for island survival simulation. + Manages agents, users, and survival mechanics with database persistence. """ def __init__(self, connection_manager: "ConnectionManager") -> None: """ - Initialize the game engine with state storage. + Initialize the game engine. Args: connection_manager: The WebSocket connection manager for broadcasting @@ -46,40 +54,50 @@ class GameEngine: self._manager = connection_manager self._running = False self._tick_count = 0 - self._tick_interval = 2.0 # seconds - - # Game state - self.players: dict[str, Player] = {} - self.boss = Boss(name="Dragon", hp=1000, max_hp=1000) + self._tick_interval = TICK_INTERVAL @property def is_running(self) -> bool: """Check if the engine is currently running.""" return self._running - def _get_or_create_player(self, username: str) -> Player: - """ - Get existing player or create new one. + def _seed_initial_data(self) -> None: + """Seed initial agents and world state if database is empty.""" + with get_db_session() as db: + # Check if agents exist + agent_count = db.query(Agent).count() + if agent_count == 0: + logger.info("Seeding initial agents...") + for agent_data in INITIAL_AGENTS: + agent = Agent( + name=agent_data["name"], + personality=agent_data["personality"], + status="Alive", + hp=100, + energy=100 + ) + db.add(agent) + logger.info(f"Created {len(INITIAL_AGENTS)} initial agents") - Args: - username: The player's username + # Check if world state exists + world = db.query(WorldState).first() + if world is None: + logger.info("Seeding initial world state...") + world = WorldState(day_count=1, weather="Sunny", resource_level=100) + db.add(world) - Returns: - Player instance - """ - if username not in self.players: - self.players[username] = Player(name=username) - logger.info(f"New player registered: {username}") - return self.players[username] + def _get_or_create_user(self, db, username: str) -> User: + """Get existing user or create new one.""" + user = db.query(User).filter(User.username == username).first() + if user is None: + user = User(username=username, gold=INITIAL_USER_GOLD) + db.add(user) + db.flush() # Get the ID without committing + logger.info(f"New user registered: {username}") + return user 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 - """ + """Create and broadcast a game event.""" event = GameEvent( event_type=event_type, timestamp=time.time(), @@ -87,216 +105,245 @@ class GameEngine: ) await self._manager.broadcast(event) - async def _broadcast_boss_update(self) -> None: - """Broadcast current boss status to all clients.""" + async def _broadcast_agents_status(self) -> None: + """Broadcast all agents' current status.""" + with get_db_session() as db: + agents = db.query(Agent).all() + agents_data = [agent.to_dict() for agent in agents] + await self._broadcast_event( - EventType.BOSS_UPDATE, - { - "boss_name": self.boss.name, - "boss_hp": self.boss.hp, - "boss_max_hp": self.boss.max_hp, - "boss_hp_percentage": self.boss.hp_percentage - } + EventType.AGENTS_UPDATE, + {"agents": agents_data} ) - async def _handle_attack(self, player: Player) -> None: + async def _process_survival_tick(self) -> None: """ - Handle attack command. Boss will counter-attack! + Process survival mechanics for all alive agents. + - Decrease energy + - If energy <= 0, decrease HP + - If HP <= 0, mark as Dead + """ + deaths = [] - Args: - player: The attacking player - """ - if not self.boss.is_alive: + with get_db_session() as db: + alive_agents = db.query(Agent).filter(Agent.status == "Alive").all() + + for agent in alive_agents: + # Energy decay + agent.energy = max(0, agent.energy - ENERGY_DECAY_PER_TICK) + + # Starvation damage + if agent.energy <= 0: + agent.hp = max(0, agent.hp - HP_DECAY_WHEN_STARVING) + + # Check for death + if agent.hp <= 0: + agent.status = "Dead" + deaths.append({ + "name": agent.name, + "personality": agent.personality + }) + logger.info(f"Agent {agent.name} has died!") + + # Broadcast death events + for death in deaths: await self._broadcast_event( - EventType.SYSTEM, - {"message": f"Boss is already defeated! Waiting for respawn..."} + EventType.AGENT_DIED, + { + "agent_name": death["name"], + "message": f"💀 {death['name']}({death['personality']})因饥饿而死亡..." + } ) - return - # Player attacks boss - damage = self.boss.take_damage(ATTACK_DAMAGE) - player.add_gold(ATTACK_GOLD_REWARD) - - # Boss counter-attacks player - counter_damage = player.take_damage(BOSS_COUNTER_DAMAGE) - - await self._broadcast_event( - EventType.ATTACK, - { - "user": player.name, - "damage": damage, - "counter_damage": counter_damage, - "gold_earned": ATTACK_GOLD_REWARD, - "boss_hp": self.boss.hp, - "boss_max_hp": self.boss.max_hp, - "player_hp": player.hp, - "player_max_hp": player.max_hp, - "player_gold": player.gold, - "message": f"⚔️ {player.name} attacked {self.boss.name}! " - f"Dealt {damage} dmg, received {counter_damage} counter-attack. " - f"HP: {player.hp}/{player.max_hp} | Boss: {self.boss.hp}/{self.boss.max_hp}" - } - ) - - await self._broadcast_boss_update() - - # Check if player died - if not player.is_alive: - await self._handle_player_death(player) - return - - # Check if boss defeated - if not self.boss.is_alive: - await self._handle_boss_defeated() - - async def _handle_player_death(self, player: Player) -> None: - """Handle player death - respawn with full HP but lose half gold.""" - lost_gold = player.gold // 2 - player.gold -= lost_gold - player.hp = player.max_hp # Respawn with full HP - - await self._broadcast_event( - EventType.SYSTEM, - { - "user": player.name, - "player_hp": player.hp, - "player_max_hp": player.max_hp, - "player_gold": player.gold, - "message": f"💀 {player.name} was slain by {self.boss.name}! " - f"Lost {lost_gold} gold. Respawned with full HP." - } - ) - - async def _handle_boss_defeated(self) -> None: - """Handle boss defeat and reset.""" - await self._broadcast_event( - EventType.BOSS_DEFEATED, - { - "boss_name": self.boss.name, - "message": f"🎉 {self.boss.name} has been defeated! A new boss will spawn soon..." - } - ) - - # Reset boss after short delay - await asyncio.sleep(3.0) - self.boss.reset() - await self._broadcast_event( - EventType.SYSTEM, - {"message": f"⚔️ {self.boss.name} has respawned with full HP!"} - ) - await self._broadcast_boss_update() - - async def _handle_heal(self, player: Player) -> None: + async def _handle_feed(self, username: str, agent_name: str) -> None: """ - Handle heal command. + Handle feed command. Args: - player: The healing player + username: The user feeding the agent + agent_name: Name of the agent to feed """ - healed = player.heal(HEAL_AMOUNT) + # Variables to store data for broadcasting (outside session) + feed_result = None - await self._broadcast_event( - EventType.HEAL, - { - "user": player.name, - "healed": healed, - "player_hp": player.hp, - "player_max_hp": player.max_hp, - "message": f"{player.name} healed themselves. " - f"Restored {healed} HP. HP: {player.hp}/{player.max_hp}" + with get_db_session() as db: + user = self._get_or_create_user(db, username) + + # Find the agent + agent = db.query(Agent).filter( + Agent.name.ilike(agent_name) + ).first() + + if agent is None: + await self._broadcast_event( + EventType.ERROR, + {"message": f"找不到名为 {agent_name} 的角色"} + ) + return + + if agent.status != "Alive": + await self._broadcast_event( + EventType.ERROR, + {"message": f"{agent.name} 已经死亡,无法投喂"} + ) + return + + if user.gold < FEED_COST: + await self._broadcast_event( + EventType.ERROR, + { + "user": username, + "message": f"金币不足!需要 {FEED_COST} 金币,当前只有 {user.gold} 金币" + } + ) + return + + # Perform feed + user.gold -= FEED_COST + old_energy = agent.energy + agent.energy = min(100, agent.energy + FEED_ENERGY_RESTORE) + actual_restore = agent.energy - old_energy + + # Store data for broadcasting before session closes + feed_result = { + "agent_name": agent.name, + "actual_restore": actual_restore, + "agent_energy": agent.energy, + "user_gold": user.gold } - ) - async def _handle_status(self, player: Player) -> None: - """ - Handle status query command. + # Broadcast outside of session + if feed_result: + await self._broadcast_event( + EventType.FEED, + { + "user": username, + "agent_name": feed_result["agent_name"], + "energy_restored": feed_result["actual_restore"], + "agent_energy": feed_result["agent_energy"], + "user_gold": feed_result["user_gold"], + "message": f"🍖 {username} 投喂了 {feed_result['agent_name']}!" + f"恢复 {feed_result['actual_restore']} 点体力(当前: {feed_result['agent_energy']}/100)" + } + ) + + await self._broadcast_event( + EventType.USER_UPDATE, + { + "user": username, + "gold": feed_result["user_gold"] + } + ) + + async def _handle_check(self, username: str) -> None: + """Handle check/status command.""" + with get_db_session() as db: + user = self._get_or_create_user(db, username) + agents = db.query(Agent).all() + world = db.query(WorldState).first() + + # Extract data before session closes + user_data = {"username": user.username, "gold": user.gold} + agents_data = [agent.to_dict() for agent in agents] + world_data = world.to_dict() if world else {} + message = f"📊 {username} 的状态 - 金币: {user_data['gold']}" - Args: - player: The querying player - """ await self._broadcast_event( - EventType.STATUS, + EventType.CHECK, { - "user": player.name, - "player_hp": player.hp, - "player_max_hp": player.max_hp, - "player_gold": player.gold, - "boss_name": self.boss.name, - "boss_hp": self.boss.hp, - "boss_max_hp": self.boss.max_hp, - "message": f"📊 {player.name}'s Status - HP: {player.hp}/{player.max_hp}, " - f"Gold: {player.gold} | Boss {self.boss.name}: {self.boss.hp}/{self.boss.max_hp}" + "user": user_data, + "agents": agents_data, + "world": world_data, + "message": message } ) async def process_comment(self, user: str, message: str) -> None: """ - Process a comment through string matching command system. + Process a comment through command matching. Args: user: Username of the commenter message: The comment text """ - # Get or create player - player = self._get_or_create_player(user) - - # Broadcast the incoming comment first + # Broadcast the incoming comment await self._broadcast_event( EventType.COMMENT, {"user": user, "message": message} ) - # Process command through string matching - if ATTACK_PATTERN.search(message): - await self._handle_attack(player) - elif HEAL_PATTERN.search(message): - await self._handle_heal(player) - elif STATUS_PATTERN.search(message): - await self._handle_status(player) - # If no command matched, treat as regular chat (no action needed) + # Match commands + feed_match = FEED_PATTERN.search(message) + if feed_match: + agent_name = feed_match.group(1) + await self._handle_feed(user, agent_name) + return + + if CHECK_PATTERN.search(message): + await self._handle_check(user) + return + + # No command matched - treat as regular chat async def _game_loop(self) -> None: """ - The main game loop - runs continuously while engine is active. + The main game loop - survival simulation. - Every tick broadcasts current game state. + Every tick: + 1. Process survival mechanics (energy/HP decay) + 2. Broadcast agent states """ - logger.info("Game loop started") + logger.info("Game loop started - Island survival simulation") - # Broadcast initial boss state - await self._broadcast_boss_update() + # Initial broadcast + await self._broadcast_agents_status() while self._running: self._tick_count += 1 - # Broadcast tick event with game state + # Process survival mechanics + await self._process_survival_tick() + + # Broadcast current state + await self._broadcast_agents_status() + + # Broadcast tick event + with get_db_session() as db: + alive_count = db.query(Agent).filter(Agent.status == "Alive").count() + world = db.query(WorldState).first() + day = world.day_count if world else 1 + await self._broadcast_event( EventType.TICK, { "tick": self._tick_count, - "boss_hp": self.boss.hp, - "boss_max_hp": self.boss.max_hp, - "player_count": len(self.players) + "day": day, + "alive_agents": alive_count } ) - logger.debug(f"Tick {self._tick_count}: Boss HP {self.boss.hp}/{self.boss.max_hp}") + logger.debug(f"Tick {self._tick_count}: {alive_count} agents alive") 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.""" + """Start the game engine.""" if self._running: logger.warning("Engine already running") return + # Initialize database and seed data + logger.info("Initializing database...") + init_db() + self._seed_initial_data() + self._running = True asyncio.create_task(self._game_loop()) - logger.info("Game engine started") + logger.info("Game engine started - The Island awaits...") async def stop(self) -> None: - """Stop the game engine loop.""" + """Stop the game engine.""" self._running = False logger.info("Game engine stopping...") diff --git a/backend/app/models.py b/backend/app/models.py index 1893943..061852c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,66 +1,81 @@ """ -Game entity models for The Island. -Defines Player and Boss data structures. +SQLAlchemy ORM models for The Island. +Defines User (viewers), Agent (NPCs), and WorldState entities. """ -from pydantic import BaseModel, Field +from datetime import datetime + +from sqlalchemy import Column, Integer, String, DateTime, func + +from .database import Base -class Player(BaseModel): +class User(Base): """ - Represents a player in the game world. + Represents a viewer/donor from the live stream. + They can spend gold to influence the island. """ - name: str - hp: int = Field(default=100, ge=0) - max_hp: int = Field(default=100, gt=0) - gold: int = Field(default=0, ge=0) + __tablename__ = "users" - def take_damage(self, amount: int) -> int: - """Apply damage to player, returns actual damage dealt.""" - actual = min(amount, self.hp) - self.hp -= actual - return actual + id = Column(Integer, primary_key=True, index=True) + username = Column(String(100), unique=True, index=True, nullable=False) + gold = Column(Integer, default=100) # Starting gold for new users + created_at = Column(DateTime, default=func.now()) - def heal(self, amount: int) -> int: - """Heal player, returns actual HP restored.""" - actual = min(amount, self.max_hp - self.hp) - self.hp += actual - return actual - - def add_gold(self, amount: int) -> None: - """Add gold to player.""" - self.gold += amount - - @property - def is_alive(self) -> bool: - """Check if player is alive.""" - return self.hp > 0 + def __repr__(self): + return f"" -class Boss(BaseModel): +class Agent(Base): """ - Represents a boss enemy in the game. + Represents an NPC survivor on the island. + Has personality, health, energy, and inventory. """ - name: str - hp: int = Field(ge=0) - max_hp: int = Field(gt=0) + __tablename__ = "agents" - def take_damage(self, amount: int) -> int: - """Apply damage to boss, returns actual damage dealt.""" - actual = min(amount, self.hp) - self.hp -= actual - return actual + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), unique=True, nullable=False) + personality = Column(String(50), nullable=False) + status = Column(String(20), default="Alive") # Alive, Exiled, Dead + hp = Column(Integer, default=100) + energy = Column(Integer, default=100) + inventory = Column(String(500), default="{}") # JSON string - def reset(self) -> None: - """Reset boss to full HP.""" - self.hp = self.max_hp + def __repr__(self): + return f"" - @property - def is_alive(self) -> bool: - """Check if boss is alive.""" - return self.hp > 0 + def to_dict(self): + """Convert to dictionary for JSON serialization.""" + return { + "id": self.id, + "name": self.name, + "personality": self.personality, + "status": self.status, + "hp": self.hp, + "energy": self.energy, + "inventory": self.inventory + } - @property - def hp_percentage(self) -> float: - """Get HP as percentage.""" - return (self.hp / self.max_hp) * 100 if self.max_hp > 0 else 0 + +class WorldState(Base): + """ + Global state of the island environment. + Tracks day count, weather, and shared resources. + """ + __tablename__ = "world_state" + + id = Column(Integer, primary_key=True, index=True) + day_count = Column(Integer, default=1) + weather = Column(String(20), default="Sunny") + resource_level = Column(Integer, default=100) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary for JSON serialization.""" + return { + "day_count": self.day_count, + "weather": self.weather, + "resource_level": self.resource_level + } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e1add3d..638d575 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -12,17 +12,16 @@ 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" - # RPG-specific events - ATTACK = "attack" - HEAL = "heal" - STATUS = "status" - BOSS_UPDATE = "boss_update" - BOSS_DEFEATED = "boss_defeated" - PLAYER_UPDATE = "player_update" + # Island survival events + AGENTS_UPDATE = "agents_update" # All agents status broadcast + AGENT_DIED = "agent_died" # An agent has died + FEED = "feed" # User fed an agent + USER_UPDATE = "user_update" # User gold/status update + WORLD_UPDATE = "world_update" # World state update + CHECK = "check" # Status check response class GameEvent(BaseModel): diff --git a/backend/requirements.txt b/backend/requirements.txt index 9375952..954815c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,3 +2,5 @@ fastapi>=0.109.0 uvicorn[standard]>=0.27.0 websockets>=12.0 pydantic>=2.5.0 +sqlalchemy>=2.0.0 +aiosqlite>=0.19.0 diff --git a/frontend/app.js b/frontend/app.js index 8311be8..ab2d4be 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,17 +1,16 @@ /** - * The Island - Debug Client JavaScript - * Handles WebSocket connection, UI interactions, and game state display + * The Island - Survival Simulation Client + * Handles WebSocket connection, agent display, and user interactions */ let ws = null; const WS_URL = 'ws://localhost:8080/ws'; -// Player state (tracked from server events) -let playerState = { - hp: 100, - maxHp: 100, - gold: 0 -}; +// User state +let userGold = 100; + +// Agents state +let agents = []; // DOM Elements const statusDot = document.getElementById('statusDot'); @@ -22,16 +21,8 @@ const usernameInput = document.getElementById('username'); const messageInput = document.getElementById('message'); const autoScrollCheckbox = document.getElementById('autoScroll'); const hideTicksCheckbox = document.getElementById('hideTicks'); - -// Boss UI Elements -const bossName = document.getElementById('bossName'); -const bossHpText = document.getElementById('bossHpText'); -const bossHealthBar = document.getElementById('bossHealthBar'); -const bossHealthLabel = document.getElementById('bossHealthLabel'); - -// Player UI Elements -const playerHpDisplay = document.getElementById('playerHp'); -const playerGoldDisplay = document.getElementById('playerGold'); +const agentsGrid = document.getElementById('agentsGrid'); +const userGoldDisplay = document.getElementById('userGold'); /** * Toggle WebSocket connection @@ -48,30 +39,30 @@ function toggleConnection() { * Establish WebSocket connection */ function connect() { - statusText.textContent = 'Connecting...'; + statusText.textContent = '连接中...'; connectBtn.disabled = true; ws = new WebSocket(WS_URL); ws.onopen = () => { statusDot.classList.add('connected'); - statusText.textContent = 'Connected'; - connectBtn.textContent = 'Disconnect'; + statusText.textContent = '已连接'; + connectBtn.textContent = '断开'; connectBtn.disabled = false; - logEvent({ event_type: 'system', data: { message: 'WebSocket connected' } }); + logEvent({ event_type: 'system', data: { message: '已连接到荒岛服务器' } }); }; ws.onclose = () => { statusDot.classList.remove('connected'); - statusText.textContent = 'Disconnected'; - connectBtn.textContent = 'Connect'; + statusText.textContent = '未连接'; + connectBtn.textContent = '连接'; connectBtn.disabled = false; - logEvent({ event_type: 'system', data: { message: 'WebSocket disconnected' } }); + logEvent({ event_type: 'system', data: { message: '与服务器断开连接' } }); }; ws.onerror = (error) => { console.error('WebSocket error:', error); - logEvent({ event_type: 'error', data: { message: 'Connection error' } }); + logEvent({ event_type: 'error', data: { message: '连接错误' } }); }; ws.onmessage = (event) => { @@ -91,122 +82,123 @@ function handleGameEvent(event) { const eventType = event.event_type; const data = event.data || {}; - // Update UI based on event type switch (eventType) { - case 'boss_update': - updateBossUI(data); + case 'agents_update': + updateAgentsUI(data.agents); break; - case 'attack': - updateBossFromAttack(data); - updatePlayerFromEvent(data); + case 'feed': + case 'user_update': + updateUserGold(data); break; - case 'heal': - updatePlayerFromEvent(data); - break; - case 'status': - updatePlayerFromEvent(data); - updateBossFromStatus(data); - break; - case 'system': - // Handle player death/respawn updates - if (data.user && data.player_hp !== undefined) { - updatePlayerFromEvent(data); - } - break; - case 'tick': - if (data.boss_hp !== undefined) { - updateBossUI({ - boss_hp: data.boss_hp, - boss_max_hp: data.boss_max_hp - }); + case 'check': + if (data.user && data.user.username === getCurrentUser()) { + userGold = data.user.gold; + userGoldDisplay.textContent = userGold; } break; } - // Log the event logEvent(event); } /** - * Update Boss health bar UI + * Get current username */ -function updateBossUI(data) { - if (data.boss_name) { - bossName.textContent = data.boss_name; +function getCurrentUser() { + return usernameInput.value.trim() || '观众001'; +} + +/** + * Update user gold display + */ +function updateUserGold(data) { + if (data.user === getCurrentUser() && data.gold !== undefined) { + userGold = data.gold; + userGoldDisplay.textContent = userGold; } - if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) { - const hp = data.boss_hp; - const maxHp = data.boss_max_hp; - const percentage = maxHp > 0 ? (hp / maxHp) * 100 : 0; - - bossHpText.textContent = `HP: ${hp} / ${maxHp}`; - bossHealthBar.style.width = `${percentage}%`; - bossHealthLabel.textContent = `${Math.round(percentage)}%`; - - // Change color based on HP percentage - if (percentage <= 25) { - bossHealthBar.style.background = 'linear-gradient(90deg, #ff2222 0%, #ff4444 100%)'; - } else if (percentage <= 50) { - bossHealthBar.style.background = 'linear-gradient(90deg, #ff6600 0%, #ff8844 100%)'; - } else { - bossHealthBar.style.background = 'linear-gradient(90deg, #ff4444 0%, #ff6666 100%)'; - } + if (data.user_gold !== undefined && data.user === getCurrentUser()) { + userGold = data.user_gold; + userGoldDisplay.textContent = userGold; } } /** - * Update boss from attack event + * Update agents UI with card view */ -function updateBossFromAttack(data) { - if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) { - updateBossUI({ - boss_hp: data.boss_hp, - boss_max_hp: data.boss_max_hp - }); - } +function updateAgentsUI(agentsData) { + if (!agentsData || agentsData.length === 0) return; + + agents = agentsData; + agentsGrid.innerHTML = ''; + + agents.forEach(agent => { + const card = createAgentCard(agent); + agentsGrid.appendChild(card); + }); } /** - * Update boss from status event + * Create an agent card element */ -function updateBossFromStatus(data) { - if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) { - updateBossUI({ - boss_name: data.boss_name, - boss_hp: data.boss_hp, - boss_max_hp: data.boss_max_hp - }); - } +function createAgentCard(agent) { + const isDead = agent.status !== 'Alive'; + const card = document.createElement('div'); + card.className = `agent-card ${isDead ? 'dead' : ''}`; + card.id = `agent-${agent.id}`; + + const statusClass = isDead ? 'dead' : 'alive'; + const statusText = isDead ? '已死亡' : '存活'; + + card.innerHTML = ` +
+
+ ${agent.name} + ${agent.personality} +
+ ${statusText} +
+
+
+ ❤️ 生命值 + ${agent.hp}/100 +
+
+
+
+
+
+
+ ⚡ 体力 + ${agent.energy}/100 +
+
+
+
+
+ + `; + + return card; } /** - * Update player stats from event data + * Feed an agent */ -function updatePlayerFromEvent(data) { - const currentUser = usernameInput.value.trim() || 'Anonymous'; - - // Only update if this event is for the current user - if (data.user !== currentUser) return; - - if (data.player_hp !== undefined) { - playerState.hp = data.player_hp; - } - if (data.player_max_hp !== undefined) { - playerState.maxHp = data.player_max_hp; - } - if (data.player_gold !== undefined) { - playerState.gold = data.player_gold; +function feedAgent(agentName) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + alert('未连接到服务器'); + return; } - updatePlayerUI(); -} + const user = getCurrentUser(); + const payload = { + action: 'send_comment', + payload: { user, message: `feed ${agentName}` } + }; -/** - * Update player stats UI - */ -function updatePlayerUI() { - playerHpDisplay.textContent = `${playerState.hp}/${playerState.maxHp}`; - playerGoldDisplay.textContent = playerState.gold; + ws.send(JSON.stringify(payload)); } /** @@ -214,15 +206,15 @@ function updatePlayerUI() { */ function sendComment() { if (!ws || ws.readyState !== WebSocket.OPEN) { - alert('Not connected to server'); + alert('未连接到服务器'); return; } - const user = usernameInput.value.trim() || 'Anonymous'; + const user = getCurrentUser(); const message = messageInput.value.trim(); if (!message) { - alert('Please enter a message'); + alert('请输入指令'); return; } @@ -235,25 +227,6 @@ function sendComment() { messageInput.value = ''; } -/** - * Quick action buttons - */ -function quickAction(action) { - if (!ws || ws.readyState !== WebSocket.OPEN) { - alert('Not connected to server'); - return; - } - - const user = usernameInput.value.trim() || 'Anonymous'; - - const payload = { - action: 'send_comment', - payload: { user, message: action } - }; - - ws.send(JSON.stringify(payload)); -} - /** * Format timestamp for display */ @@ -269,20 +242,18 @@ 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} | Players: ${data.player_count || 0}`; + return `Tick #${data.tick} | 第${data.day}天 | 存活: ${data.alive_agents}人`; case 'system': case 'error': + case 'feed': + case 'agent_died': + case 'check': return data.message; - case 'attack': - case 'heal': - case 'status': - case 'boss_defeated': - return data.message; - case 'boss_update': - return `Boss ${data.boss_name}: ${data.boss_hp}/${data.boss_max_hp} (${Math.round(data.boss_hp_percentage)}%)`; + case 'agents_update': + return `角色状态已更新`; + case 'user_update': + return `${data.user} 金币: ${data.gold}`; default: return JSON.stringify(data); } @@ -301,8 +272,8 @@ function logEvent(event) { return; } - // Skip boss_update events to reduce log noise (they're reflected in the UI) - if (eventType === 'boss_update') { + // Skip agents_update to reduce noise + if (eventType === 'agents_update') { return; } diff --git a/frontend/debug_client.html b/frontend/debug_client.html index 7499f51..640aa65 100644 --- a/frontend/debug_client.html +++ b/frontend/debug_client.html @@ -1,9 +1,9 @@ - + - The Island - Debug Client + 荒岛:人性的试炼