Files
the-island/backend/app/engine.py
empty cf1739b7f8 feat: pivot to island survival simulation with SQLite persistence
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>
2025-12-30 17:15:35 +08:00

350 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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...")