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:
52
server-node/README.md
Normal file
52
server-node/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# AI Town Server (Node.js)
|
||||
|
||||
WebSocket 服务器,连接前端和 Python 引擎。
|
||||
|
||||
## 功能
|
||||
|
||||
- HTTP 静态托管 `../web` 目录
|
||||
- WebSocket 接收客户端消息并入队
|
||||
- 每 2 秒批量调用 Python 引擎并广播结果
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发模式(热重载)
|
||||
npm run dev
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## 依赖服务
|
||||
|
||||
启动前需先运行 Python 引擎:
|
||||
|
||||
```bash
|
||||
cd ../engine-python
|
||||
uvicorn app.main:app --port 8000
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health` - 健康检查,返回 `ok`
|
||||
- `WebSocket /` - 游戏消息通道
|
||||
|
||||
## WebSocket 协议
|
||||
|
||||
**客户端发送:**
|
||||
```json
|
||||
{ "type": "comment", "text": "你好", "user": "玩家A" }
|
||||
```
|
||||
|
||||
**服务端广播:**
|
||||
```json
|
||||
{
|
||||
"world_state": { "tick": 1, "weather": "sunny", ... },
|
||||
"actions": [{ "agent_id": "alice", "say": "...", "do": "..." }]
|
||||
}
|
||||
```
|
||||
1547
server-node/package-lock.json
generated
Normal file
1547
server-node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
server-node/package.json
Normal file
22
server-node/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "ai-town-server",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Town WebSocket server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
17
server-node/src/engine.ts
Normal file
17
server-node/src/engine.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { StepRequest, StepResponse } from './types';
|
||||
|
||||
const ENGINE_URL = 'http://localhost:8000/step';
|
||||
|
||||
export async function callEngine(request: StepRequest): Promise<StepResponse> {
|
||||
const response = await fetch(ENGINE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Engine returned ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<StepResponse>;
|
||||
}
|
||||
82
server-node/src/index.ts
Normal file
82
server-node/src/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { eventQueue } from './queue';
|
||||
import { callEngine } from './engine';
|
||||
import { ClientMessage, GameEvent } from './types';
|
||||
|
||||
const PORT = 3000;
|
||||
const TICK_INTERVAL = 2000;
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// 静态托管 ../web 目录
|
||||
const webDir = path.join(__dirname, '../../web');
|
||||
app.use(express.static(webDir));
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (_req, res) => {
|
||||
res.send('ok');
|
||||
});
|
||||
|
||||
// WebSocket 连接处理
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('[WS] Client connected');
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg: ClientMessage = JSON.parse(data.toString());
|
||||
if (msg.type === 'comment' && msg.text && msg.user) {
|
||||
const event: GameEvent = {
|
||||
type: msg.type,
|
||||
text: msg.text,
|
||||
user: msg.user,
|
||||
ts: Date.now(),
|
||||
};
|
||||
eventQueue.push(event);
|
||||
console.log(`[Queue] Added event, size: ${eventQueue.size()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] Invalid message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
// 广播消息给所有客户端
|
||||
function broadcast(data: object): void {
|
||||
const message = JSON.stringify(data);
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 定时调用 Python 引擎
|
||||
async function tick(): Promise<void> {
|
||||
const events = eventQueue.drain();
|
||||
console.log(`[Tick] Processing ${events.length} events`);
|
||||
|
||||
try {
|
||||
const response = await callEngine({ events });
|
||||
console.log(`[Tick] Step success, tick=${response.world_state.tick}`);
|
||||
broadcast(response);
|
||||
} catch (err) {
|
||||
console.error('[Tick] Engine call failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(tick, TICK_INTERVAL);
|
||||
|
||||
// 启动服务器
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[Server] HTTP + WebSocket running on http://localhost:${PORT}`);
|
||||
console.log(`[Server] Serving static files from ${webDir}`);
|
||||
});
|
||||
28
server-node/src/queue.ts
Normal file
28
server-node/src/queue.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { GameEvent } from './types';
|
||||
|
||||
const MAX_QUEUE_SIZE = 200;
|
||||
|
||||
class EventQueue {
|
||||
private queue: GameEvent[] = [];
|
||||
|
||||
push(event: GameEvent): void {
|
||||
this.queue.push(event);
|
||||
if (this.queue.length > MAX_QUEUE_SIZE) {
|
||||
const dropped = this.queue.length - MAX_QUEUE_SIZE;
|
||||
this.queue = this.queue.slice(dropped);
|
||||
console.log(`[Queue] Dropped ${dropped} oldest events`);
|
||||
}
|
||||
}
|
||||
|
||||
drain(): GameEvent[] {
|
||||
const events = this.queue;
|
||||
this.queue = [];
|
||||
return events;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const eventQueue = new EventQueue();
|
||||
41
server-node/src/types.ts
Normal file
41
server-node/src/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface GameEvent {
|
||||
type: string;
|
||||
text: string;
|
||||
user: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface StepRequest {
|
||||
events: GameEvent[];
|
||||
}
|
||||
|
||||
export interface AgentState {
|
||||
emotion: string;
|
||||
goal: string;
|
||||
memory: string[];
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
tick: number;
|
||||
weather: string;
|
||||
town_mood: number;
|
||||
agents: Record<string, AgentState>;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
agent_id: string;
|
||||
say: string;
|
||||
do: string;
|
||||
}
|
||||
|
||||
export interface StepResponse {
|
||||
world_state: WorldState;
|
||||
actions: Action[];
|
||||
}
|
||||
|
||||
export interface ClientMessage {
|
||||
type: string;
|
||||
text: string;
|
||||
user: string;
|
||||
}
|
||||
16
server-node/tsconfig.json
Normal file
16
server-node/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user