feat: AI Town MVP - 完整三层架构实现

- Python FastAPI 引擎:世界状态模拟、全局能量条、世界事件系统
- Node.js WebSocket 服务器:实时通信、事件队列批处理
- 前端仪表盘:世界状态可视化、行动日志、事件展示

🤖 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 10:29:35 +08:00
commit 8b90d55f02
20 changed files with 2729 additions and 0 deletions

View File

206
engine-python/app/engine.py Normal file
View File

@@ -0,0 +1,206 @@
from __future__ import annotations
from typing import List, Optional, Tuple
from .models import (
WorldState, AgentState, Event, Action, Emotion, Weather,
GlobalEventResult, GlobalEventInfo
)
from .global_events import GLOBAL_EVENT_POOL, GlobalEvent
MAX_EVENTS = 20
MAX_MEMORY_PER_AGENT = 3
COOLDOWN_TICKS = 5
# 事件类型对应的能量值
ENERGY_MAP = {
"comment": 1,
"support": 3,
"chaos": 5,
}
DEFAULT_ENERGY = 1
def update_world_effects(state: WorldState) -> None:
"""更新世界效果生命周期,应用持续影响"""
# 递减 remaining_ticks移除过期效果
active_effects = []
for effect in state.world_effects:
effect.remaining_ticks -= 1
if effect.remaining_ticks > 0:
active_effects.append(effect)
# 应用持续情绪影响
state.town_mood += effect.mood_modifier
state.town_mood = max(-10, min(10, state.town_mood))
state.world_effects = active_effects
def process_events(state: WorldState, events: List[Event]) -> WorldState:
"""处理事件并更新世界状态"""
# tick 递增
state.tick += 1
# cooldown 递减
if state.global_meter.cooldown > 0:
state.global_meter.cooldown -= 1
# 更新世界效果(持续影响)
update_world_effects(state)
for event in events:
text = event.text
# 累计能量
energy = ENERGY_MAP.get(event.type, DEFAULT_ENERGY)
state.global_meter.value += energy
# 天气规则
if "下雨" in text:
state.weather = Weather.RAINY
if "天晴" in text:
state.weather = Weather.SUNNY
# 情绪规则
if "支持" in text:
state.town_mood = min(10, state.town_mood + 1)
if "混乱" in text or "讨厌" in text:
state.town_mood = max(-10, state.town_mood - 2)
# 添加事件到全局事件列表
event_str = f"{event.user}: {text}"
state.events.append(event_str)
# 添加到每个 agent 的 memory
for agent in state.agents.values():
agent.memory.append(event_str)
# 保持最多3条
if len(agent.memory) > MAX_MEMORY_PER_AGENT:
agent.memory = agent.memory[-MAX_MEMORY_PER_AGENT:]
# 保持全局事件最多20条
if len(state.events) > MAX_EVENTS:
state.events = state.events[-MAX_EVENTS:]
# 更新所有 agent 的 emotion
update_emotions(state)
return state
def check_and_trigger_global_event(
state: WorldState
) -> Tuple[WorldState, GlobalEventResult]:
"""检查并触发世界级事件"""
meter = state.global_meter
result = GlobalEventResult(triggered=False, event=None)
# 检查触发条件
if meter.value >= meter.threshold and meter.cooldown == 0:
# 基于 tick 选择事件(确保可复现)
event_index = state.tick % len(GLOBAL_EVENT_POOL)
global_event = GLOBAL_EVENT_POOL[event_index]
# 应用即时效果
global_event.apply_effect(state)
# 生成持续影响效果
world_effect = global_event.create_world_effect()
state.world_effects.append(world_effect)
# 重置能量条,设置冷却
state.global_meter.value = 0
state.global_meter.cooldown = COOLDOWN_TICKS
# 更新 emotion
update_emotions(state)
result = GlobalEventResult(
triggered=True,
event=global_event.to_info()
)
return state, result
def update_emotions(state: WorldState) -> None:
"""根据 town_mood 更新所有 agent 的 emotion"""
if state.town_mood >= 4:
emotion = Emotion.HAPPY
elif state.town_mood <= -4:
emotion = Emotion.ANXIOUS
else:
emotion = Emotion.CALM
for agent in state.agents.values():
agent.emotion = emotion
def generate_actions(state: WorldState) -> List[Action]:
"""根据当前状态生成 agent 的行动"""
actions = []
for agent_id in state.agents:
agent = state.agents[agent_id]
action = generate_agent_action(agent_id, agent, state)
actions.append(action)
return actions
def generate_agent_action(
agent_id: str, agent: AgentState, state: WorldState
) -> Action:
"""为单个 agent 生成行动(基于规则模板)"""
weather = state.weather
emotion = agent.emotion
# Alice 的行为模板
if agent_id == "alice":
if weather == Weather.RAINY:
if emotion == Emotion.HAPPY:
say = "虽然下雨了,但心情还是很好呢!"
do = "在屋檐下哼着歌"
elif emotion == Emotion.ANXIOUS:
say = "这雨什么时候才能停啊..."
do = "焦虑地看着窗外"
else:
say = "下雨了,待在室内吧。"
do = "在室内看书"
else: # sunny
if emotion == Emotion.HAPPY:
say = "今天天气真好,心情也很棒!"
do = "在广场上散步"
elif emotion == Emotion.ANXIOUS:
say = "天气虽好,但总觉得不安..."
do = "在小镇边缘徘徊"
else:
say = "天气不错,出去走走吧。"
do = "在街道上闲逛"
# Bob 的行为模板
elif agent_id == "bob":
if weather == Weather.RAINY:
if emotion == Emotion.HAPPY:
say = "雨天也有雨天的乐趣!"
do = "在咖啡馆和人聊天"
elif emotion == Emotion.ANXIOUS:
say = "这种天气让人心烦..."
do = "独自待在角落"
else:
say = "下雨天适合思考。"
do = "在窗边沉思"
else: # sunny
if emotion == Emotion.HAPPY:
say = "阳光明媚,适合交朋友!"
do = "主动和路人打招呼"
elif emotion == Emotion.ANXIOUS:
say = "人太多了,有点紧张..."
do = "躲在树荫下观察"
else:
say = "今天适合出门。"
do = "在市场闲逛"
# 默认行为(其他 agent
else:
say = f"我是{agent_id},今天感觉{emotion.value}"
do = "四处走动"
return Action(agent_id=agent_id, say=say, do=do)

View File

@@ -0,0 +1,108 @@
"""世界级事件池"""
from typing import Callable, List, Dict, Any, Optional
from .models import WorldState, Weather, GlobalEventInfo, WorldEffect
class GlobalEvent:
"""世界级事件定义"""
def __init__(
self,
name: str,
description: str,
apply_effect: Callable[[WorldState], None],
effect_type: str,
effect_duration: int = 5,
mood_modifier: int = 0
):
self.name = name
self.description = description
self.apply_effect = apply_effect
self.effect_type = effect_type
self.effect_duration = effect_duration
self.mood_modifier = mood_modifier
def to_info(self) -> GlobalEventInfo:
return GlobalEventInfo(name=self.name, description=self.description)
def create_world_effect(self) -> WorldEffect:
return WorldEffect(
type=self.effect_type,
name=self.name,
intensity=abs(self.mood_modifier),
remaining_ticks=self.effect_duration,
mood_modifier=self.mood_modifier
)
# 事件效果函数
def effect_festival(state: WorldState) -> None:
"""节日效果:天气变晴,情绪+5"""
state.weather = Weather.SUNNY
state.town_mood = min(10, state.town_mood + 5)
def effect_storm(state: WorldState) -> None:
"""暴风雨效果:天气变雨,情绪-3"""
state.weather = Weather.RAINY
state.town_mood = max(-10, state.town_mood - 3)
def effect_scandal(state: WorldState) -> None:
"""丑闻效果:情绪-5"""
state.town_mood = max(-10, state.town_mood - 5)
def effect_miracle(state: WorldState) -> None:
"""奇迹效果:天气变晴,情绪+3"""
state.weather = Weather.SUNNY
state.town_mood = min(10, state.town_mood + 3)
def effect_fire(state: WorldState) -> None:
"""火灾效果:情绪-4"""
state.town_mood = max(-10, state.town_mood - 4)
# 世界级事件池
GLOBAL_EVENT_POOL: List[GlobalEvent] = [
GlobalEvent(
name="小镇节日",
description="居民们欢聚一堂,举办盛大庆典!",
apply_effect=effect_festival,
effect_type="festival",
effect_duration=5,
mood_modifier=2,
),
GlobalEvent(
name="暴风雨来袭",
description="乌云密布,暴风雨席卷小镇。",
apply_effect=effect_storm,
effect_type="storm",
effect_duration=4,
mood_modifier=-1,
),
GlobalEvent(
name="惊天丑闻",
description="镇长的秘密被曝光,全镇哗然。",
apply_effect=effect_scandal,
effect_type="scandal",
effect_duration=6,
mood_modifier=-2,
),
GlobalEvent(
name="彩虹奇迹",
description="雨后天空出现绚丽彩虹。",
apply_effect=effect_miracle,
effect_type="miracle",
effect_duration=3,
mood_modifier=1,
),
GlobalEvent(
name="火灾警报",
description="镇中心发生火情,消防队紧急出动。",
apply_effect=effect_fire,
effect_type="fire",
effect_duration=4,
mood_modifier=-2,
),
]

37
engine-python/app/main.py Normal file
View File

@@ -0,0 +1,37 @@
from fastapi import FastAPI
from .models import StepRequest, StepResponse
from .state import load_state, save_state
from .engine import process_events, generate_actions, check_and_trigger_global_event
app = FastAPI(title="AI Town Engine", version="0.1.0")
@app.post("/step", response_model=StepResponse)
def step(request: StepRequest) -> StepResponse:
"""执行一个模拟步骤"""
# 加载当前状态
state = load_state()
# 处理事件并更新状态
state = process_events(state, request.events)
# 检查并触发世界级事件
state, global_event_result = check_and_trigger_global_event(state)
# 生成 agent 行动
actions = generate_actions(state)
# 保存状态
save_state(state)
return StepResponse(
world_state=state,
actions=actions,
global_event=global_event_result
)
@app.get("/health")
def health():
"""健康检查"""
return {"status": "ok"}

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import List, Dict, Optional
from enum import Enum
class Emotion(str, Enum):
CALM = "calm"
HAPPY = "happy"
ANXIOUS = "anxious"
class Weather(str, Enum):
SUNNY = "sunny"
RAINY = "rainy"
class GlobalMeter(BaseModel):
"""全体能量条"""
value: int = 0
threshold: int = 100
cooldown: int = 0
class WorldEffect(BaseModel):
"""持续影响效果"""
type: str
name: str
intensity: int = 1
remaining_ticks: int = 5
mood_modifier: int = 0
class AgentState(BaseModel):
emotion: Emotion = Emotion.CALM
goal: str = ""
memory: List[str] = Field(default_factory=list)
class WorldState(BaseModel):
tick: int = 0
weather: Weather = Weather.SUNNY
town_mood: int = Field(default=0, ge=-10, le=10)
agents: Dict[str, AgentState] = Field(default_factory=dict)
events: List[str] = Field(default_factory=list)
global_meter: GlobalMeter = Field(default_factory=GlobalMeter)
world_effects: List[WorldEffect] = Field(default_factory=list)
class Event(BaseModel):
type: str
text: str
user: str
ts: float
class StepRequest(BaseModel):
events: List[Event] = Field(default_factory=list)
class Action(BaseModel):
agent_id: str
say: str
do: str
class GlobalEventInfo(BaseModel):
"""世界级事件信息"""
name: str
description: str
class GlobalEventResult(BaseModel):
"""世界级事件触发结果"""
triggered: bool = False
event: Optional[GlobalEventInfo] = None
class StepResponse(BaseModel):
world_state: WorldState
actions: List[Action]
global_event: GlobalEventResult = Field(default_factory=GlobalEventResult)

View File

@@ -0,0 +1,35 @@
import json
from pathlib import Path
from .models import WorldState, AgentState, GlobalMeter
STATE_FILE = Path(__file__).parent.parent / "state.json"
def get_default_state() -> WorldState:
"""创建默认的世界状态"""
return WorldState(
tick=0,
weather="sunny",
town_mood=0,
agents={
"alice": AgentState(emotion="calm", goal="探索小镇", memory=[]),
"bob": AgentState(emotion="calm", goal="与人交流", memory=[]),
},
events=[],
global_meter=GlobalMeter(value=0, threshold=100, cooldown=0),
)
def load_state() -> WorldState:
"""从文件加载状态,如果不存在则创建默认状态"""
if STATE_FILE.exists():
with open(STATE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return WorldState(**data)
return get_default_state()
def save_state(state: WorldState) -> None:
"""保存状态到文件"""
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state.model_dump(), f, ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,5 @@
fastapi>=0.104.0
uvicorn>=0.24.0
pydantic>=2.5.0
pytest>=7.4.0
httpx>=0.25.0

View File

View File

@@ -0,0 +1,173 @@
import pytest
from fastapi.testclient import TestClient
import os
import json
from pathlib import Path
from app.main import app
from app.state import STATE_FILE, get_default_state, save_state
@pytest.fixture(autouse=True)
def reset_state():
"""每个测试前重置状态文件"""
if STATE_FILE.exists():
STATE_FILE.unlink()
save_state(get_default_state())
yield
if STATE_FILE.exists():
STATE_FILE.unlink()
@pytest.fixture
def client():
return TestClient(app)
def test_step_tick_increments(client):
"""测试 tick 递增"""
# 第一次调用
resp1 = client.post("/step", json={"events": []})
assert resp1.status_code == 200
data1 = resp1.json()
assert data1["world_state"]["tick"] == 1
# 第二次调用
resp2 = client.post("/step", json={"events": []})
assert resp2.status_code == 200
data2 = resp2.json()
assert data2["world_state"]["tick"] == 2
def test_mood_bounded(client):
"""测试 town_mood 有界 (-10 到 10)"""
# 发送多个"支持"事件,测试上限
events = [
{"type": "comment", "text": "支持", "user": "test", "ts": i}
for i in range(15)
]
resp = client.post("/step", json={"events": events})
data = resp.json()
assert data["world_state"]["town_mood"] <= 10
# 重置状态
save_state(get_default_state())
# 发送多个"混乱"事件,测试下限
events = [
{"type": "comment", "text": "混乱", "user": "test", "ts": i}
for i in range(15)
]
resp = client.post("/step", json={"events": events})
data = resp.json()
assert data["world_state"]["town_mood"] >= -10
def test_memory_max_length(client):
"""测试 memory 长度不超过 3"""
events = [
{"type": "comment", "text": f"消息{i}", "user": "user1", "ts": i}
for i in range(10)
]
resp = client.post("/step", json={"events": events})
data = resp.json()
for agent_id, agent in data["world_state"]["agents"].items():
assert len(agent["memory"]) <= 3
def test_weather_change(client):
"""测试天气变化"""
# 下雨
resp = client.post("/step", json={
"events": [{"type": "comment", "text": "下雨了", "user": "test", "ts": 1}]
})
assert resp.json()["world_state"]["weather"] == "rainy"
# 天晴
resp = client.post("/step", json={
"events": [{"type": "comment", "text": "天晴了", "user": "test", "ts": 2}]
})
assert resp.json()["world_state"]["weather"] == "sunny"
def test_actions_generated(client):
"""测试 actions 生成"""
resp = client.post("/step", json={"events": []})
data = resp.json()
assert len(data["actions"]) == 2
agent_ids = [a["agent_id"] for a in data["actions"]]
assert "alice" in agent_ids
assert "bob" in agent_ids
for action in data["actions"]:
assert "say" in action
assert "do" in action
def test_global_meter_accumulates(client):
"""测试能量累计"""
# comment 类型 +1
events = [{"type": "comment", "text": "hello", "user": "test", "ts": 1}]
resp = client.post("/step", json={"events": events})
data = resp.json()
assert data["world_state"]["global_meter"]["value"] == 1
# support 类型 +3
events = [{"type": "support", "text": "支持", "user": "test", "ts": 2}]
resp = client.post("/step", json={"events": events})
data = resp.json()
assert data["world_state"]["global_meter"]["value"] == 4
# chaos 类型 +5
events = [{"type": "chaos", "text": "混乱", "user": "test", "ts": 3}]
resp = client.post("/step", json={"events": events})
data = resp.json()
assert data["world_state"]["global_meter"]["value"] == 9
def test_global_event_triggers(client):
"""测试世界级事件触发"""
# 发送足够多的 chaos 事件达到阈值 (100)
# chaos +5, 需要 20 个事件
events = [
{"type": "chaos", "text": f"chaos{i}", "user": "test", "ts": i}
for i in range(20)
]
resp = client.post("/step", json={"events": events})
data = resp.json()
# 验证事件触发
assert data["global_event"]["triggered"] is True
assert data["global_event"]["event"] is not None
assert "name" in data["global_event"]["event"]
assert "description" in data["global_event"]["event"]
# 验证能量重置和冷却设置
assert data["world_state"]["global_meter"]["value"] == 0
assert data["world_state"]["global_meter"]["cooldown"] == 5
def test_global_event_cooldown(client):
"""测试冷却期间不触发事件"""
# 先触发一次事件
events = [
{"type": "chaos", "text": f"chaos{i}", "user": "test", "ts": i}
for i in range(20)
]
resp = client.post("/step", json={"events": events})
assert resp.json()["global_event"]["triggered"] is True
# 冷却期间再次达到阈值,不应触发
events = [
{"type": "chaos", "text": f"chaos{i}", "user": "test", "ts": i}
for i in range(20)
]
resp = client.post("/step", json={"events": events})
data = resp.json()
# 不应触发(冷却中)
assert data["global_event"]["triggered"] is False
# 冷却递减
assert data["world_state"]["global_meter"]["cooldown"] == 4