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>
This commit is contained in:
empty
2025-12-30 17:15:35 +08:00
parent 4c08b00832
commit cf1739b7f8
9 changed files with 676 additions and 613 deletions

View File

@@ -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...")