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 <noreply@anthropic.com>
350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""
|
||
Core Game Engine - The Island Survival Simulation.
|
||
Manages survival mechanics, agent states, and user interactions.
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
import time
|
||
from typing import TYPE_CHECKING
|
||
|
||
from .schemas import GameEvent, EventType
|
||
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
|
||
FEED_PATTERN = re.compile(r"feed\s+(\w+)", re.IGNORECASE)
|
||
CHECK_PATTERN = re.compile(r"(check|查询|状态)", re.IGNORECASE)
|
||
|
||
# Game constants
|
||
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 for island survival simulation.
|
||
Manages agents, users, and survival mechanics with database persistence.
|
||
"""
|
||
|
||
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._running = False
|
||
self._tick_count = 0
|
||
self._tick_interval = TICK_INTERVAL
|
||
|
||
@property
|
||
def is_running(self) -> bool:
|
||
"""Check if the engine is currently running."""
|
||
return self._running
|
||
|
||
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")
|
||
|
||
# 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)
|
||
|
||
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."""
|
||
event = GameEvent(
|
||
event_type=event_type,
|
||
timestamp=time.time(),
|
||
data=data
|
||
)
|
||
await self._manager.broadcast(event)
|
||
|
||
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.AGENTS_UPDATE,
|
||
{"agents": agents_data}
|
||
)
|
||
|
||
async def _process_survival_tick(self) -> None:
|
||
"""
|
||
Process survival mechanics for all alive agents.
|
||
- Decrease energy
|
||
- If energy <= 0, decrease HP
|
||
- If HP <= 0, mark as Dead
|
||
"""
|
||
deaths = []
|
||
|
||
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.AGENT_DIED,
|
||
{
|
||
"agent_name": death["name"],
|
||
"message": f"💀 {death['name']}({death['personality']})因饥饿而死亡..."
|
||
}
|
||
)
|
||
|
||
async def _handle_feed(self, username: str, agent_name: str) -> None:
|
||
"""
|
||
Handle feed command.
|
||
|
||
Args:
|
||
username: The user feeding the agent
|
||
agent_name: Name of the agent to feed
|
||
"""
|
||
# Variables to store data for broadcasting (outside session)
|
||
feed_result = None
|
||
|
||
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
|
||
}
|
||
|
||
# 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']}"
|
||
|
||
await self._broadcast_event(
|
||
EventType.CHECK,
|
||
{
|
||
"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 command matching.
|
||
|
||
Args:
|
||
user: Username of the commenter
|
||
message: The comment text
|
||
"""
|
||
# Broadcast the incoming comment
|
||
await self._broadcast_event(
|
||
EventType.COMMENT,
|
||
{"user": user, "message": message}
|
||
)
|
||
|
||
# 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 - survival simulation.
|
||
|
||
Every tick:
|
||
1. Process survival mechanics (energy/HP decay)
|
||
2. Broadcast agent states
|
||
"""
|
||
logger.info("Game loop started - Island survival simulation")
|
||
|
||
# Initial broadcast
|
||
await self._broadcast_agents_status()
|
||
|
||
while self._running:
|
||
self._tick_count += 1
|
||
|
||
# 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,
|
||
"day": day,
|
||
"alive_agents": alive_count
|
||
}
|
||
)
|
||
|
||
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."""
|
||
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 - The Island awaits...")
|
||
|
||
async def stop(self) -> None:
|
||
"""Stop the game engine."""
|
||
self._running = False
|
||
logger.info("Game engine stopping...")
|