├── shared ├── lib │ ├── src │ │ ├── index.ts │ │ ├── speechSystem.ts │ │ ├── roleAssignment.ts │ │ ├── operationLog.ts │ │ └── __tests__ │ │ │ ├── roleAssignment.bun.test.ts │ │ │ └── operationLog.bun.test.ts │ ├── tsconfig.json │ └── package.json └── types │ ├── tsconfig.json │ ├── package.json │ └── src │ ├── index.ts │ ├── prompts.ts │ ├── schemas.ts │ └── api.ts ├── packages ├── game-master-vite │ ├── src │ │ ├── lib │ │ │ ├── Client.ts │ │ │ ├── utils.ts │ │ │ ├── playerConfig.ts │ │ │ ├── PlayerAPIClient.ts │ │ │ ├── langfuse.ts │ │ │ └── Player.ts │ │ ├── stores │ │ │ └── gameStore.ts │ │ ├── main.tsx │ │ ├── App.tsx │ │ ├── components │ │ │ ├── GameConsole.tsx │ │ │ ├── ui │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ └── card.tsx │ │ │ ├── GameOperationLog.tsx │ │ │ ├── PlayerList.tsx │ │ │ ├── ChatDisplay.tsx │ │ │ ├── GameControls.tsx │ │ │ └── GameStatus.tsx │ │ ├── assets │ │ │ └── react.svg │ │ └── globals.css │ ├── .gitignore │ ├── vite.config.ts │ ├── index.html │ ├── components.json │ ├── eslint.config.js │ ├── tsconfig.json │ ├── public │ │ └── vite.svg │ └── package.json └── player │ ├── tsconfig.json │ ├── src │ ├── validation.ts │ ├── prompts │ │ ├── utils.ts │ │ ├── special │ │ │ └── index.ts │ │ ├── personality │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── night │ │ │ └── index.ts │ │ ├── speech │ │ │ └── index.ts │ │ └── voting │ │ │ └── index.ts │ ├── config │ │ └── PlayerConfig.ts │ ├── index.ts │ ├── PlayerServer.ts │ └── services │ │ └── langfuse.ts │ ├── configs │ ├── aggressive.json │ ├── witty.json │ ├── default.json │ └── conservative.json │ ├── package.json │ └── CONFIG.md ├── .env.example ├── config-example ├── player1.yaml └── game-config.yaml ├── .mcp.json ├── tsconfig.json ├── .gitignore ├── tests └── setup.ts ├── scripts ├── README.md └── dev-players.sh ├── package.json ├── docs ├── mobx-react-best-practices.md └── ai-sdk-zod-compatibility.md ├── README.md └── CLAUDE.md /shared/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './roleAssignment'; 2 | export * from './speechSystem'; 3 | export * from './operationLog'; -------------------------------------------------------------------------------- /packages/game-master-vite/src/lib/Client.ts: -------------------------------------------------------------------------------- 1 | import { type Player } from "./Player"; 2 | 3 | export interface Client { 4 | id: number; 5 | url: string; 6 | player?: Player; 7 | } 8 | -------------------------------------------------------------------------------- /shared/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "composite": true 7 | }, 8 | "include": ["src/**/*"] 9 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/stores/gameStore.ts: -------------------------------------------------------------------------------- 1 | import { GameMaster } from '@/lib/GameMaster'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | // 创建全局的 GameMaster 实例 5 | export const gameMaster = new GameMaster(uuidv4()); -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # AI Configuration 2 | # 使用OpenRouter (推荐,支持多种模型) 3 | OPENROUTER_API_KEY= 4 | 5 | LANGFUSE_SECRET_KEY= 6 | LANGFUSE_PUBLIC_KEY= 7 | LANGFUSE_BASEURL="https://us.cloud.langfuse.com" 8 | 9 | AI_MODEL=google/gemini-2.5-flashm -------------------------------------------------------------------------------- /shared/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "composite": true, 7 | "types": ["bun-types"] 8 | }, 9 | "include": ["src/**/*"] 10 | } -------------------------------------------------------------------------------- /packages/player/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "composite": true, 7 | "types": ["bun-types"] 8 | }, 9 | "include": ["src/**/*"] 10 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App.tsx' 4 | 5 | createRoot(document.getElementById('root')!).render( 6 | 7 | 8 | , 9 | ) 10 | -------------------------------------------------------------------------------- /config-example/player1.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 3001 3 | host: "0.0.0.0" 4 | 5 | ai: 6 | maxTokens: 5000 7 | temperature: 0.8 8 | provider: "openrouter" 9 | 10 | game: 11 | personality: "理性分析型玩家,善于逻辑推理,不轻易相信他人但也不会过度怀疑" 12 | strategy: "balanced" 13 | 14 | logging: 15 | enabled: true -------------------------------------------------------------------------------- /config-example/game-config.yaml: -------------------------------------------------------------------------------- 1 | # Game Master Configuration 2 | # This file defines the game settings and available AI players for the werewolf game 3 | 4 | players: 5 | - http://localhost:3001 6 | - http://localhost:3002 7 | - http://localhost:3003 8 | - http://localhost:3004 9 | - http://localhost:3005 10 | - http://localhost:3006 -------------------------------------------------------------------------------- /packages/player/src/validation.ts: -------------------------------------------------------------------------------- 1 | // Re-export validation schemas from shared types package 2 | export { 3 | SpeechResponseSchema, 4 | VotingResponseSchema, 5 | NightActionResponseSchema, 6 | LastWordsResponseSchema, 7 | WerewolfNightActionSchema, 8 | WitchNightActionSchema, 9 | SeerNightActionSchema, 10 | getNightActionSchemaByRole 11 | } from '@ai-werewolf/types'; -------------------------------------------------------------------------------- /packages/game-master-vite/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/game-master-vite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import path from 'path' 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | '@': path.resolve(__dirname, './src'), 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /shared/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ai-werewolf/types", 3 | "version": "1.0.0", 4 | "description": "Shared TypeScript types for AI Werewolf game", 5 | "main": "src/index.ts", 6 | "types": "src/index.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "zod": "*" 13 | }, 14 | "devDependencies": { 15 | "typescript": "^5.0.0" 16 | } 17 | } -------------------------------------------------------------------------------- /.mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "zen": { 4 | "command": "sh", 5 | "args": [ 6 | "-c", 7 | "exec $(which uvx || echo uvx) --from git+https://github.com/BeehiveInnovations/zen-mcp-server.git zen-mcp-server" 8 | ], 9 | "env": { 10 | "PATH": "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:~/.local/bin", 11 | "OPENAI_API_KEY": "your_api_key_here" 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /packages/game-master-vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /shared/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ai-werewolf/lib", 3 | "version": "1.0.0", 4 | "description": "Shared utility library for AI Werewolf game", 5 | "main": "src/index.ts", 6 | "types": "src/index.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "@ai-werewolf/types": "workspace:*" 13 | }, 14 | "devDependencies": { 15 | "typescript": "^5.0.0" 16 | } 17 | } -------------------------------------------------------------------------------- /shared/types/src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface NightTempState { 3 | werewolfTarget?: number; 4 | witchHealTarget?: number; 5 | witchPoisonTarget?: number; 6 | } 7 | 8 | export interface GameEvent { 9 | type: string; 10 | playerId?: number; 11 | content?: any; 12 | timestamp: Date; 13 | } 14 | 15 | export type PersonalityType = 'aggressive' | 'conservative' | 'cunning'; 16 | 17 | export * from './api'; 18 | export * from './schemas'; 19 | export * from './prompts'; -------------------------------------------------------------------------------- /packages/player/configs/aggressive.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 3002, 4 | "host": "0.0.0.0" 5 | }, 6 | "ai": { 7 | "model": "openai/gpt-4", 8 | "maxTokens": 200, 9 | "temperature": 0.9, 10 | "provider": "openrouter" 11 | }, 12 | "game": { 13 | "name": "狼王", 14 | "personality": "激进型玩家,敢于质疑和攻击,说话直接,逻辑犀利", 15 | "strategy": "aggressive", 16 | "speakingStyle": "formal" 17 | }, 18 | "logging": { 19 | "level": "debug", 20 | "enabled": true 21 | } 22 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | import { type PlayerInfo } from "@ai-werewolf/types" 4 | import { type Player } from "./Player" 5 | 6 | export function cn(...inputs: ClassValue[]) { 7 | return twMerge(clsx(inputs)) 8 | } 9 | 10 | export function playersToInfo(players: Player[]): PlayerInfo[] { 11 | return players.map(player => ({ 12 | id: player.id, 13 | isAlive: player.isAlive 14 | })) 15 | } 16 | -------------------------------------------------------------------------------- /packages/player/configs/witty.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 3004, 4 | "host": "0.0.0.0" 5 | }, 6 | "ai": { 7 | "model": "openai/gpt-3.5-turbo", 8 | "maxTokens": 180, 9 | "temperature": 1.0, 10 | "provider": "openrouter" 11 | }, 12 | "game": { 13 | "name": "幽默大师", 14 | "personality": "风趣幽默的玩家,善于用幽默化解紧张气氛,但关键时刻也很认真", 15 | "strategy": "balanced", 16 | "speakingStyle": "witty" 17 | }, 18 | "logging": { 19 | "level": "info", 20 | "enabled": true 21 | } 22 | } -------------------------------------------------------------------------------- /packages/player/configs/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 3001, 4 | "host": "0.0.0.0" 5 | }, 6 | "ai": { 7 | "model": "anthropic/claude-3-haiku", 8 | "maxTokens": 150, 9 | "temperature": 0.8, 10 | "provider": "openrouter" 11 | }, 12 | "game": { 13 | "name": "智能分析师", 14 | "personality": "理性分析型玩家,善于逻辑推理,不轻易相信他人但也不会过度怀疑", 15 | "strategy": "balanced", 16 | "speakingStyle": "casual" 17 | }, 18 | "logging": { 19 | "level": "info", 20 | "enabled": true 21 | } 22 | } -------------------------------------------------------------------------------- /packages/player/configs/conservative.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 3003, 4 | "host": "0.0.0.0" 5 | }, 6 | "ai": { 7 | "model": "anthropic/claude-3.5-sonnet", 8 | "maxTokens": 120, 9 | "temperature": 0.6, 10 | "provider": "openrouter" 11 | }, 12 | "game": { 13 | "name": "守护者", 14 | "personality": "保守稳重型玩家,不轻易发言,但每次发言都很有价值,善于观察", 15 | "strategy": "conservative", 16 | "speakingStyle": "formal" 17 | }, 18 | "logging": { 19 | "level": "warn", 20 | "enabled": true 21 | } 22 | } -------------------------------------------------------------------------------- /packages/game-master-vite/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "lib": ["ES2022"], 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "noEmit": true, 12 | "moduleResolution": "node", 13 | "allowSyntheticDefaultImports": true 14 | }, 15 | "files": [], 16 | "references": [ 17 | { "path": "./shared/types" }, 18 | { "path": "./shared/lib" }, 19 | { "path": "./packages/game-master-vite" }, 20 | { "path": "./packages/player" } 21 | ] 22 | } -------------------------------------------------------------------------------- /packages/player/src/prompts/utils.ts: -------------------------------------------------------------------------------- 1 | // 通用工具函数 2 | 3 | export function formatPlayerList(players: any[]): string { 4 | if (!players || !Array.isArray(players)) { 5 | return '暂无玩家信息'; 6 | } 7 | return players.filter(p => p.isAlive).map(p => p.id || p).join(', '); 8 | } 9 | 10 | export function formatSpeechHistory(history: any[]): string { 11 | if (!history || !Array.isArray(history)) { 12 | return '暂无发言记录'; 13 | } 14 | return history.map(h => `${h.playerId}: "${h.content}"`).join(','); 15 | } 16 | 17 | export function formatHistoryEvents(events: string[]): string { 18 | if (!events || !Array.isArray(events)) { 19 | return '暂无历史事件'; 20 | } 21 | return events.join(','); 22 | } -------------------------------------------------------------------------------- /packages/game-master-vite/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Environment variables 5 | .env 6 | .env.local 7 | .env.*.local 8 | 9 | # Build outputs 10 | dist/ 11 | build/ 12 | .cache/ 13 | *.tsbuildinfo 14 | tsconfig.tsbuildinfo 15 | 16 | # Logs 17 | logs/ 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | pnpm-debug.log* 23 | 24 | # Runtime generated configs 25 | /config 26 | 27 | # Lock files (项目使用Bun) 28 | package-lock.json 29 | pnpm-lock.yaml 30 | yarn.lock 31 | 32 | # IDE 33 | .vscode/ 34 | .idea/ 35 | *.swp 36 | *.swo 37 | 38 | # OS 39 | .DS_Store 40 | .DS_Store? 41 | ._* 42 | .Spotlight-V100 43 | .Trashes 44 | ehthumbs.db 45 | Thumbs.db 46 | 47 | # Personal notes 48 | todo.md 49 | 50 | coverage/ 51 | 52 | CLAUDE.local.md 53 | ai-temp -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | // 测试环境设置 2 | beforeEach(() => { 3 | // 重置控制台mock 4 | jest.clearAllMocks(); 5 | }); 6 | 7 | // 全局测试工具函数 8 | (global as any).createMockPlayer = (id: string, name: string, role: any) => ({ 9 | id, 10 | name, 11 | role, 12 | isAlive: true, 13 | teammates: role === 'werewolf' ? ['teammate1'] : undefined 14 | }); 15 | 16 | (global as any).createMockGameState = (players: any[] = []) => ({ 17 | gameId: 'test-game', 18 | players, 19 | currentPhase: 'night', 20 | dayCount: 1, 21 | winCondition: 'ongoing', 22 | votes: {} 23 | }); 24 | 25 | // 抑制控制台输出(在测试期间) 26 | (global as any).console = { 27 | ...console, 28 | log: jest.fn(), 29 | info: jest.fn(), 30 | warn: jest.fn(), 31 | error: jest.fn(), 32 | debug: jest.fn() 33 | }; -------------------------------------------------------------------------------- /packages/player/src/prompts/special/index.ts: -------------------------------------------------------------------------------- 1 | import type { LastWordsParams } from '@ai-werewolf/types'; 2 | import { formatPlayerList } from '../utils'; 3 | 4 | 5 | export function getLastWords(params: LastWordsParams): string { 6 | const playerList = formatPlayerList(params.alivePlayers); 7 | const killedByInfo = params.killedBy === 'werewolf' ? '狼人击杀' : 8 | params.killedBy === 'vote' ? '投票放逐' : '女巫毒杀'; 9 | const importantInfo = params.importantInfo || '暂无特殊信息'; 10 | 11 | return `你是${params.playerId}号玩家,狼人杀游戏中的${params.role}角色,你已被${killedByInfo}。当前游戏状态: 12 | - 存活玩家: [${playerList}] 13 | - 放逐原因: ${killedByInfo} 14 | - 重要信息: ${importantInfo} 15 | 16 | 作为被${killedByInfo}的${params.role},你需要发表遗言: 17 | 1. 可以留下对游戏的最后分析 18 | 2. 可以暗示自己的身份(如果是神职) 19 | 3. 可以指认你认为的狼人 20 | 4. 可以给好人阵营提供建议 21 | 22 | `; 23 | } 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /packages/game-master-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | "composite": true, 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | 26 | /* Path Mapping */ 27 | "baseUrl": ".", 28 | "paths": { 29 | "@/*": ["./src/*"] 30 | } 31 | }, 32 | "include": ["src", "vite.config.ts"], 33 | } 34 | -------------------------------------------------------------------------------- /shared/lib/src/speechSystem.ts: -------------------------------------------------------------------------------- 1 | import type { GameEvent, Speech, AllSpeeches } from '@ai-werewolf/types'; 2 | import { makeAutoObservable } from 'mobx'; 3 | 4 | export class SpeechSystem { 5 | private speeches: AllSpeeches = {}; 6 | 7 | constructor() { 8 | makeAutoObservable(this); 9 | } 10 | 11 | addSpeech(round: number, speech: Speech): void { 12 | if (!this.speeches[round]) { 13 | this.speeches[round] = []; 14 | } 15 | 16 | this.speeches[round].push(speech); 17 | } 18 | 19 | getSpeeches(round: number): Speech[] { 20 | return this.speeches[round] || []; 21 | } 22 | 23 | getAllSpeeches(): AllSpeeches { 24 | return this.speeches; 25 | } 26 | 27 | broadcastSpeech(round: number, speech: Speech): GameEvent { 28 | this.addSpeech(round, speech); 29 | 30 | return { 31 | type: 'speech', 32 | playerId: speech.playerId, 33 | content: speech, 34 | timestamp: new Date() 35 | }; 36 | } 37 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/lib/playerConfig.ts: -------------------------------------------------------------------------------- 1 | // Browser-compatible player configuration 2 | // Uses environment variables or defaults 3 | 4 | export function getPlayerUrls(): string[] { 5 | // In production, these could come from environment variables 6 | // For now, we'll use the default localhost URLs 7 | const defaultPlayers = [ 8 | 'http://localhost:3001', 9 | 'http://localhost:3002', 10 | 'http://localhost:3003', 11 | 'http://localhost:3004', 12 | 'http://localhost:3005', 13 | 'http://localhost:3006', 14 | 'http://localhost:3007', 15 | 'http://localhost:3008' 16 | ]; 17 | 18 | // Check if there's a custom configuration in environment variables 19 | // This would need to be set at build time for Vite 20 | const customPlayers = import.meta.env.VITE_PLAYER_URLS; 21 | 22 | if (customPlayers) { 23 | try { 24 | return JSON.parse(customPlayers); 25 | } catch (e) { 26 | console.warn('Failed to parse VITE_PLAYER_URLS, using defaults'); 27 | } 28 | } 29 | 30 | return defaultPlayers; 31 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/App.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import { GameConsole } from '@/components/GameConsole'; 3 | import './globals.css'; 4 | 5 | function App() { 6 | return ( 7 |
8 |
9 |
10 |
11 |
12 | 🎮 13 |
14 |
15 |

16 | Agent狼人杀竞技场 17 |

18 |

19 | Created By Box(@BoxMrChen) from Monad Foundation 20 |

21 |
22 |
23 |
24 |
25 | 26 |
27 | 28 |
29 |
30 | ); 31 | } 32 | 33 | export default App; -------------------------------------------------------------------------------- /packages/player/src/prompts/personality/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getAggressivePersonality(): string { 3 | return `## 性格特点 4 | 性格:激进、好斗、喜欢主导局面 5 | 行为特点:主动发起攻击,敢于冒险,善于制造混乱 6 | 弱点:容易暴露,缺乏耐心 7 | 角色扮演要求: 8 | 9 | - 所有发言和决策都应符合你的性格特点 10 | - 在游戏中保持角色一致性 11 | - 根据性格特点调整策略(如更主动发起攻击) 12 | - 在发言中体现性格特点(如直接、强势)`; 13 | } 14 | 15 | export function getConservativePersonality(): string { 16 | return `## 性格特点 17 | 性格:保守、谨慎、避免风险 18 | 行为特点:观察仔细,不轻易表态,喜欢隐藏自己 19 | 弱点:过于被动,可能错失机会 20 | 角色扮演要求: 21 | 22 | - 所有发言和决策都应符合你的性格特点 23 | - 在游戏中保持角色一致性 24 | - 根据性格特点调整策略(如更谨慎行动) 25 | - 在发言中体现性格特点(如谨慎、含蓄)`; 26 | } 27 | 28 | export function getCunningPersonality(): string { 29 | return `## 性格特点 30 | 性格:狡猾、善于伪装、精于算计 31 | 行为特点:隐藏真实意图,误导他人,长期布局 32 | 弱点:过于复杂可能导致逻辑漏洞 33 | 角色扮演要求: 34 | 35 | - 所有发言和决策都应符合你的性格特点 36 | - 在游戏中保持角色一致性 37 | - 根据性格特点调整策略(如更善于伪装) 38 | - 在发言中体现性格特点(如模棱两可、误导性)`; 39 | } 40 | 41 | // 工厂函数 42 | export function getPersonalityPrompt( 43 | personalityType: 'aggressive' | 'conservative' | 'cunning' 44 | ): string { 45 | switch (personalityType) { 46 | case 'aggressive': 47 | return getAggressivePersonality(); 48 | case 'conservative': 49 | return getConservativePersonality(); 50 | case 'cunning': 51 | return getCunningPersonality(); 52 | default: 53 | throw new Error(`Unknown personality type: ${personalityType}`); 54 | } 55 | } -------------------------------------------------------------------------------- /packages/game-master-vite/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/game-master-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game-master-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port 3000", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@ai-werewolf/lib": "workspace:*", 14 | "@ai-werewolf/types": "workspace:*", 15 | "@radix-ui/react-dropdown-menu": "^2.1.15", 16 | "axios": "^1.11.0", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "date-fns": "^4.1.0", 20 | "lucide-react": "^0.536.0", 21 | "mobx": "^6.13.7", 22 | "mobx-react-lite": "^4.1.0", 23 | "react": "^19.1.0", 24 | "react-dom": "^19.1.0", 25 | "react-router-dom": "^7.7.1", 26 | "tailwind-merge": "^3.3.1", 27 | "uuid": "^11.1.0" 28 | }, 29 | "devDependencies": { 30 | "@eslint/js": "^9.30.1", 31 | "@tailwindcss/vite": "^4.1.11", 32 | "@types/node": "^24.1.0", 33 | "@types/react": "^19.1.8", 34 | "@types/react-dom": "^19.1.6", 35 | "@types/uuid": "^10.0.0", 36 | "@vitejs/plugin-react": "^4.6.0", 37 | "eslint": "^9.30.1", 38 | "eslint-plugin-react-hooks": "^5.2.0", 39 | "eslint-plugin-react-refresh": "^0.4.20", 40 | "globals": "^16.3.0", 41 | "tailwindcss": "^4.1.11", 42 | "tw-animate-css": "^1.3.6", 43 | "typescript": "~5.8.3", 44 | "typescript-eslint": "^8.35.1", 45 | "vite": "^7.0.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/GameConsole.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { GameControls } from './GameControls'; 4 | import { ChatDisplay } from './ChatDisplay'; 5 | import { GameOperationLog } from './GameOperationLog'; 6 | import { PlayerList } from './PlayerList'; 7 | 8 | export const GameConsole = observer(function GameConsole() { 9 | const [error, setError] = useState(null); 10 | 11 | const clearError = () => setError(null); 12 | 13 | return ( 14 |
15 | {/* 游戏控制面板和玩家列表 - 横向布局 */} 16 |
17 | 18 | 19 |
20 | 21 | {/* 错误提示 */} 22 | {error && ( 23 |
24 | {error} 25 | 31 |
32 | )} 33 | 34 | {/* 主要游戏界面 */} 35 |
36 | {/* 玩家对话记录 - 占1/2宽度 */} 37 |
38 | 39 |
40 | 41 | {/* 游戏操作记录 - 占1/2宽度 */} 42 |
43 | 44 |
45 |
46 |
47 | ); 48 | }); -------------------------------------------------------------------------------- /packages/player/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ai-werewolf/player", 3 | "version": "1.0.0", 4 | "description": "AI Werewolf player server", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "node dist/index.js", 9 | "start:config": "node dist/index.js --config=", 10 | "dev": "bun --watch src/index.ts", 11 | "dev:config": "bun --watch src/index.ts --config=", 12 | "dev:default": "bun --watch src/index.ts --config=configs/default.json", 13 | "dev:aggressive": "bun --watch src/index.ts --config=configs/aggressive.json", 14 | "dev:conservative": "bun --watch src/index.ts --config=configs/conservative.json", 15 | "dev:witty": "bun --watch src/index.ts --config=configs/witty.json", 16 | "test": "bun test", 17 | "test:watch": "bun test --watch", 18 | "test:coverage": "bun test --coverage", 19 | "lint": "eslint src/**/*.ts", 20 | "typecheck": "tsc --noEmit" 21 | }, 22 | "dependencies": { 23 | "@ai-sdk/openai": "*", 24 | "@ai-sdk/openai-compatible": "^1.0.2", 25 | "@ai-werewolf/lib": "workspace:*", 26 | "@ai-werewolf/types": "workspace:*", 27 | "@openrouter/ai-sdk-provider": "^0.7.3", 28 | "@types/cors": "^2.8.19", 29 | "ai": "*", 30 | "axios": "^1.6.0", 31 | "cors": "^2.8.5", 32 | "dotenv": "^16.0.0", 33 | "express": "^4.18.2", 34 | "uuid": "^9.0.0", 35 | "zod": "*" 36 | }, 37 | "devDependencies": { 38 | "@types/express": "^4.17.21", 39 | "@types/node": "^20.0.0", 40 | "@types/supertest": "^6.0.3", 41 | "@types/uuid": "^9.0.0", 42 | "jest": "^29.0.0", 43 | "supertest": "^7.1.4", 44 | "ts-node": "^10.9.0", 45 | "typescript": "^5.0.0" 46 | } 47 | } -------------------------------------------------------------------------------- /shared/lib/src/roleAssignment.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '@ai-werewolf/types'; 2 | 3 | export interface RoleConfig { 4 | role: Role; 5 | count: number; 6 | } 7 | 8 | export class RoleAssignment { 9 | static getDefaultRoleConfig(playerCount: number): RoleConfig[] { 10 | const configs: RoleConfig[] = []; 11 | 12 | if (playerCount < 6) { 13 | throw new Error('Minimum 6 players required'); 14 | } 15 | 16 | // 六人局特殊配置:2狼人、1预言家、1女巫、2村民 17 | if (playerCount === 6) { 18 | configs.push({ role: Role.WEREWOLF, count: 2 }); 19 | configs.push({ role: Role.SEER, count: 1 }); 20 | configs.push({ role: Role.WITCH, count: 1 }); 21 | configs.push({ role: Role.VILLAGER, count: 2 }); 22 | return configs; 23 | } 24 | 25 | // 八人局特殊配置:2狼人、1预言家、1女巫、4村民 26 | if (playerCount === 8) { 27 | configs.push({ role: Role.WEREWOLF, count: 2 }); 28 | configs.push({ role: Role.SEER, count: 1 }); 29 | configs.push({ role: Role.WITCH, count: 1 }); 30 | configs.push({ role: Role.VILLAGER, count: 4 }); 31 | return configs; 32 | } 33 | 34 | // 其他人数的标准配置 35 | const werewolfCount = Math.floor(playerCount / 3); 36 | configs.push({ role: Role.WEREWOLF, count: werewolfCount }); 37 | 38 | configs.push({ role: Role.SEER, count: 1 }); 39 | 40 | if (playerCount >= 8) { 41 | configs.push({ role: Role.WITCH, count: 1 }); 42 | } 43 | 44 | const specialRolesCount = configs.reduce((sum, config) => 45 | config.role !== Role.WEREWOLF ? sum + config.count : sum, 0 46 | ); 47 | const villagerCount = playerCount - werewolfCount - specialRolesCount; 48 | 49 | configs.push({ role: Role.VILLAGER, count: villagerCount }); 50 | 51 | return configs; 52 | } 53 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /packages/player/src/prompts/index.ts: -------------------------------------------------------------------------------- 1 | import { type GameContext } from '@ai-werewolf/types'; 2 | import type { PlayerServer } from '../PlayerServer'; 3 | 4 | // Personality functions 5 | export * from './personality'; 6 | 7 | // Speech generation functions 8 | export * from './speech'; 9 | 10 | // Voting decision functions 11 | export * from './voting'; 12 | 13 | // Night action functions 14 | export * from './night'; 15 | 16 | // Special scenario functions 17 | export * from './special'; 18 | 19 | // Main prompt factory class 20 | export class WerewolfPrompts { 21 | // Personality prompts 22 | static getPersonality( 23 | personalityType: 'aggressive' | 'conservative' | 'cunning' 24 | ): string { 25 | const { getPersonalityPrompt } = require('./personality'); 26 | return getPersonalityPrompt(personalityType); 27 | } 28 | 29 | // Speech prompts 30 | static getSpeech( 31 | playerServer: PlayerServer, 32 | context: GameContext 33 | ): string { 34 | const { getRoleSpeech } = require('./speech'); 35 | return getRoleSpeech(playerServer, context); 36 | } 37 | 38 | // Voting prompts 39 | static getVoting( 40 | playerServer: PlayerServer, 41 | context: GameContext 42 | ): string { 43 | const { getRoleVoting } = require('./voting'); 44 | return getRoleVoting(playerServer, context); 45 | } 46 | 47 | // Night action prompts 48 | static getNightAction( 49 | playerServer: PlayerServer, 50 | context: GameContext 51 | ): string { 52 | const { getRoleNightAction } = require('./night'); 53 | return getRoleNightAction(playerServer, context); 54 | } 55 | 56 | // Special scenario prompts 57 | // TODO: 遗言功能暂时注释,待后续实现 58 | // static getLastWords( 59 | // playerServer: PlayerServer, 60 | // context: PlayerContext 61 | // ): string { 62 | // const { getLastWords } = require('./special'); 63 | // return getLastWords(playerServer, context); 64 | // } 65 | } 66 | 67 | // Default export 68 | export default WerewolfPrompts; -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /shared/types/src/prompts.ts: -------------------------------------------------------------------------------- 1 | import { Role, type AllVotes, type Round, type Speech, type PlayerInfo } from "./api"; 2 | 3 | // Speech history type - using Speech from types 4 | export type SpeechHistory = Speech; 5 | 6 | // 投票信息 7 | export interface VoteInfo { 8 | voter: string; 9 | target: string; 10 | reason?: string; 11 | } 12 | 13 | // 人格特征参数 14 | export interface PersonalityParams { 15 | role: Role; 16 | playerId: string; 17 | playerName: string; 18 | } 19 | 20 | // 发言生成参数 21 | export interface SpeechParams { 22 | playerId: string; 23 | playerName: string; 24 | role: string; // 改为string,因为传入的是中文角色名 25 | speechHistory: SpeechHistory[]; 26 | customContent?: string; 27 | teammates?: string[]; // 狼人队友 28 | suspiciousPlayers?: string[]; 29 | logicalContradictions?: string; 30 | } 31 | 32 | // 投票决策参数 33 | export interface VotingParams { 34 | playerId: string; 35 | role: Role; 36 | alivePlayers: PlayerInfo[]; 37 | speechSummary: SpeechHistory[]; 38 | currentVotes: VoteInfo[]; 39 | allVotes?: AllVotes; // 完整投票历史,供AI分析投票模式 40 | currentRound?: Round; 41 | teammates?: string[]; // 狼人队友 42 | } 43 | 44 | // 夜间决策参数 45 | export interface NightActionParams { 46 | playerId: number; 47 | role: Role; 48 | alivePlayers: PlayerInfo[]; 49 | currentRound: number; 50 | historyEvents: string[]; 51 | teammates?: number[]; // 队友信息 52 | customContent?: string; // 自定义内容 53 | checkedPlayers?: { [key: string]: 'good' | 'werewolf' }; // 预言家查验结果 54 | potionUsed?: { heal: boolean; poison: boolean }; // 女巫药水使用情况 55 | guardHistory?: string[]; // 守卫历史 56 | } 57 | 58 | 59 | // 遗言生成参数 60 | export interface LastWordsParams { 61 | playerId: string; 62 | playerName: string; 63 | role: Role; 64 | killedBy: 'werewolf' | 'vote' | 'poison'; 65 | alivePlayers: PlayerInfo[]; 66 | importantInfo?: string; 67 | } 68 | 69 | // 狼人团队协商参数 70 | export interface WerewolfTeamParams { 71 | playerId: string; 72 | teammates: PlayerInfo[]; 73 | alivePlayers: PlayerInfo[]; 74 | targetCandidates: string[]; 75 | gameAnalysis: string; 76 | } 77 | 78 | 79 | // Response interfaces for internal use 80 | export interface NightActionResponse { 81 | action: string; 82 | target?: string; 83 | reason: string; 84 | } 85 | 86 | export interface VotingResponse { 87 | target: string; 88 | reason: string; 89 | } 90 | 91 | export interface RoleSettingResponse { 92 | name: string; 93 | personality: string; 94 | playstyle: string; 95 | catchphrase: string; 96 | } -------------------------------------------------------------------------------- /shared/types/src/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // Speech Response Schema - 对应发言生成的返回格式 4 | export const SpeechResponseSchema = z.object({ 5 | speech: z.string().describe('生成的发言内容') 6 | }); 7 | 8 | // Voting Response Schema - 对应投票决策的返回格式 9 | export const VotingResponseSchema = z.object({ 10 | target: z.number().describe('要投票的玩家ID'), 11 | reason: z.string().describe('投票的理由') 12 | }); 13 | 14 | // 狼人夜间行动Schema - 匹配API的WerewolfAbilityResponse 15 | export const WerewolfNightActionSchema = z.object({ 16 | action: z.literal('kill').describe('行动类型,狼人固定为kill'), 17 | target: z.number().describe('要击杀的目标玩家ID'), 18 | reason: z.string().describe('选择该目标的详细理由,包括对其身份的推测'), 19 | }); 20 | 21 | // 预言家夜间行动Schema - 匹配API的SeerAbilityResponse 22 | export const SeerNightActionSchema = z.object({ 23 | action: z.literal('investigate').describe('行动类型,预言家固定为investigate'), 24 | target: z.number().describe('要查验身份的目标玩家ID'), 25 | reason: z.string().describe('选择查验该玩家的理由,基于其发言和行为的分析'), 26 | }); 27 | 28 | // 女巫夜间行动Schema - 匹配API的WitchAbilityResponse 29 | export const WitchNightActionSchema = z.object({ 30 | action: z.enum(['using', 'idle']).describe('行动类型:using表示使用药水,idle表示不使用'), 31 | healTarget: z.number().describe('救人的目标玩家ID,0表示不救人'), 32 | healReason: z.string().describe('救人或不救人的理由'), 33 | poisonTarget: z.number().describe('毒人的目标玩家ID,0表示不毒人'), 34 | poisonReason: z.string().describe('毒人或不毒人的理由'), 35 | }); 36 | 37 | 38 | // 通用夜间行动Schema (向后兼容) 39 | export const NightActionResponseSchema = z.union([ 40 | WerewolfNightActionSchema, 41 | SeerNightActionSchema, 42 | WitchNightActionSchema 43 | ]); 44 | 45 | // 根据角色获取对应的Schema,村民返回null表示跳过 46 | export function getNightActionSchemaByRole(role: string): typeof WerewolfNightActionSchema | typeof SeerNightActionSchema | typeof WitchNightActionSchema | null { 47 | switch (role) { 48 | case '狼人': 49 | return WerewolfNightActionSchema; 50 | case '预言家': 51 | return SeerNightActionSchema; 52 | case '女巫': 53 | return WitchNightActionSchema; 54 | case '村民': 55 | return null; // 村民夜间无行动,直接跳过 56 | default: 57 | throw new Error(`Unknown role: ${role}`); 58 | } 59 | } 60 | 61 | // Last Words Response Schema - 对应遗言生成的返回格式 62 | export const LastWordsResponseSchema = z.object({ 63 | content: z.string(), 64 | reveal_role: z.boolean().optional(), 65 | accusation: z.string().optional(), 66 | advice: z.string().optional() 67 | }); 68 | 69 | 70 | // 类型导出 71 | export type SpeechResponseType = z.infer; 72 | export type VotingResponseType = z.infer; 73 | export type NightActionResponseType = z.infer; 74 | export type LastWordsResponseType = z.infer; -------------------------------------------------------------------------------- /shared/lib/src/operationLog.ts: -------------------------------------------------------------------------------- 1 | export interface OperationLog { 2 | id: string; 3 | sequence: number; // 添加序列号确保正确排序 4 | timestamp: Date; 5 | type: 'phase_change' | 'player_request' | 'player_response' | 'system_action' | 'result'; 6 | message: string; 7 | details?: { 8 | playerId?: number; 9 | phase?: string; 10 | actionType?: string; 11 | target?: string; 12 | result?: string; 13 | }; 14 | } 15 | 16 | import { makeAutoObservable } from 'mobx'; 17 | 18 | export class OperationLogSystem { 19 | private logs: OperationLog[] = []; 20 | private sequenceCounter: number = 0; 21 | 22 | constructor() { 23 | makeAutoObservable(this); 24 | } 25 | 26 | addLog(log: Omit): void { 27 | const operationLog: OperationLog = { 28 | ...log, 29 | id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, 30 | sequence: this.sequenceCounter++, 31 | timestamp: new Date() 32 | }; 33 | 34 | this.logs.push(operationLog); 35 | console.log('🔍 addLog called:', log.message, 'total logs:', this.logs.length); 36 | } 37 | 38 | getLogs(): OperationLog[] { 39 | return [...this.logs]; 40 | } 41 | 42 | getRecentLogs(count: number): OperationLog[] { 43 | return this.logs.slice(-count); 44 | } 45 | 46 | clearLogs(): void { 47 | this.logs = []; 48 | this.sequenceCounter = 0; 49 | } 50 | 51 | // 便捷方法 52 | logPhaseChange(phase: string, dayCount: number): void { 53 | this.addLog({ 54 | type: 'phase_change', 55 | message: `🔄 游戏进入${phase}阶段(第${dayCount}天)`, 56 | details: { phase } 57 | }); 58 | } 59 | 60 | logPlayerRequest(playerId: number, actionType: string): void { 61 | this.addLog({ 62 | type: 'player_request', 63 | message: `📤 询问玩家${playerId} ${actionType}`, 64 | details: { playerId, actionType } 65 | }); 66 | } 67 | 68 | logPlayerResponse(playerId: number, actionType: string, result?: string): void { 69 | this.addLog({ 70 | type: 'player_response', 71 | message: `📥 玩家${playerId} ${actionType}完成${result ? ': ' + result : ''}`, 72 | details: { playerId, actionType, result } 73 | }); 74 | } 75 | 76 | logSystemAction(message: string, details?: any): void { 77 | this.addLog({ 78 | type: 'system_action', 79 | message: `⚙️ ${message}`, 80 | details 81 | }); 82 | } 83 | 84 | logResult(message: string, details?: any): void { 85 | this.addLog({ 86 | type: 'result', 87 | message: `📊 ${message}`, 88 | details 89 | }); 90 | } 91 | 92 | logPhaseComplete(phase: string, message?: string): void { 93 | const defaultMessage = `✅ ${phase}阶段完成,可以进入下一阶段`; 94 | this.addLog({ 95 | type: 'system_action', 96 | message: message || defaultMessage, 97 | details: { phase } 98 | }); 99 | } 100 | } -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # AI狼人杀启动脚本 2 | 3 | ## 📁 文件说明 4 | 5 | - `start-players.sh` - Linux/Mac生产模式启动脚本 6 | - `start-players.bat` - Windows生产模式启动脚本 7 | - `dev-players.sh` - Linux/Mac开发模式启动脚本 8 | 9 | ## 🚀 使用方法 10 | 11 | ### 方法1: 使用npm脚本 (推荐) 12 | 13 | ```bash 14 | # 开发模式启动所有6个AI玩家 15 | pnpm dev:all-players 16 | 17 | # 生产模式启动所有6个AI玩家 (需先构建) 18 | pnpm build 19 | pnpm start:all-players 20 | 21 | # 同时启动游戏主进程和所有AI玩家 22 | pnpm dev:game 23 | ``` 24 | 25 | ### 方法2: 直接运行脚本 26 | 27 | ```bash 28 | # Linux/Mac - 开发模式 29 | ./scripts/dev-players.sh 30 | 31 | # Linux/Mac - 生产模式 32 | ./scripts/start-players.sh 33 | 34 | # Windows - 生产模式 35 | scripts\start-players.bat 36 | ``` 37 | 38 | ## 🎮 AI玩家配置 39 | 40 | 每个AI玩家都有独特的个性和配置: 41 | 42 | | 端口 | 玩家名称 | 个性特点 | 策略 | 说话风格 | AI模型 | 43 | |------|----------|----------|------|----------|--------| 44 | | 3001 | 智能分析师 | 理性分析型,善于逻辑推理 | balanced | casual | claude-3-haiku | 45 | | 3002 | 狼王 | 激进型,敢于质疑攻击 | aggressive | formal | gpt-4 | 46 | | 3003 | 守护者 | 保守稳重,观察思考 | conservative | formal | claude-3.5-sonnet | 47 | | 3004 | 幽默大师 | 风趣幽默,善于化解紧张 | balanced | witty | gpt-3.5-turbo | 48 | | 3005 | 侦探 | 逻辑推理强,专注事实分析 | balanced | formal | claude-3-haiku | 49 | | 3006 | 新手村民 | 新手型,容易被误导 | conservative | casual | gpt-3.5-turbo | 50 | 51 | ## 📋 状态监控 52 | 53 | 启动后可以通过以下地址查看各AI玩家状态: 54 | 55 | - 智能分析师: http://localhost:3001/api/player/status 56 | - 狼王: http://localhost:3002/api/player/status 57 | - 守护者: http://localhost:3003/api/player/status 58 | - 幽默大师: http://localhost:3004/api/player/status 59 | - 侦探: http://localhost:3005/api/player/status 60 | - 新手村民: http://localhost:3006/api/player/status 61 | 62 | ## 📝 日志文件 63 | 64 | 所有日志文件保存在 `logs/` 目录下: 65 | 66 | - `player1.log` - 智能分析师日志 67 | - `player2.log` - 狼王日志 68 | - `player3.log` - 守护者日志 69 | - `player4.log` - 幽默大师日志 70 | - `player5.log` - 侦探日志 71 | - `player6.log` - 新手村民日志 72 | 73 | 开发模式日志文件后缀为 `-dev.log` 74 | 75 | ## 🛑 停止AI玩家 76 | 77 | ### Linux/Mac 78 | 按 `Ctrl+C` 停止脚本,会自动清理所有启动的进程 79 | 80 | ### Windows 81 | 关闭命令行窗口,或手动关闭各个AI玩家的cmd窗口 82 | 83 | ## ⚙️ 配置文件 84 | 85 | 所有配置文件位于 `config/` 目录: 86 | 87 | - `player1.json` - 智能分析师配置 88 | - `player2.json` - 狼王配置 89 | - `player3.json` - 守护者配置 90 | - `player4.json` - 幽默大师配置 91 | - `player5.json` - 侦探配置 92 | - `player6.json` - 新手村民配置 93 | 94 | 你可以修改这些配置文件来调整AI玩家的行为特点。 95 | 96 | ## 🔧 故障排除 97 | 98 | ### 端口被占用 99 | 如果某个端口被占用,修改对应的配置文件中的端口号。 100 | 101 | ### AI API失败 102 | - 检查环境变量中的API密钥设置 103 | - AI服务会自动降级到预设回复,不影响游戏进行 104 | 105 | ### 进程启动失败 106 | - 查看对应的日志文件获取详细错误信息 107 | - 确保已正确安装依赖:`pnpm install` 108 | - 生产模式需要先构建:`pnpm build` 109 | 110 | ## 🎯 测试示例 111 | 112 | 启动后可以测试AI玩家的发言功能: 113 | 114 | ```bash 115 | # 测试智能分析师发言 116 | curl -X POST http://localhost:3001/api/player/speak \ 117 | -H "Content-Type: application/json" \ 118 | -d '{ 119 | "otherSpeeches": ["player2: 我觉得player3很可疑"], 120 | "allSpeeches": ["player1: 大家好", "player2: 我觉得player3很可疑"] 121 | }' 122 | ``` 123 | 124 | 每个AI玩家会根据自己的个性特点生成不同风格的回应。 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-werewolf-monorepo", 3 | "version": "1.0.0", 4 | "description": "AI-powered Werewolf game framework monorepo", 5 | "private": true, 6 | "workspaces": [ 7 | "packages/*", 8 | "shared/*" 9 | ], 10 | "overrides": { 11 | "ai": "5.0.2", 12 | "@ai-sdk/openai": "2.0.2", 13 | "zod": "4.0.14" 14 | }, 15 | "scripts": { 16 | "build": "bun run --filter '*' build", 17 | "dev:game-master": "bun run --filter game-master-vite dev", 18 | "dev:players": "bash ./scripts/dev-players.sh", 19 | "dev:player": "bun run --filter @ai-werewolf/player dev", 20 | "dev:player:config": "bun run --filter @ai-werewolf/player dev:config", 21 | "dev:player:default": "bun run --filter @ai-werewolf/player dev:default", 22 | "dev:player:aggressive": "bun run --filter @ai-werewolf/player dev:aggressive", 23 | "dev:player:conservative": "bun run --filter @ai-werewolf/player dev:conservative", 24 | "dev:player:witty": "bun run --filter @ai-werewolf/player dev:witty", 25 | "start:game-master": "bun run --filter @ai-werewolf/game-master start", 26 | "start:player": "bun run --filter @ai-werewolf/player start", 27 | "start:player:config": "bun run --filter @ai-werewolf/player start:config", 28 | "test": "jest", 29 | "test:watch": "jest --watch", 30 | "test:coverage": "jest --coverage", 31 | "test:packages": "bun run --filter '*' test", 32 | "lint": "bun run --filter '*' lint", 33 | "typecheck": "bunx tsc --build", 34 | "typecheck:watch": "bunx tsc --build --watch", 35 | "typecheck:frontend": "bunx tsc --project packages/game-master-vite/tsconfig.json --noEmit", 36 | "typecheck:backend": "bunx tsc --project packages/player/tsconfig.json --noEmit" 37 | }, 38 | "devDependencies": { 39 | "@tailwindcss/postcss": "^4.1.11", 40 | "@types/cors": "^2.8.19", 41 | "@types/express": "^5.0.3", 42 | "@types/jest": "^30.0.0", 43 | "@typescript-eslint/eslint-plugin": "^6.0.0", 44 | "@typescript-eslint/parser": "^6.0.0", 45 | "bun-types": "^1.2.19", 46 | "cors": "^2.8.5", 47 | "eslint": "^8.0.0", 48 | "express": "^5.1.0", 49 | "jest": "^29.0.0", 50 | "ts-jest": "^29.0.0", 51 | "typescript": "^5.0.0" 52 | }, 53 | "keywords": [ 54 | "werewolf", 55 | "ai", 56 | "game", 57 | "framework", 58 | "monorepo" 59 | ], 60 | "author": "", 61 | "license": "MIT", 62 | "dependencies": { 63 | "@ai-sdk/openai": "^2.0.2", 64 | "@opentelemetry/api-logs": "^0.203.0", 65 | "@opentelemetry/auto-instrumentations-node": "^0.62.0", 66 | "@opentelemetry/instrumentation": "^0.203.0", 67 | "@opentelemetry/sdk-logs": "^0.203.0", 68 | "@opentelemetry/sdk-node": "^0.203.0", 69 | "@types/figlet": "^1.7.0", 70 | "@types/js-yaml": "^4.0.9", 71 | "boxen": "^8.0.1", 72 | "chalk": "^5.4.1", 73 | "figlet": "^1.8.2", 74 | "inquirer": "^12.9.0", 75 | "js-yaml": "^4.1.0", 76 | "langfuse": "^3.38.4", 77 | "langfuse-vercel": "^3.38.4", 78 | "openai": "^5.12.0", 79 | "ora": "^8.2.0", 80 | "p-retry": "^6.2.1" 81 | } 82 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/lib/PlayerAPIClient.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PlayerContext, 3 | StartGameParams, 4 | SpeechResponseType, 5 | VotingResponseType, 6 | NightActionResponseType 7 | } from '@ai-werewolf/types'; 8 | import pRetry, { AbortError } from 'p-retry'; 9 | 10 | export class PlayerAPIClient { 11 | private url: string; 12 | private playerId: number; 13 | 14 | // 不该重试的状态码 15 | private static readonly NON_RETRYABLE_STATUS = [400, 401, 403, 404, 422]; 16 | 17 | constructor(playerId: number, url: string) { 18 | this.playerId = playerId; 19 | this.url = url; 20 | } 21 | 22 | // 函数重载 - 精确的类型映射 23 | private async call(endpoint: 'start-game', params: StartGameParams): Promise; 24 | private async call(endpoint: 'speak', params: PlayerContext): Promise; 25 | private async call(endpoint: 'vote', params: PlayerContext): Promise; 26 | private async call(endpoint: 'use-ability', params: PlayerContext): Promise; 27 | private async call( 28 | endpoint: 'use-ability' | 'speak' | 'vote' | 'start-game', 29 | params: PlayerContext | StartGameParams 30 | ): Promise { 31 | return pRetry( 32 | async () => { 33 | const response = await fetch(`${this.url}/api/player/${endpoint}`, { 34 | method: 'POST', 35 | headers: { 'Content-Type': 'application/json' }, 36 | body: JSON.stringify(params), 37 | signal: AbortSignal.timeout(45000) // AI需要更长时间 38 | }); 39 | 40 | if (response.ok) { 41 | // start-game 没有响应体 42 | if (endpoint === 'start-game') { 43 | return; 44 | } 45 | return await response.json(); 46 | } 47 | 48 | const errorText = await response.text(); 49 | const error = new Error(`HTTP ${response.status}: ${errorText}`); 50 | 51 | // 精确的错误分类 52 | if (PlayerAPIClient.NON_RETRYABLE_STATUS.includes(response.status)) { 53 | throw new AbortError(error.message); 54 | } 55 | 56 | // 5xx、429、408等值得重试 57 | throw error; 58 | }, 59 | { 60 | retries: endpoint === 'start-game' ? 1 : 3, // 初始化快速失败 61 | minTimeout: 1000, 62 | maxTimeout: 10000, 63 | factor: 2, 64 | onFailedAttempt: error => { 65 | console.warn(`⚠️ Player ${this.playerId} [${endpoint}] retry ${error.attemptNumber}/${error.retriesLeft + error.attemptNumber}: ${error.message}`); 66 | } 67 | } 68 | ); 69 | } 70 | 71 | async useAbility(params: PlayerContext): Promise { 72 | return this.call('use-ability', params); 73 | } 74 | 75 | async speak(params: PlayerContext): Promise { 76 | return this.call('speak', params); 77 | } 78 | 79 | async vote(params: PlayerContext): Promise { 80 | return this.call('vote', params); 81 | } 82 | 83 | async startGame(params: StartGameParams): Promise { 84 | return this.call('start-game', params); 85 | } 86 | } -------------------------------------------------------------------------------- /shared/types/src/api.ts: -------------------------------------------------------------------------------- 1 | // 基础类型定义 2 | export enum Role { 3 | VILLAGER = 'villager', 4 | WEREWOLF = 'werewolf', 5 | SEER = 'seer', 6 | WITCH = 'witch', 7 | } 8 | 9 | export enum GamePhase { 10 | PREPARING = 'preparing', 11 | NIGHT = 'night', 12 | DAY = 'day', 13 | VOTING = 'voting', 14 | ENDED = 'ended' 15 | } 16 | 17 | export enum WinCondition { 18 | ONGOING = 'ongoing', 19 | WEREWOLVES_WIN = 'werewolves_win', 20 | VILLAGERS_WIN = 'villagers_win' 21 | } 22 | 23 | export interface PlayerInfo { 24 | id: number; 25 | isAlive: boolean; 26 | } 27 | 28 | export type Round = number; 29 | export type PlayerId = number; 30 | 31 | 32 | 33 | export interface Speech { 34 | playerId: number; 35 | content: string; 36 | type?: 'player' | 'system'; 37 | } 38 | 39 | // 所有发言记录的类型定义 40 | export type AllSpeeches = Record; 41 | 42 | // 投票记录 43 | export interface Vote { 44 | voterId: number; 45 | targetId: number; 46 | } 47 | 48 | // 所有投票记录的类型定义 49 | export type AllVotes = Record; 50 | 51 | 52 | 53 | // 基础能力请求接口 54 | export interface BaseAbilityRequest { 55 | reason: string; 56 | alivePlayers: Array; 57 | currentRound: number; 58 | } 59 | 60 | export type InvestigatedPlayers = Record 64 | 65 | // 女巫能力请求 66 | export interface WitchAbilityRequest extends BaseAbilityRequest { 67 | killedTonight?: number; 68 | potionUsed: { heal: boolean; poison: boolean }; 69 | } 70 | 71 | // 预言家能力请求 72 | export interface SeerAbilityRequest extends BaseAbilityRequest {} 73 | 74 | // 狼人能力请求 75 | export interface WerewolfAbilityRequest extends BaseAbilityRequest { 76 | } 77 | 78 | 79 | // 女巫能力响应 80 | export interface WitchAbilityResponse { 81 | action: 'using'|"idle" 82 | healTarget:number // 0 表示 不行动 83 | healReason:string 84 | poisonTarget:number // 0 表示 不行动 85 | poisonReason:string 86 | } 87 | 88 | // 预言家能力响应 89 | export interface SeerAbilityResponse { 90 | action: 'investigate'; 91 | target: number; 92 | reason: string; 93 | } 94 | 95 | // 狼人能力响应 96 | export interface WerewolfAbilityResponse { 97 | action: 'kill'|'idle'; 98 | target: number; 99 | reason: string; 100 | } 101 | 102 | // Player API 上下文类型 103 | export interface PlayerContext { 104 | round: Round; 105 | currentPhase: GamePhase; 106 | alivePlayers: PlayerInfo[]; 107 | allSpeeches: AllSpeeches; 108 | allVotes: AllVotes; 109 | } 110 | 111 | // 女巫特有上下文 112 | export interface WitchContext extends PlayerContext { 113 | killedTonight?: PlayerId; 114 | potionUsed: { heal: boolean; poison: boolean }; 115 | } 116 | 117 | // 预言家特有上下文 118 | export interface SeerContext extends PlayerContext { 119 | investigatedPlayers: InvestigatedPlayers; 120 | } 121 | 122 | // 开始游戏参数 123 | export interface StartGameParams { 124 | gameId: string; 125 | playerId: number; 126 | role: string; 127 | teammates: PlayerId[]; 128 | } 129 | 130 | // Combined context type for all roles 131 | export type GameContext = PlayerContext | SeerContext | WitchContext; 132 | 133 | 134 | -------------------------------------------------------------------------------- /docs/mobx-react-best-practices.md: -------------------------------------------------------------------------------- 1 | # MobX React 最佳实践经验 2 | 3 | ## 核心原则 4 | 5 | ### 1. 全局状态优先原则 6 | - **直接使用全局 store**:组件应直接导入并使用全局 MobX store,而非通过 props 传递 7 | - **移除冗余接口**:删除所有为 props 传递而定义的接口类型 8 | - **简化组件签名**:组件不需要接收状态相关的 props 9 | 10 | ```typescript 11 | // ❌ 错误:通过 props 传递状态 12 | interface ComponentProps { 13 | gameState: GameState; 14 | gameId: string; 15 | } 16 | export function Component({ gameState, gameId }: ComponentProps) {} 17 | 18 | // ✅ 正确:直接使用全局状态 19 | import { gameMaster } from '@/stores/gameStore'; 20 | export const Component = observer(function Component() { 21 | const gameState = gameMaster.getGameState(); 22 | }); 23 | ``` 24 | 25 | ### 2. MobX Computed 性能优化 26 | - **缓存派生数据**:使用 `computed` 属性缓存昂贵的计算结果 27 | - **同步访问**:将异步方法转换为同步的 computed 属性供 UI 直接访问 28 | - **标记 computed**:在 `makeAutoObservable` 中明确标记 computed 属性 29 | 30 | ```typescript 31 | // ✅ 在 store 中添加 computed 属性 32 | class GameMaster { 33 | constructor() { 34 | makeAutoObservable(this, { 35 | recentOperationLogs: computed, // 明确标记 36 | }); 37 | } 38 | 39 | // 同步 computed 属性,自动缓存 40 | get recentOperationLogs() { 41 | return this.operationLogSystem.getLogs().slice(-20); 42 | } 43 | } 44 | 45 | // ✅ 组件中直接使用 46 | const operationLogs = gameMaster.recentOperationLogs; // 同步访问,自动缓存 47 | ``` 48 | 49 | ### 3. Observer 包装必须 50 | - **包装所有组件**:使用 MobX 状态的组件必须用 `observer` 包装 51 | - **移除手动状态**:删除 `useState`, `useEffect` 等手动状态管理 52 | - **函数组件语法**:使用 `observer(function ComponentName() {})` 语法 53 | 54 | ```typescript 55 | // ✅ 正确的 observer 使用 56 | export const GameControls = observer(function GameControls() { 57 | const gameState = gameMaster.getGameState(); // 自动响应变化 58 | return
{gameState.round}
; 59 | }); 60 | ``` 61 | 62 | ### 4. 避免不必要的 API 调用 63 | - **前端状态管理**:在纯前端状态管理场景中,避免 HTTP API 调用 64 | - **直接状态访问**:直接从全局状态获取数据,而非通过网络请求 65 | - **移除异步逻辑**:删除 fetch、useEffect 等异步数据获取逻辑 66 | 67 | ```typescript 68 | // ❌ 错误:不必要的 API 调用 69 | useEffect(() => { 70 | const fetchLogs = async () => { 71 | const response = await fetch(`/api/game/${gameId}/operation-logs`); 72 | // ... 73 | }; 74 | fetchLogs(); 75 | }, [gameId]); 76 | 77 | // ✅ 正确:直接访问状态 78 | const operationLogs = gameMaster.recentOperationLogs; 79 | ``` 80 | 81 | ## 重构步骤 82 | 83 | ### 步骤 1:添加 Computed 属性 84 | 在 MobX store 中添加 computed 属性替代异步方法: 85 | 86 | ```typescript 87 | // 在 constructor 中标记 88 | makeAutoObservable(this, { 89 | derivedProperty: computed, 90 | }); 91 | 92 | // 添加 getter 93 | get derivedProperty() { 94 | return this.someData.slice(-20); // 同步计算 95 | } 96 | ``` 97 | 98 | ### 步骤 2:重构组件 99 | 1. 移除 props 接口定义 100 | 2. 添加 `observer` 包装 101 | 3. 导入全局 store 102 | 4. 直接使用 store 数据 103 | 104 | ### 步骤 3:清理父组件 105 | 移除所有不必要的 props 传递 106 | 107 | ### 步骤 4:清理导入 108 | 移除不再使用的导入和依赖 109 | 110 | ## 性能关键点 111 | 112 | 1. **Computed 缓存**:computed 属性只在依赖变化时重新计算,大幅提升性能 113 | 2. **精确更新**:observer 确保只有使用到变化数据的组件才重新渲染 114 | 3. **减少网络请求**:直接状态访问避免不必要的 API 调用 115 | 4. **内存优化**:移除冗余的状态管理代码 116 | 117 | ## 常见陷阱 118 | 119 | 1. **忘记 observer 包装**:组件不会响应 MobX 状态变化 120 | 2. **异步访问 computed**:computed 应该是同步的 121 | 3. **props 传递状态**:破坏了 MobX 的反应性系统 122 | 4. **混合状态管理**:不要同时使用 useState 和 MobX -------------------------------------------------------------------------------- /scripts/dev-players.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # AI狼人杀 - 开发模式AI玩家启动脚本 4 | # 启动8个AI玩家进程 5 | 6 | echo "🤖 AI狼人杀玩家启动(开发模式)" 7 | echo "==============================" 8 | 9 | # 检查是否在正确的目录 10 | if [ ! -f "package.json" ]; then 11 | echo "❌ 请在项目根目录运行此脚本" 12 | exit 1 13 | fi 14 | 15 | # 创建日志目录 16 | LOG_DIR="logs" 17 | mkdir -p "$LOG_DIR" 18 | 19 | # 存储进程ID 20 | declare -a PIDS=() 21 | 22 | # 清理函数 23 | cleanup() { 24 | echo "" 25 | echo "🛑 正在停止所有AI玩家进程..." 26 | 27 | # 停止所有AI玩家 28 | for pid in "${PIDS[@]}"; do 29 | if kill -0 $pid 2>/dev/null; then 30 | echo " 停止AI玩家进程 (PID: $pid)" 31 | kill $pid 32 | fi 33 | done 34 | 35 | echo "✅ 所有AI玩家进程已停止" 36 | exit 0 37 | } 38 | 39 | # 设置信号处理 40 | trap cleanup SIGINT SIGTERM 41 | 42 | # 加载环境变量 43 | if [ -f ".env" ]; then 44 | echo "📋 加载环境变量..." 45 | export $(grep -v '^#' .env | xargs) 46 | fi 47 | 48 | # 确保依赖已安装 49 | if [ ! -d "node_modules" ]; then 50 | echo "📦 安装monorepo依赖..." 51 | bun install 52 | if [ $? -ne 0 ]; then 53 | echo "❌ 依赖安装失败" 54 | exit 1 55 | fi 56 | fi 57 | 58 | # 启动AI玩家 59 | echo "🤖 启动AI玩家(开发模式)..." 60 | 61 | # 定义玩家配置 62 | declare -a PLAYERS=( 63 | "player1:玩家1:3001" 64 | "player2:玩家2:3002" 65 | "player3:玩家3:3003" 66 | "player4:玩家4:3004" 67 | "player5:玩家5:3005" 68 | "player6:玩家6:3006" 69 | "player7:玩家7:3007" 70 | "player8:玩家8:3008" 71 | ) 72 | 73 | # 启动每个玩家 74 | for player_info in "${PLAYERS[@]}"; do 75 | IFS=':' read -r config_name player_name port <<< "$player_info" 76 | config_file="config/${config_name}.yaml" 77 | log_file="$LOG_DIR/${config_name}-dev.log" 78 | 79 | echo " 启动 $player_name (端口: $port)" 80 | 81 | cd packages/player 82 | bun run dev --config="../../$config_file" > "../../$log_file" 2>&1 & 83 | pid=$! 84 | cd ../.. 85 | 86 | PIDS+=($pid) 87 | echo " PID: $pid" 88 | 89 | # 检查进程是否启动成功 90 | if ! kill -0 $pid 2>/dev/null; then 91 | echo "❌ $player_name 启动失败,请检查日志: $log_file" 92 | cleanup 93 | fi 94 | done 95 | 96 | echo "" 97 | echo "✅ 所有AI玩家启动成功!(开发模式)" 98 | echo "" 99 | echo "🎮 AI玩家状态:" 100 | echo " 玩家1: http://localhost:3001/api/player/status" 101 | echo " 玩家2: http://localhost:3002/api/player/status" 102 | echo " 玩家3: http://localhost:3003/api/player/status" 103 | echo " 玩家4: http://localhost:3004/api/player/status" 104 | echo " 玩家5: http://localhost:3005/api/player/status" 105 | echo " 玩家6: http://localhost:3006/api/player/status" 106 | echo " 玩家7: http://localhost:3007/api/player/status" 107 | echo " 玩家8: http://localhost:3008/api/player/status" 108 | echo "" 109 | echo "📋 日志文件: $LOG_DIR/ (后缀 -dev.log)" 110 | echo "" 111 | echo "💡 提示:" 112 | echo " 请确保游戏主进程已启动:bun run dev:game-master" 113 | echo " 或使用 bun run dev:game 同时启动游戏主进程和AI玩家" 114 | echo "" 115 | echo "🛑 按 Ctrl+C 停止所有AI玩家服务" 116 | echo "" 117 | 118 | # 监控进程状态 119 | while true; do 120 | sleep 5 121 | 122 | # 静默检查AI玩家进程 123 | alive_count=0 124 | for pid in "${PIDS[@]}"; do 125 | if kill -0 $pid 2>/dev/null; then 126 | ((alive_count++)) 127 | fi 128 | done 129 | 130 | # 如果AI玩家都退出了,提示并退出 131 | if [ $alive_count -eq 0 ]; then 132 | echo "⚠️ 所有AI玩家都已退出" 133 | cleanup 134 | fi 135 | done -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/GameOperationLog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 5 | import { gameMaster } from '@/stores/gameStore'; 6 | 7 | export const GameOperationLog = observer(function GameOperationLog() { 8 | const operationLogs = gameMaster.recentOperationLogs; 9 | 10 | const formatTime = (timestamp: Date) => { 11 | const date = new Date(timestamp); 12 | return date.toLocaleTimeString('zh-CN', { 13 | hour12: false, 14 | hour: '2-digit', 15 | minute: '2-digit', 16 | second: '2-digit' 17 | }); 18 | }; 19 | 20 | const getLogIcon = (type: string) => { 21 | switch (type) { 22 | case 'phase_change': 23 | return '🎯'; 24 | case 'player_request': 25 | return '💬'; 26 | case 'player_response': 27 | return '💬'; 28 | case 'system_action': 29 | return '🔮'; 30 | case 'result': 31 | return '⚡'; 32 | default: 33 | return '📝'; 34 | } 35 | }; 36 | 37 | if (!gameMaster.gameId) { 38 | return ( 39 | 40 | 41 | 📊 游戏操作记录 42 | 43 | 44 |
45 | 尚未创建游戏 46 |
47 |
48 |
49 | ); 50 | } 51 | 52 | return ( 53 | 54 | 55 | 📊 游戏操作记录 56 | 57 | 58 |
59 | {operationLogs.length === 0 ? ( 60 |
61 | 暂无操作记录 62 |
63 | ) : ( 64 |
65 | {operationLogs 66 | .sort((a, b) => b.sequence - a.sequence) 67 | .map((log) => ( 68 |
72 |
73 | {getLogIcon(log.type)} 74 |
75 |
76 |

77 | {log.message} 78 |

79 | 80 | {formatTime(log.timestamp)} 81 | 82 |
83 | {log.details && log.details.result && ( 84 |
85 | {log.details.result} 86 |
87 | )} 88 |
89 |
90 |
91 | ))} 92 |
93 | )} 94 |
95 |
96 |
97 | ); 98 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🐺 AI 狼人杀游戏框架 2 | 3 | 一个基于 AI 的多人狼人杀游戏框架,采用 monorepo 架构,支持多个具有独特个性的 AI 玩家进行游戏。 4 | 5 | ## ✨ 特性 6 | 7 | - 🤖 **AI 驱动**: 6 个具有不同个性和策略的 AI 玩家 8 | - 🎮 **完整游戏流程**: 白天讨论投票、夜晚角色技能 9 | - 🎭 **角色系统**: 支持村民、狼人、预言家、女巫四种角色 10 | - 📊 **可视化界面**: React + MobX 实时状态管理 11 | - 🔍 **AI 遥测**: 集成 Langfuse 进行 AI 行为分析 12 | - 🚀 **高性能**: 使用 Bun 运行时,无需构建步骤 13 | 14 | ## 🛠 技术栈 15 | 16 | - **运行时**: Bun 17 | - **前端**: Vite + React + MobX + TailwindCSS 18 | - **后端**: Express + TypeScript 19 | - **AI**: OpenAI SDK + 自定义个性系统 20 | - **监控**: Langfuse 遥测 21 | - **架构**: Monorepo (Bun Workspaces) 22 | 23 | ## 📦 项目结构 24 | 25 | ``` 26 | AI-Werewolf/ 27 | ├── packages/ 28 | │ ├── game-master-vite/ # 游戏主控前端 29 | │ └── player/ # AI 玩家服务器 30 | ├── shared/ 31 | │ ├── types/ # 共享类型定义 32 | │ ├── lib/ # 共享工具库 33 | │ └── prompts/ # AI 提示模板 34 | ├── config/ # 玩家配置文件 35 | └── scripts/ # 启动脚本 36 | ``` 37 | 38 | ## 🚀 快速开始 39 | 40 | ### 前置要求 41 | 42 | - Node.js 18+ 43 | - Bun 1.0+ 44 | - OpenAI API Key 45 | 46 | ### 安装 47 | 48 | ```bash 49 | # 克隆仓库 50 | git clone https://github.com/yourusername/AI-Werewolf.git 51 | cd AI-Werewolf 52 | 53 | # 安装依赖 54 | bun install 55 | 56 | # 配置环境变量 57 | cp .env.example .env 58 | # 编辑 .env 文件,添加你的 OpenAI API Key 59 | ``` 60 | 61 | ### 启动游戏 62 | 63 | ```bash 64 | # 启动所有 AI 玩家(端口 3001-3006) 65 | bun run dev:players 66 | 67 | # 新开终端,启动游戏主控界面(端口 3000) 68 | bun run dev:game-master 69 | ``` 70 | 71 | 访问 http://localhost:3000 开始游戏! 72 | 73 | ## 🎮 游戏流程 74 | 75 | 1. **创建游戏**: 点击"创建新游戏"按钮 76 | 2. **添加玩家**: 系统自动添加 6 个 AI 玩家 77 | 3. **分配角色**: 随机分配狼人、预言家、女巫和村民 78 | 4. **游戏循环**: 79 | - 🌞 白天: 玩家讨论并投票放逐 80 | - 🌙 夜晚: 特殊角色使用技能 81 | 5. **胜利条件**: 82 | - 村民阵营: 消灭所有狼人 83 | - 狼人阵营: 狼人数量 ≥ 村民数量 84 | 85 | ## 🤖 AI 玩家配置 86 | 87 | 每个 AI 玩家都有独特的个性设置: 88 | 89 | | 端口 | 玩家 | 策略类型 | 说话风格 | 特点 | 90 | |------|------|----------|----------|------| 91 | | 3001 | 玩家1 | balanced | casual | 理性分析型 | 92 | | 3002 | 玩家2 | aggressive | formal | 激进攻击型 | 93 | | 3003 | 玩家3 | conservative | formal | 保守稳重型 | 94 | | 3004 | 玩家4 | balanced | witty | 幽默风趣型 | 95 | | 3005 | 玩家5 | balanced | formal | 逻辑推理型 | 96 | | 3006 | 玩家6 | conservative | casual | 新手谨慎型 | 97 | 98 | ## 🔧 开发命令 99 | 100 | ### 开发模式 101 | 102 | ```bash 103 | # 启动所有 AI 玩家 104 | bun run dev:players 105 | 106 | # 启动游戏主控 107 | bun run dev:game-master 108 | 109 | # 启动特定个性的玩家 110 | bun run dev:player:aggressive 111 | bun run dev:player:conservative 112 | bun run dev:player:witty 113 | ``` 114 | 115 | ### 代码质量 116 | 117 | ```bash 118 | # 类型检查 119 | bun run typecheck 120 | 121 | # 代码规范检查 122 | bun run lint 123 | 124 | # 运行测试 125 | bun test 126 | 127 | # 测试覆盖率 128 | bun run test:coverage 129 | ``` 130 | 131 | ## 📊 监控与日志 132 | 133 | ### AI 玩家状态 134 | 135 | 每个 AI 玩家都提供状态接口: 136 | 137 | - http://localhost:3001/api/player/status 138 | - http://localhost:3002/api/player/status 139 | - ... (3003-3006) 140 | 141 | ### 日志文件 142 | 143 | 开发模式日志保存在 `logs/` 目录: 144 | 145 | - `player1-dev.log` - 玩家1日志 146 | - `player2-dev.log` - 玩家2日志 147 | - ... (player3-6) 148 | - `game-master-dev.log` - 游戏主控日志 149 | 150 | ## 🎯 核心功能 151 | 152 | ### 角色系统 153 | 154 | - **村民** 👤: 白天投票,无特殊技能 155 | - **狼人** 🐺: 夜晚击杀,知道队友身份 156 | - **预言家** 🔮: 每晚查验一名玩家身份 157 | - **女巫** 🧪: 拥有解药和毒药各一瓶 158 | 159 | ### 游戏阶段 160 | 161 | - **准备阶段**: 等待玩家加入 162 | - **夜晚阶段**: 特殊角色行动 163 | - **白天讨论**: AI 玩家自由发言 164 | - **投票阶段**: 投票放逐可疑玩家 165 | - **游戏结束**: 判定胜利条件 166 | 167 | ### AI 决策系统 168 | - 个性化提示工程 169 | - 上下文感知决策 170 | - 策略性投票逻辑 171 | 172 | ## 等待完成功能 173 | - [ ] 游戏结束时AI评分 174 | - [ ] 遗言 175 | - [ ] 狼队夜晚交流功能 176 | - [ ] 添加守卫,猎人等角色 177 | - [ ] 9人游戏模式 178 | - [ ] 提升UI/UX 179 | ## 🤝 贡献 180 | 181 | 欢迎提交 Pull Request 或创建 Issue! -------------------------------------------------------------------------------- /packages/game-master-vite/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/PlayerList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 5 | import { Badge } from '@/components/ui/badge'; 6 | import clsx from 'clsx'; 7 | import { Role } from '@ai-werewolf/types'; 8 | import { gameMaster } from '@/stores/gameStore'; 9 | 10 | export const PlayerList = observer(function PlayerList() { 11 | const getRoleText = (role: Role): string => { 12 | const roleMap = { 13 | [Role.WEREWOLF]: '狼人', 14 | [Role.VILLAGER]: '村民', 15 | [Role.SEER]: '预言家', 16 | [Role.WITCH]: '女巫', 17 | }; 18 | return roleMap[role] || role; 19 | }; 20 | 21 | const getRoleVariant = (role: Role) => { 22 | switch (role) { 23 | case Role.WEREWOLF: 24 | return 'destructive'; 25 | case Role.VILLAGER: 26 | return 'default'; 27 | case Role.SEER: 28 | return 'secondary'; 29 | case Role.WITCH: 30 | return 'outline'; 31 | default: 32 | return 'outline'; 33 | } 34 | }; 35 | 36 | const gameState = gameMaster.getGameState(); 37 | 38 | if (!gameMaster.gameId || !gameState) { 39 | return ( 40 | 41 | 42 | 43 | 👥 玩家列表 44 | 45 | 46 | 47 |
48 | 😴 49 | 暂无玩家信息 50 | 请先创建游戏 51 |
52 |
53 |
54 | ); 55 | } 56 | 57 | return ( 58 | 59 | 60 | 61 | 👥 玩家列表 62 | 63 | {gameState.players.length}人 64 | 65 | 66 | 67 | 68 |
69 | {gameState.players.map((player, index) => { 70 | 71 | return ( 72 |
82 |
86 | {player.role === Role.WEREWOLF ? '🐺' : 87 | player.role === Role.SEER ? '🔮' : 88 | player.role === Role.WITCH ? '🧪' : '👤'} 89 |
90 | 91 |
95 | 玩家{player.id} 96 | {!player.isAlive && ( 97 | ☠️ 98 | )} 99 |
100 | 101 | 105 | {getRoleText(player.role)} 106 | 107 |
108 | ) 109 | } 110 | )} 111 |
112 |
113 |
114 | ); 115 | }); -------------------------------------------------------------------------------- /packages/game-master-vite/src/lib/langfuse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Langfuse 前端集成 3 | * 用于 Game Master 的 Langfuse Web SDK 4 | */ 5 | 6 | import { LangfuseWeb } from 'langfuse'; 7 | 8 | // Langfuse Web 客户端实例 9 | let langfuseClient: LangfuseWeb | null = null; 10 | 11 | /** 12 | * 获取或创建 Langfuse Web 客户端 13 | */ 14 | function getLangfuseClient(): LangfuseWeb | null { 15 | if (!langfuseClient && import.meta.env.VITE_LANGFUSE_PUBLIC_KEY) { 16 | langfuseClient = new LangfuseWeb({ 17 | publicKey: import.meta.env.VITE_LANGFUSE_PUBLIC_KEY, 18 | baseUrl: import.meta.env.VITE_LANGFUSE_BASEURL || 'https://cloud.langfuse.com', 19 | }); 20 | console.log('✅ Langfuse Web 客户端已初始化'); 21 | } 22 | return langfuseClient; 23 | } 24 | 25 | /** 26 | * 初始化 Langfuse Web 27 | */ 28 | export function initializeLangfuse() { 29 | const client = getLangfuseClient(); 30 | if (client) { 31 | console.log('📊 Langfuse Web 已启用'); 32 | console.log(` - Public Key: ${import.meta.env.VITE_LANGFUSE_PUBLIC_KEY?.substring(0, 8)}...`); 33 | console.log(` - Base URL: ${import.meta.env.VITE_LANGFUSE_BASEURL || 'https://cloud.langfuse.com'}`); 34 | } else { 35 | console.log('⚠️ Langfuse Web 未启用(缺少 VITE_LANGFUSE_PUBLIC_KEY)'); 36 | } 37 | return client; 38 | } 39 | 40 | /** 41 | * 创建游戏 trace(前端只记录 gameId,实际 trace 由后端创建) 42 | */ 43 | export function createGameTrace(gameId: string): string { 44 | const client = getLangfuseClient(); 45 | if (client) { 46 | console.log(`📊 游戏已开始,ID: ${gameId}`); 47 | console.log(`📊 Langfuse Web 客户端准备记录用户反馈`); 48 | } else { 49 | console.log(`📊 [模拟] Game ID: ${gameId}`); 50 | } 51 | return gameId; 52 | } 53 | 54 | /** 55 | * 记录用户反馈评分 56 | */ 57 | export function scoreUserFeedback(traceId: string, score: number, comment?: string) { 58 | const client = getLangfuseClient(); 59 | if (!client) { 60 | console.log(`📊 [模拟] 用户反馈: ${traceId}, 评分: ${score}, 评论: ${comment || '无'}`); 61 | return; 62 | } 63 | 64 | try { 65 | client.score({ 66 | traceId, 67 | name: 'user-feedback', 68 | value: score, 69 | comment, 70 | }); 71 | console.log(`✅ 记录用户反馈: ${traceId}, 评分: ${score}`); 72 | } catch (error) { 73 | console.error('❌ 记录用户反馈失败:', error); 74 | } 75 | } 76 | 77 | /** 78 | * 记录游戏结果 79 | */ 80 | export function scoreGameResult(traceId: string, winner: 'werewolf' | 'villager', rounds: number) { 81 | const client = getLangfuseClient(); 82 | if (!client) { 83 | console.log(`📊 [模拟] 游戏结果: ${traceId}, 胜者: ${winner}, 回合数: ${rounds}`); 84 | return; 85 | } 86 | 87 | try { 88 | // 记录胜者 89 | client.score({ 90 | traceId, 91 | name: 'game-winner', 92 | value: winner === 'werewolf' ? 1 : 0, 93 | comment: `${winner} 阵营获胜`, 94 | }); 95 | 96 | // 记录回合数 97 | client.score({ 98 | traceId, 99 | name: 'game-rounds', 100 | value: rounds, 101 | comment: `游戏进行了 ${rounds} 回合`, 102 | }); 103 | 104 | console.log(`✅ 记录游戏结果: ${traceId}, 胜者: ${winner}, 回合数: ${rounds}`); 105 | } catch (error) { 106 | console.error('❌ 记录游戏结果失败:', error); 107 | } 108 | } 109 | 110 | /** 111 | * 关闭 Langfuse Web(浏览器环境下通常不需要) 112 | */ 113 | export async function shutdownLangfuse() { 114 | // 浏览器环境下通常不需要显式关闭 115 | console.log('📊 Langfuse Web 客户端将在页面卸载时自动清理'); 116 | } 117 | 118 | /** 119 | * Langfuse 错误处理包装器 120 | */ 121 | export function withLangfuseErrorHandling any>( 122 | fn: T, 123 | context?: string 124 | ): T { 125 | return ((...args: Parameters) => { 126 | try { 127 | return fn(...args); 128 | } catch (error) { 129 | console.error(`❌ Langfuse error in ${context || 'function'}:`, error); 130 | // 不要抛出错误,继续执行 131 | return undefined; 132 | } 133 | }) as T; 134 | } 135 | 136 | // 导出 langfuse 对象,提供统一接口(浏览器环境下为空操作) 137 | export const langfuse = { 138 | async flushAsync() { 139 | console.log('📊 Langfuse Web 在浏览器环境下自动管理数据发送'); 140 | } 141 | }; -------------------------------------------------------------------------------- /packages/game-master-vite/src/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.145 0 0); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.145 0 0); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.145 0 0); 54 | --primary: oklch(0.205 0 0); 55 | --primary-foreground: oklch(0.985 0 0); 56 | --secondary: oklch(0.97 0 0); 57 | --secondary-foreground: oklch(0.205 0 0); 58 | --muted: oklch(0.97 0 0); 59 | --muted-foreground: oklch(0.556 0 0); 60 | --accent: oklch(0.97 0 0); 61 | --accent-foreground: oklch(0.205 0 0); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.922 0 0); 64 | --input: oklch(0.922 0 0); 65 | --ring: oklch(0.708 0 0); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.985 0 0); 72 | --sidebar-foreground: oklch(0.145 0 0); 73 | --sidebar-primary: oklch(0.205 0 0); 74 | --sidebar-primary-foreground: oklch(0.985 0 0); 75 | --sidebar-accent: oklch(0.97 0 0); 76 | --sidebar-accent-foreground: oklch(0.205 0 0); 77 | --sidebar-border: oklch(0.922 0 0); 78 | --sidebar-ring: oklch(0.708 0 0); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.145 0 0); 83 | --foreground: oklch(0.985 0 0); 84 | --card: oklch(0.205 0 0); 85 | --card-foreground: oklch(0.985 0 0); 86 | --popover: oklch(0.205 0 0); 87 | --popover-foreground: oklch(0.985 0 0); 88 | --primary: oklch(0.922 0 0); 89 | --primary-foreground: oklch(0.205 0 0); 90 | --secondary: oklch(0.269 0 0); 91 | --secondary-foreground: oklch(0.985 0 0); 92 | --muted: oklch(0.269 0 0); 93 | --muted-foreground: oklch(0.708 0 0); 94 | --accent: oklch(0.269 0 0); 95 | --accent-foreground: oklch(0.985 0 0); 96 | --destructive: oklch(0.704 0.191 22.216); 97 | --border: oklch(1 0 0 / 10%); 98 | --input: oklch(1 0 0 / 15%); 99 | --ring: oklch(0.556 0 0); 100 | --chart-1: oklch(0.488 0.243 264.376); 101 | --chart-2: oklch(0.696 0.17 162.48); 102 | --chart-3: oklch(0.769 0.188 70.08); 103 | --chart-4: oklch(0.627 0.265 303.9); 104 | --chart-5: oklch(0.645 0.246 16.439); 105 | --sidebar: oklch(0.205 0 0); 106 | --sidebar-foreground: oklch(0.985 0 0); 107 | --sidebar-primary: oklch(0.488 0.243 264.376); 108 | --sidebar-primary-foreground: oklch(0.985 0 0); 109 | --sidebar-accent: oklch(0.269 0 0); 110 | --sidebar-accent-foreground: oklch(0.985 0 0); 111 | --sidebar-border: oklch(1 0 0 / 10%); 112 | --sidebar-ring: oklch(0.556 0 0); 113 | } 114 | 115 | @layer base { 116 | * { 117 | @apply border-border outline-ring/50; 118 | } 119 | body { 120 | @apply bg-background text-foreground; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /packages/player/CONFIG.md: -------------------------------------------------------------------------------- 1 | # Player 配置文件说明 2 | 3 | ## 概述 4 | 5 | Player服务器现在支持通过配置文件来自定义AI玩家的行为、个性和服务器设置。这使得你可以创建具有不同特点的AI玩家来进行游戏。 6 | 7 | ## 使用方法 8 | 9 | ### 命令行启动 10 | 11 | ```bash 12 | # 使用默认配置 13 | pnpm dev:player 14 | 15 | # 使用指定配置文件 16 | pnpm dev:player --config=configs/aggressive.json 17 | pnpm run dev:player:aggressive # 快捷方式 18 | 19 | # 生产环境 20 | pnpm build 21 | node dist/index.js --config=configs/default.json 22 | ``` 23 | 24 | ### 可用的预设配置 25 | 26 | - `configs/default.json` - 默认平衡型玩家 27 | - `configs/aggressive.json` - 激进攻击型玩家 28 | - `configs/conservative.json` - 保守稳重型玩家 29 | - `configs/witty.json` - 风趣幽默型玩家 30 | 31 | ### 快捷启动脚本 32 | 33 | ```bash 34 | pnpm dev:player:default # 使用默认配置 35 | pnpm dev:player:aggressive # 激进型玩家 36 | pnpm dev:player:conservative # 保守型玩家 37 | pnpm dev:player:witty # 幽默型玩家 38 | ``` 39 | 40 | ## 配置文件结构 41 | 42 | ```json 43 | { 44 | "server": { 45 | "port": 3001, // 服务器端口 46 | "host": "0.0.0.0" // 监听主机 47 | }, 48 | "ai": { 49 | "model": "anthropic/claude-3-haiku", // AI模型 50 | "maxTokens": 150, // 最大token数 51 | "temperature": 0.8, // 创造性参数(0-2) 52 | "provider": "openrouter" // AI提供商 53 | }, 54 | "game": { 55 | "name": "智能分析师", // 玩家显示名称 56 | "personality": "理性分析型玩家...", // 个性描述 57 | "strategy": "balanced", // 策略类型 58 | "speakingStyle": "casual" // 说话风格 59 | }, 60 | "logging": { 61 | "level": "info", // 日志级别 62 | "enabled": true // 是否启用日志 63 | } 64 | } 65 | ``` 66 | 67 | ## 配置选项详解 68 | 69 | ### 服务器配置 (server) 70 | 71 | - `port` (number): 服务器监听端口,默认3001 72 | - `host` (string): 监听地址,默认"0.0.0.0" 73 | 74 | ### AI配置 (ai) 75 | 76 | - `model` (string): AI模型名称 77 | - OpenRouter: `anthropic/claude-3-haiku`, `anthropic/claude-3.5-sonnet`, `openai/gpt-4`等 78 | - OpenAI: `gpt-3.5-turbo`, `gpt-4`等 79 | - `maxTokens` (number): 单次生成最大token数,范围10-2000 80 | - `temperature` (number): 创造性参数,范围0-2,越高越有创意 81 | - `provider` (string): AI提供商,"openrouter"或"openai" 82 | - `apiKey` (string, 可选): API密钥,通常通过环境变量设置 83 | 84 | ### 游戏配置 (game) 85 | 86 | - `name` (string): 玩家在游戏中的显示名称 87 | - `personality` (string): 详细的个性描述,影响AI的发言风格 88 | - `strategy` (string): 游戏策略类型 89 | - `"aggressive"`: 积极主动,敢于质疑和攻击 90 | - `"conservative"`: 谨慎稳重,多观察少发言 91 | - `"balanced"`: 平衡理性,灵活应对 92 | - `speakingStyle` (string): 说话风格 93 | - `"formal"`: 正式严谨,逻辑清晰 94 | - `"casual"`: 轻松随意,贴近日常对话 95 | - `"witty"`: 风趣幽默,善用比喻 96 | 97 | ### 日志配置 (logging) 98 | 99 | - `level` (string): 日志级别,"debug", "info", "warn", "error" 100 | - `enabled` (boolean): 是否启用日志输出 101 | 102 | ## 环境变量覆盖 103 | 104 | 配置文件中的设置可以被环境变量覆盖: 105 | 106 | ```bash 107 | # AI配置 108 | export AI_MODEL="openai/gpt-4" 109 | export AI_MAX_TOKENS=200 110 | export AI_TEMPERATURE=0.9 111 | export OPENROUTER_API_KEY="your_key" 112 | export OPENAI_API_KEY="your_key" 113 | 114 | # 服务器配置 115 | export PORT=3005 116 | export HOST="127.0.0.1" 117 | 118 | # 游戏配置 119 | export PLAYER_NAME="我的AI玩家" 120 | export PLAYER_PERSONALITY="善于分析的理性玩家" 121 | export PLAYER_STRATEGY="aggressive" 122 | export PLAYER_SPEAKING_STYLE="formal" 123 | 124 | # 日志配置 125 | export LOG_LEVEL="debug" 126 | export LOG_ENABLED="true" 127 | ``` 128 | 129 | ## 自定义配置文件 130 | 131 | 你可以创建自己的配置文件: 132 | 133 | ```json 134 | { 135 | "server": { 136 | "port": 3005, 137 | "host": "0.0.0.0" 138 | }, 139 | "ai": { 140 | "model": "anthropic/claude-3.5-sonnet", 141 | "maxTokens": 200, 142 | "temperature": 0.7, 143 | "provider": "openrouter" 144 | }, 145 | "game": { 146 | "name": "推理大师", 147 | "personality": "逻辑推理能力极强,善于从细节中发现线索,说话简洁有力", 148 | "strategy": "balanced", 149 | "speakingStyle": "formal" 150 | }, 151 | "logging": { 152 | "level": "info", 153 | "enabled": true 154 | } 155 | } 156 | ``` 157 | 158 | 保存为 `configs/detective.json`,然后启动: 159 | 160 | ```bash 161 | pnpm dev:player --config=configs/detective.json 162 | ``` 163 | 164 | ## 多玩家部署 165 | 166 | 通过配置不同端口,可以同时运行多个AI玩家: 167 | 168 | ```bash 169 | # 终端1: 激进型玩家 (端口3002) 170 | pnpm dev:player:aggressive 171 | 172 | # 终端2: 保守型玩家 (端口3003) 173 | pnpm dev:player:conservative 174 | 175 | # 终端3: 幽默型玩家 (端口3004) 176 | pnpm dev:player:witty 177 | ``` 178 | 179 | ## 故障排除 180 | 181 | ### 配置文件加载失败 182 | - 检查文件路径是否正确 183 | - 确认JSON格式是否有效 184 | - 查看控制台的错误信息 185 | 186 | ### AI API调用失败 187 | - 检查API密钥是否正确设置 188 | - 确认网络连接正常 189 | - AI服务会自动降级到预设回复 190 | 191 | ### 端口被占用 192 | - 修改配置文件中的端口号 193 | - 或通过环境变量 `PORT=3005` 覆盖 194 | 195 | ## 示例场景 196 | 197 | ### 测试不同策略 198 | ```bash 199 | # 同时启动三种策略的玩家进行对比测试 200 | pnpm dev:player:aggressive & # 后台运行激进玩家 201 | pnpm dev:player:conservative & # 后台运行保守玩家 202 | pnpm dev:player:witty # 前台运行幽默玩家 203 | ``` 204 | 205 | ### 自定义角色扮演 206 | 创建角色特定的配置文件,比如`configs/detective.json`用于扮演侦探角色,`configs/newbie.json`用于扮演新手玩家等。 -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/ChatDisplay.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | import { format } from 'date-fns'; 5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 6 | import { Badge } from '@/components/ui/badge'; 7 | import clsx from 'clsx'; 8 | import { Role } from '@ai-werewolf/types'; 9 | import { gameMaster } from '@/stores/gameStore'; 10 | 11 | export const ChatDisplay = observer(function ChatDisplay() { 12 | const gameState = gameMaster.getGameState(); 13 | const speechesData = gameMaster.getSpeeches(); 14 | 15 | // 将 AllSpeeches 对象转换为数组格式,保持时间顺序(最新的在前) 16 | const speeches = Object.keys(speechesData) 17 | .sort((a, b) => Number(b) - Number(a)) // 按回合数倒序排序,最新的回合在前 18 | .flatMap(round => { 19 | const roundSpeeches = speechesData[Number(round)] || []; 20 | return roundSpeeches.slice().reverse(); // 每个回合内的消息也倒序,最新的在前 21 | }) 22 | .filter(speech => speech != null); 23 | 24 | // 调试信息 25 | console.log('📋 ChatDisplay - speeches data:', speechesData); 26 | console.log('📋 ChatDisplay - flattened speeches:', speeches); 27 | console.log('📋 ChatDisplay - speeches length:', speeches.length); 28 | console.log('📋 ChatDisplay - gameState:', gameState); 29 | 30 | const getPlayerRole = (playerId: number): Role | null => { 31 | if (!gameState) return null; 32 | const player = gameState.players.find(p => p.id === playerId); 33 | return player?.role || null; 34 | }; 35 | 36 | const getMessageStyle = () => { 37 | return 'border border-border bg-card'; 38 | }; 39 | 40 | const getRoleText = (role: Role | null) => { 41 | const roleMap = { 42 | [Role.WEREWOLF]: '狼人', 43 | [Role.VILLAGER]: '村民', 44 | [Role.SEER]: '预言家', 45 | [Role.WITCH]: '女巫' 46 | }; 47 | return role ? roleMap[role] : ''; 48 | }; 49 | 50 | if (!gameState && speeches.length === 0) { 51 | return ( 52 | 53 | 54 | 玩家对话记录 55 | 56 | 57 |
58 | 等待游戏开始... 59 |
60 |
61 |
62 | ); 63 | } 64 | 65 | return ( 66 | 67 | 68 | 玩家对话记录 69 | 70 | 71 | 72 | {speeches.length === 0 ? ( 73 |
74 | 暂无发言记录 75 |
76 | ) : ( 77 | speeches 78 | .map((speech, index) => { 79 | const role = getPlayerRole(speech.playerId); 80 | const messageStyle = getMessageStyle(); 81 | console.log('ChatDisplay111 - speech:', speech); 82 | 83 | return ( 84 |
92 |
93 |
94 | 101 | {speech.type === 'system' ? '系统' : `玩家${speech.playerId}`} 102 | 103 | 104 | {speech.type === 'system' && ( 105 | 106 | 系统通知 107 | 108 | )} 109 | 110 | 111 | {(!speech.type || speech.type === 'player') && role && ( 112 | 116 | {getRoleText(role)} 117 | 118 | )} 119 |
120 | 121 | {format(new Date(), 'HH:mm:ss')} 122 | 123 |
124 |
125 | {speech.content} 126 |
127 |
128 | ); 129 | }) 130 | )} 131 |
132 |
133 | ); 134 | }); -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/GameControls.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { observer } from 'mobx-react-lite'; 5 | import { GamePhase } from '@ai-werewolf/types'; 6 | import { Button } from '@/components/ui/button'; 7 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 8 | import { Badge } from '@/components/ui/badge'; 9 | import { gameMaster } from '@/stores/gameStore'; 10 | import { getPlayerUrls } from '@/lib/playerConfig'; 11 | 12 | export const GameControls = observer(function GameControls() { 13 | const [isLoading, setIsLoading] = useState(false); 14 | 15 | const handleCreateGame = async () => { 16 | setIsLoading(true); 17 | try { 18 | // 获取玩家URL列表 19 | const playerUrls = getPlayerUrls(); 20 | 21 | // 创建游戏 22 | await gameMaster.createGame(playerUrls.length); 23 | 24 | // 添加AI玩家,ID从1开始 25 | for (let i = 0; i < playerUrls.length; i++) { 26 | await gameMaster.addPlayer(i + 1, playerUrls[i]); 27 | } 28 | 29 | // 分配角色 30 | await gameMaster.assignRoles(); 31 | 32 | console.log(`✅ Game created successfully with ID: ${gameMaster.gameId}`); 33 | console.log(`👥 Added ${playerUrls.length} players`); 34 | } catch (err) { 35 | console.error('Failed to create game:', err); 36 | } finally { 37 | setIsLoading(false); 38 | } 39 | }; 40 | 41 | const handleStartGame = async () => { 42 | setIsLoading(true); 43 | try { 44 | await gameMaster.startGame(); 45 | } catch (err) { 46 | console.error('Failed to start game:', err); 47 | } finally { 48 | setIsLoading(false); 49 | } 50 | }; 51 | 52 | const handleNextPhase = async () => { 53 | setIsLoading(true); 54 | try { 55 | await gameMaster.nextPhase(); 56 | } catch (err) { 57 | console.error('Failed to advance phase:', err); 58 | } finally { 59 | setIsLoading(false); 60 | } 61 | }; 62 | 63 | const handleEndGame = () => { 64 | // Reset game state if needed 65 | console.log('End game requested'); 66 | }; 67 | 68 | const gameState = gameMaster.getGameState(); 69 | const canStart = gameMaster.gameId && gameState && gameState.players.length > 0 && gameState.round === 0; 70 | const canAdvance = gameMaster.gameId && gameState && gameState.round > 0 && gameState.currentPhase !== GamePhase.ENDED; 71 | const canEnd = gameMaster.gameId !== null; 72 | 73 | return ( 74 | 75 | 76 | 77 | 🎮 游戏控制 78 | 79 | 80 | 81 |
82 | 90 | 91 | 99 | 100 | 108 | 109 | 117 | 118 | {gameMaster.gameId && ( 119 |
120 | 游戏ID: 121 | 122 | {gameMaster.gameId} 123 | 124 |
125 | )} 126 |
127 | 128 | {gameState && ( 129 |
130 |
131 |
132 | 第{gameState.round}天 133 |
134 |
135 | 阶段: 136 | 137 | {getPhaseText(gameState.currentPhase)} 138 | 139 |
140 |
141 |
142 | )} 143 |
144 |
145 | ); 146 | }); 147 | 148 | function getPhaseText(phase: GamePhase): string { 149 | const phaseMap = { 150 | [GamePhase.PREPARING]: '准备中', 151 | [GamePhase.NIGHT]: '夜晚', 152 | [GamePhase.DAY]: '白天', 153 | [GamePhase.VOTING]: '投票', 154 | [GamePhase.ENDED]: '结束' 155 | }; 156 | return phaseMap[phase] || phase; 157 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | AI-powered Werewolf game framework - a monorepo implementing an AI-driven multiplayer werewolf game with distinct AI personalities. 7 | 8 | ## Tech Stack & Package Manager 9 | - **Package Manager**: Bun (no build step needed for backend, direct execution) 10 | - **Frontend**: Vite + React + MobX + TailwindCSS 11 | - **Backend**: Node.js/Bun + Express 12 | - **AI Integration**: OpenAI SDK, Langfuse telemetry 13 | - **State Management**: MobX with global stores 14 | - 我用bun,不需要build 15 | 16 | ## Critical Development Rules 17 | - **TypeScript**: NEVER use `any` type - always use proper typing 18 | - **Always use ultrathink** for complex reasoning tasks 19 | - **Player IDs**: Always use numbers for Player IDs 20 | - **Shared Types**: Only put types in shared/ if needed by Player services (e.g., API types called by game master) 21 | 22 | ## Common Development Commands 23 | 24 | ### Development 25 | ```bash 26 | # Start all 6 AI players (ports 3001-3006) 27 | ./scripts/dev-players.sh 28 | # OR 29 | bun run dev:players 30 | 31 | # Start game master frontend (port 3000) 32 | bun run dev:game-master 33 | 34 | # Start individual player with config 35 | bun run dev:player:aggressive 36 | bun run dev:player:conservative 37 | bun run dev:player:witty 38 | bun run dev:player:default 39 | ``` 40 | 41 | ### Code Quality 42 | ```bash 43 | # Type checking (entire monorepo) 44 | bun run typecheck 45 | bunx tsc --build 46 | 47 | # Type checking specific packages 48 | bun run typecheck:frontend 49 | bun run typecheck:backend 50 | 51 | # Linting 52 | bun run lint 53 | 54 | # Testing (when tests exist) 55 | bun test 56 | bun run test:packages 57 | bun run test:coverage 58 | ``` 59 | 60 | ## Architecture Overview 61 | 62 | ### Monorepo Structure 63 | ``` 64 | packages/ 65 | ├── game-master-vite/ # Frontend UI (Vite + React + MobX) 66 | │ └── src/ 67 | │ ├── components/ # React components with observer HOC 68 | │ ├── stores/ # MobX global stores 69 | │ └── lib/ # GameMaster class 70 | ├── player/ # AI player server 71 | │ └── src/ 72 | │ ├── services/ # AIService, PersonalityFactory 73 | │ └── configs/ # Player personality configs 74 | shared/ 75 | ├── types/ # Shared TypeScript types & schemas 76 | │ └── src/ 77 | │ ├── api.ts # API request/response types 78 | │ └── schemas.ts # Zod schemas for AI responses 79 | ├── lib/ # Shared utilities & Langfuse integration 80 | └── prompts/ # AI prompt templates 81 | ``` 82 | 83 | ### Core Game Flow 84 | 1. **Game Creation**: Frontend calls `gameMaster.createGame(6)` → adds 6 AI players → assigns roles 85 | 2. **Game Phases**: Day (discussion + voting) → Night (role abilities) → repeat 86 | 3. **AI Players**: Each runs on separate port (3001-3006), receives game state via HTTP API 87 | 4. **Role System**: 4 roles only - VILLAGER, WEREWOLF, SEER, WITCH (no HUNTER/GUARD) 88 | 89 | ## MobX React Development Pattern 90 | 91 | ### Required Pattern 92 | ```typescript 93 | // ✅ ALWAYS use this pattern 94 | import { observer } from 'mobx-react-lite'; 95 | import { gameMaster } from '@/stores/gameStore'; 96 | 97 | export const Component = observer(function Component() { 98 | const data = gameMaster.computedProperty; // Direct global state access 99 | return
{data}
; 100 | }); 101 | ``` 102 | 103 | ### Core MobX Rules 104 | 1. **Global State First**: Access state directly from global stores, never pass through props 105 | 2. **Observer Wrapper**: ALL components using MobX state MUST use `observer` HOC 106 | 3. **Computed Properties**: Use `computed` for derived data to optimize performance 107 | 4. **Avoid Redundant APIs**: Get data directly from state, don't make unnecessary network requests 108 | 109 | ## Critical Integration Points 110 | 111 | ### Langfuse Telemetry 112 | - Located in `shared/lib/src/langfuse.ts` 113 | - Key exports: `getAITelemetryConfig`, `shutdownLangfuse`, `langfuse` object 114 | - Browser-safe implementation (no-op flush in frontend) 115 | 116 | ### AI Service Architecture 117 | - `AIService` class handles all AI interactions 118 | - Personality system via `PersonalityFactory` 119 | - Each player has configurable personality affecting decisions 120 | - Zod schemas validate AI responses (see `shared/types/src/schemas.ts`) 121 | 122 | ### Game State Management 123 | - Frontend: Global `GameMaster` instance in `packages/game-master-vite/src/stores/gameStore.ts` 124 | - Players maintain local state, receive updates via API 125 | - State sync through HTTP endpoints, no WebSocket 126 | 127 | ## Player Configuration 128 | AI players run on ports 3001-3006 with personalities defined in YAML configs: 129 | - **Port 3001-3006**: Individual AI players with unique personalities 130 | - Config files: `config/player[1-6].yaml` 131 | - Each player has strategy (aggressive/conservative/balanced), speech style (casual/formal/witty) 132 | 133 | ## UI Components 134 | - **Game Controls**: Blue create, green start, purple next phase, red end buttons 135 | - **Player Cards**: Show role icons (🐺🔮🧪👤), alive/dead status 136 | - **Auto-setup**: "Create New Game" button automatically configures 6 AI players 137 | 138 | ## Known Issues & Fixes 139 | - **Langfuse Integration**: `getAITelemetryConfig` must be exported from `shared/lib/src/langfuse.ts` 140 | - **Create Game**: Must add players and assign roles after game creation 141 | - **Type Imports**: Always import `PersonalityType` when using AI services -------------------------------------------------------------------------------- /packages/player/src/config/PlayerConfig.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import yaml from 'js-yaml'; 4 | 5 | export interface PlayerConfig { 6 | server: { 7 | port: number; 8 | host: string; 9 | }; 10 | ai: { 11 | model: string; 12 | maxTokens: number; 13 | temperature: number; 14 | provider: 'openrouter' | 'openai'; 15 | apiKey?: string; 16 | }; 17 | game: { 18 | personality: string; 19 | strategy: 'aggressive' | 'conservative' | 'balanced'; 20 | }; 21 | logging: { 22 | enabled: boolean; 23 | }; 24 | } 25 | 26 | export const DEFAULT_CONFIG: PlayerConfig = { 27 | server: { 28 | port: 3001, 29 | host: '0.0.0.0' 30 | }, 31 | ai: { 32 | model: 'gpt-3.5-turbo', 33 | maxTokens: 150, 34 | temperature: 0.8, 35 | provider: 'openai' 36 | }, 37 | game: { 38 | personality: '理性分析型玩家,善于逻辑推理', 39 | strategy: 'balanced' 40 | }, 41 | logging: { 42 | enabled: true 43 | } 44 | }; 45 | 46 | export class ConfigLoader { 47 | private config: PlayerConfig; 48 | 49 | constructor(configPath?: string) { 50 | this.config = this.loadConfig(configPath); 51 | } 52 | 53 | private loadConfig(configPath?: string): PlayerConfig { 54 | // 加载默认配置 55 | let config = { ...DEFAULT_CONFIG }; 56 | 57 | // 从环境变量覆盖配置 58 | this.loadFromEnv(config); 59 | 60 | // 从配置文件覆盖配置 61 | if (configPath) { 62 | try { 63 | const fileConfig = this.loadFromFile(configPath); 64 | config = this.mergeConfig(config, fileConfig); 65 | console.log(`✅ 配置文件已加载: ${configPath}`); 66 | } catch (error) { 67 | console.warn(`⚠️ 无法加载配置文件 ${configPath}:`, error); 68 | console.log('使用默认配置和环境变量配置'); 69 | } 70 | } 71 | 72 | return config; 73 | } 74 | 75 | private loadFromFile(configPath: string): Partial { 76 | const absolutePath = join(process.cwd(), configPath); 77 | const configContent = readFileSync(absolutePath, 'utf-8'); 78 | 79 | if (configPath.endsWith('.json')) { 80 | return JSON.parse(configContent); 81 | } else if (configPath.endsWith('.yaml') || configPath.endsWith('.yml')) { 82 | return yaml.load(configContent) as Partial; 83 | } else if (configPath.endsWith('.js') || configPath.endsWith('.ts')) { 84 | // 对于JS/TS文件,我们需要动态导入 85 | delete require.cache[require.resolve(absolutePath)]; 86 | const configModule = require(absolutePath); 87 | return configModule.default || configModule; 88 | } else { 89 | throw new Error('不支持的配置文件格式,请使用 .json、.yaml、.yml、.js 或 .ts 文件'); 90 | } 91 | } 92 | 93 | private loadFromEnv(config: PlayerConfig): void { 94 | // 服务器配置 95 | if (process.env.PORT) { 96 | config.server.port = parseInt(process.env.PORT); 97 | } 98 | if (process.env.HOST) { 99 | config.server.host = process.env.HOST; 100 | } 101 | 102 | // AI配置 103 | if (process.env.AI_MODEL) { 104 | config.ai.model = process.env.AI_MODEL; 105 | } 106 | if (process.env.AI_MAX_TOKENS) { 107 | config.ai.maxTokens = parseInt(process.env.AI_MAX_TOKENS); 108 | } 109 | if (process.env.AI_TEMPERATURE) { 110 | config.ai.temperature = parseFloat(process.env.AI_TEMPERATURE); 111 | } 112 | if (process.env.OPENROUTER_API_KEY) { 113 | config.ai.provider = 'openrouter'; 114 | config.ai.apiKey = process.env.OPENROUTER_API_KEY; 115 | } else if (process.env.OPENAI_API_KEY) { 116 | config.ai.provider = 'openai'; 117 | config.ai.apiKey = process.env.OPENAI_API_KEY; 118 | } 119 | 120 | // 游戏配置 121 | if (process.env.PLAYER_PERSONALITY) { 122 | config.game.personality = process.env.PLAYER_PERSONALITY; 123 | } 124 | if (process.env.PLAYER_STRATEGY) { 125 | config.game.strategy = process.env.PLAYER_STRATEGY as any; 126 | } 127 | 128 | // 日志配置 129 | if (process.env.LOG_ENABLED) { 130 | config.logging.enabled = process.env.LOG_ENABLED === 'true'; 131 | } 132 | } 133 | 134 | private mergeConfig(base: PlayerConfig, override: Partial): PlayerConfig { 135 | return { 136 | server: { ...base.server, ...override.server }, 137 | ai: { ...base.ai, ...override.ai }, 138 | game: { ...base.game, ...override.game }, 139 | logging: { ...base.logging, ...override.logging } 140 | }; 141 | } 142 | 143 | getConfig(): PlayerConfig { 144 | return this.config; 145 | } 146 | 147 | // 验证配置 148 | validateConfig(): boolean { 149 | const { config } = this; 150 | 151 | // 验证端口 152 | if (config.server.port < 1 || config.server.port > 65535) { 153 | console.error('❌ 无效的端口号:', config.server.port); 154 | return false; 155 | } 156 | 157 | // 验证AI配置 158 | if (!config.ai.apiKey && process.env.NODE_ENV !== 'test') { 159 | console.warn('⚠️ 未配置AI API密钥,将使用预设回复'); 160 | } 161 | 162 | if (config.ai.maxTokens < 10 || config.ai.maxTokens > 10000) { 163 | console.error('❌ maxTokens应在10-2000之间:', config.ai.maxTokens); 164 | return false; 165 | } 166 | 167 | if (config.ai.temperature < 0 || config.ai.temperature > 2) { 168 | console.error('❌ temperature应在0-2之间:', config.ai.temperature); 169 | return false; 170 | } 171 | 172 | return true; 173 | } 174 | 175 | // 打印配置信息 176 | printConfig(): void { 177 | if (!this.config.logging.enabled) return; 178 | 179 | console.log('\n🎯 Player配置信息:'); 180 | console.log(` 服务器: ${this.config.server.host}:${this.config.server.port}`); 181 | console.log(` AI模型: ${this.config.ai.model} (${this.config.ai.provider})`); 182 | console.log(` 策略: ${this.config.game.strategy}`); 183 | console.log(` 日志: ${this.config.logging.enabled ? '启用' : '禁用'}\n`); 184 | } 185 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/components/GameStatus.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | import { GamePhase } from '@ai-werewolf/types'; 5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 6 | import { Badge } from '@/components/ui/badge'; 7 | import { gameMaster } from '@/stores/gameStore'; 8 | 9 | export const GameStatus = observer(function GameStatus() { 10 | const gameState = gameMaster.getGameState(); 11 | const operationLogs = gameMaster.recentOperationLogs; 12 | 13 | const getPhaseText = (phase: GamePhase): string => { 14 | const phaseMap = { 15 | [GamePhase.PREPARING]: '准备中', 16 | [GamePhase.NIGHT]: '夜晚', 17 | [GamePhase.DAY]: '白天', 18 | [GamePhase.VOTING]: '投票', 19 | [GamePhase.ENDED]: '结束' 20 | }; 21 | return phaseMap[phase] || phase; 22 | }; 23 | 24 | const getPhaseVariant = (phase: GamePhase) => { 25 | switch (phase) { 26 | case GamePhase.PREPARING: 27 | return 'outline'; 28 | case GamePhase.NIGHT: 29 | return 'secondary'; 30 | case GamePhase.DAY: 31 | return 'default'; 32 | case GamePhase.VOTING: 33 | return 'destructive'; 34 | default: 35 | return 'secondary'; 36 | } 37 | }; 38 | 39 | 40 | const getLogTypeStyle = (type: string) => { 41 | switch (type) { 42 | case 'phase_change': 43 | return 'border-l-4 border-l-primary'; 44 | case 'player_request': 45 | return 'border-l-4 border-l-orange-500'; 46 | case 'player_response': 47 | return 'border-l-4 border-l-green-500'; 48 | case 'system_action': 49 | return 'border-l-4 border-l-blue-500'; 50 | case 'result': 51 | return 'border-l-4 border-l-purple-500'; 52 | default: 53 | return 'border-l-4 border-l-muted'; 54 | } 55 | }; 56 | 57 | const formatTime = (timestamp: Date) => { 58 | const date = new Date(timestamp); 59 | return date.toLocaleTimeString('zh-CN', { 60 | hour12: false, 61 | hour: '2-digit', 62 | minute: '2-digit', 63 | second: '2-digit' 64 | }); 65 | }; 66 | 67 | if (!gameMaster.gameId) { 68 | return ( 69 | 70 | 71 | 📊 游戏状态 72 | 73 | 74 |
75 | 尚未创建游戏 76 |
77 |
78 |
79 | ); 80 | } 81 | 82 | return ( 83 | 84 | 85 | 📊 游戏状态 86 | 87 | 88 | {/* 当前阶段和游戏天数 */} 89 |
90 |
91 |
当前阶段
92 | 96 | {gameState ? getPhaseText(gameState.currentPhase) : '未开始'} 97 | 98 |
99 |
100 |
游戏天数
101 |
102 | 第 {gameState?.round || 0} 天 103 |
104 |
105 |
106 | 107 | 108 | {/* 操作记录 */} 109 |
110 |
📋 操作记录
111 |
112 | {operationLogs.length === 0 ? ( 113 |
114 | 暂无操作记录 115 |
116 | ) : ( 117 | operationLogs 118 | .sort((a, b) => b.sequence - a.sequence) // 使用序列号排序,最新的在上面 119 | .map((log) => ( 120 | 124 |
125 |
126 | {log.message} 127 |
128 |
129 | {formatTime(log.timestamp)} 130 |
131 |
132 | {log.details && ( 133 |
134 | {log.details.result && ( 135 |
结果: {log.details.result}
136 | )} 137 | {log.details.target && ( 138 |
目标: {log.details.target}
139 | )} 140 |
141 | )} 142 |
143 | )) 144 | )} 145 |
146 |
147 | 148 | {/* 游戏信息 */} 149 |
150 |
游戏信息
151 |
152 |
游戏ID: {gameMaster.gameId}
153 | {gameState && ( 154 | <> 155 |
游戏状态: 156 | {gameState.round === 0 ? '准备中' : '进行中'} 157 |
158 |
胜利条件: 159 | 进行中 160 |
161 | 162 | )} 163 |
164 |
165 |
166 |
167 | ); 168 | }); -------------------------------------------------------------------------------- /docs/ai-sdk-zod-compatibility.md: -------------------------------------------------------------------------------- 1 | # AI SDK 和 Zod 兼容性指南 2 | 3 | ## 版本兼容性 4 | 5 | ### AI SDK 版本支持情况 6 | 7 | - **AI SDK 3.x**: 主要支持 Zod v3,对 Zod v4 支持有限 8 | - **AI SDK 4.x**: 开始改进对 Zod v4 的支持 9 | - **AI SDK 5.x**: 完全支持 Zod v3 和 v4,推荐使用 Zod v4 10 | 11 | ### 当前项目状态 (已更新) 12 | 13 | - 项目使用 AI SDK 5.0.2 ✅ 14 | - 全局使用 Zod v4.0.14 ✅ 15 | - **状态**: AI SDK 5.x 完全支持 Zod v4,不再有兼容性问题 16 | 17 | ## 常见错误及解决方案 18 | 19 | ### 1. ZodObject 类型不兼容错误 (AI SDK 5.x 已解决) 20 | 21 | **注意**: 在 AI SDK 5.x 中,这个问题已经被解决。以下内容供参考。 22 | 23 | **历史错误信息**: 24 | ``` 25 | Type 'ZodObject<...>' is not assignable to type 'ZodType<...>' 26 | ``` 27 | 28 | **AI SDK 5.x 解决方案**: 29 | 30 | 直接使用 Zod schema,无需包装器: 31 | ```typescript 32 | import { generateObject } from 'ai'; 33 | import { z } from 'zod'; 34 | 35 | const schema = z.object({ 36 | speech: z.string() 37 | }); 38 | 39 | const result = await generateObject({ 40 | model: this.getModel(), 41 | schema: schema, 42 | prompt: prompt 43 | }); 44 | ``` 45 | 46 | ### 2. generateObject API 更新 (AI SDK 5.x) 47 | 48 | **AI SDK 5.x 中的新 API**: 49 | ```typescript 50 | // AI SDK 5.x 支持的参数 51 | const result = await generateObject({ 52 | model: this.getModel(), 53 | schema: schema, // 直接使用 Zod schema 54 | prompt: prompt, 55 | maxTokens: 500, 56 | temperature: 0.8, 57 | // 新增参数 58 | system: "系统提示词", 59 | messages: [], // 支持消息历史 60 | seed: 123, // 确定性生成 61 | }); 62 | ``` 63 | 64 | **注意事项**: 65 | - 不再需要 `mode: 'json'` 66 | - 直接传入 Zod schema,无需包装 67 | - 支持更多配置选项 68 | 69 | ## AI SDK 5.x + Zod v4 实际示例 70 | 71 | ### 生成游戏角色对话 72 | 73 | ```typescript 74 | import { generateObject } from 'ai'; 75 | import { z } from 'zod'; 76 | 77 | // 定义响应 schema 78 | const SpeechResponseSchema = z.object({ 79 | speech: z.string().describe('角色的发言内容'), 80 | confidence: z.number().min(0).max(1).describe('发言的信心程度'), 81 | targetPlayer: z.number().optional().describe('指向的玩家 ID'), 82 | }); 83 | 84 | // 使用 AI SDK 5.x 生成 85 | const result = await generateObject({ 86 | model: this.getModel(), 87 | schema: SpeechResponseSchema, 88 | system: "你是一个狼人杀游戏中的角色", 89 | prompt: `作为${role},在${phase}阶段发言`, 90 | temperature: 0.8, 91 | }); 92 | 93 | // result.object 直接包含类型安全的数据 94 | console.log(result.object.speech); 95 | ``` 96 | 97 | ### 处理复杂游戏状态 98 | 99 | ```typescript 100 | // 使用 Zod v4 的新特性 101 | const GameStateSchema = z.object({ 102 | players: z.array(z.object({ 103 | id: z.number(), 104 | role: z.enum(['VILLAGER', 'WEREWOLF', 'SEER', 'WITCH']), 105 | alive: z.boolean(), 106 | // 使用 .transform() 进行数据转换 107 | lastSpeech: z.string().transform(str => str.trim()), 108 | })), 109 | // 使用 .catch() 处理解析失败 110 | phase: z.enum(['DAY', 'NIGHT']).catch('DAY'), 111 | // 使用 .brand() 创建品牌类型 112 | gameId: z.string().uuid().brand('GameId'), 113 | }); 114 | 115 | // 类型推断完美工作 116 | type GameState = z.infer; 117 | ``` 118 | 119 | ## Zod Schema 最佳实践 120 | 121 | ### 1. 使用 describe() 提供上下文 122 | 123 | ```typescript 124 | const schema = z.object({ 125 | name: z.string().describe('Name of a fictional person'), 126 | message: z.string().describe('Message. Do not use emojis or links.') 127 | }); 128 | ``` 129 | 130 | ### 2. 处理可选参数 (Zod v4) 131 | 132 | ```typescript 133 | // Zod v4 中 .optional() 完全支持 134 | const schema = z.object({ 135 | workdir: z.string().optional(), // ✅ 在 Zod v4 中正常工作 136 | nullable: z.string().nullable(), // 允许 null 值 137 | nullish: z.string().nullish(), // 允许 null 或 undefined 138 | }); 139 | 140 | // 带默认值 141 | const withDefaults = z.object({ 142 | port: z.number().default(3000), 143 | host: z.string().default('localhost') 144 | }); 145 | ``` 146 | 147 | ### 3. 复杂的 transform 操作 148 | 149 | ```typescript 150 | // 带 transform 的 schema 可能需要特殊处理 151 | export const SpeechResponseSchema = z.object({ 152 | speech: z.string().transform(val => { 153 | try { 154 | const parsed = JSON.parse(val); 155 | if (parsed.speech_content) return parsed.speech_content; 156 | if (parsed.statement) return parsed.statement; 157 | if (typeof parsed === 'string') return parsed; 158 | return val; 159 | } catch { 160 | return val; 161 | } 162 | }) 163 | }); 164 | ``` 165 | 166 | ### 4. 递归 Schema (AI SDK 5.x) 167 | 168 | ```typescript 169 | import { z } from 'zod'; 170 | 171 | // AI SDK 5.x 直接支持递归 schema 172 | interface Category { 173 | name: string; 174 | subcategories: Category[]; 175 | } 176 | 177 | const categorySchema: z.ZodType = z.object({ 178 | name: z.string(), 179 | subcategories: z.lazy(() => categorySchema.array()), 180 | }); 181 | 182 | // 直接使用,无需包装 183 | const result = await generateObject({ 184 | model: this.getModel(), 185 | schema: z.object({ 186 | category: categorySchema, 187 | }), 188 | prompt: "生成一个分类树" 189 | }); 190 | ``` 191 | 192 | ## 调试技巧 193 | 194 | ### 1. 检查 Schema Shape 195 | 196 | ```typescript 197 | console.log('Schema shape:', JSON.stringify(SpeechResponseSchema.shape, null, 2)); 198 | ``` 199 | 200 | ### 2. 查看警告信息 201 | 202 | ```typescript 203 | const result = await generateObject({...}); 204 | console.log(result.warnings); // 查看兼容性警告 205 | ``` 206 | 207 | ### 3. AI SDK 5.x 新特性 208 | 209 | ```typescript 210 | // 流式对象生成 211 | import { streamObject } from 'ai'; 212 | 213 | const { partialObjectStream } = await streamObject({ 214 | model: this.getModel(), 215 | schema: schema, 216 | prompt: prompt, 217 | }); 218 | 219 | // 处理流式响应 220 | for await (const partialObject of partialObjectStream) { 221 | console.log(partialObject); 222 | } 223 | ``` 224 | 225 | ## 项目迁移建议 (已完成 ✅) 226 | 227 | ### 迁移成果 228 | 1. ✅ 已升级到 AI SDK 5.0.2 229 | 2. ✅ 统一使用 Zod v4.0.14 230 | 3. ✅ 移除了所有兼容性包装器 231 | 232 | ### AI SDK 5.x 主要优势 233 | 1. **完全兼容 Zod v4** - 无需特殊处理 234 | 2. **更好的类型推断** - TypeScript 支持更完善 235 | 3. **流式生成支持** - `streamObject`, `streamText` 236 | 4. **更丰富的配置** - 支持 system prompts, seed 等 237 | 238 | ### Zod v4 新特性 239 | 1. **更好的性能** - 解析速度提升 240 | 2. **改进的错误消息** - 更清晰的验证错误 241 | 3. **新的 API** - `.catch()`, `.pipe()`, `.brand()` 242 | 4. **更好的 TypeScript 支持** - 类型推断更准确 243 | 244 | ## 参考资源 245 | 246 | - [AI SDK 5.x 文档](https://sdk.vercel.ai/docs) 247 | - [Zod v4 文档](https://zod.dev) 248 | - [AI SDK 5.x 迁移指南](https://sdk.vercel.ai/docs/migrations/migrating-from-4-to-5) 249 | - [Zod v3 到 v4 迁移](https://github.com/colinhacks/zod/blob/main/MIGRATION.md) -------------------------------------------------------------------------------- /packages/player/src/prompts/night/index.ts: -------------------------------------------------------------------------------- 1 | import type { GameContext, PlayerContext, SeerContext, WitchContext } from '@ai-werewolf/types'; 2 | import { formatPlayerList, formatHistoryEvents } from '../utils'; 3 | import { Role } from '@ai-werewolf/types'; 4 | import type { PlayerServer } from '../../PlayerServer'; 5 | 6 | export function getWerewolfNightAction(playerServer: PlayerServer, context: GameContext): string { 7 | const playerList = formatPlayerList(context.alivePlayers); 8 | const historyEvents = formatHistoryEvents(['夜间行动阶段']); 9 | const teammates = playerServer.getTeammates()?.join('、') || '暂无队友信息'; 10 | 11 | // 添加游戏进度说明,防止AI幻觉 12 | const gameProgressInfo = context.round === 1 13 | ? `【重要提示】现在是第1轮夜间阶段,游戏刚刚开始: 14 | - 还没有任何白天发言记录 15 | - 还没有任何投票记录 16 | - 没有玩家暴露身份 17 | - 你的击杀决策应基于随机性或位置策略 18 | - 不要假设或编造不存在的玩家行为` 19 | : ''; 20 | 21 | return `你是${playerServer.getPlayerId()}号玩家,狼人杀游戏中的狼人角色。当前游戏状态: 22 | - 存活玩家: [${playerList}] 23 | - 你的狼人队友ID: [${teammates}] 24 | - 当前轮次: 第${context.round}轮 25 | - 历史事件: ${historyEvents} 26 | 27 | ${gameProgressInfo} 28 | 29 | 作为狼人,你需要决定: 30 | - action: 固定为'kill' 31 | - target: 要击杀的目标玩家ID(数字) 32 | - reason: 选择该目标的详细理由 33 | 34 | 击杀策略建议: 35 | 1. 第1轮时基于位置或随机选择目标 36 | 2. 后续轮次优先击杀对狼人威胁最大的玩家(如预言家、女巫、守卫) 37 | 3. 避免在早期暴露团队 38 | 4. 与队友协调选择目标 39 | 40 | 请分析当前局势并选择最佳击杀目标。`; 41 | } 42 | 43 | export function getSeerNightAction(playerServer: PlayerServer, context: SeerContext): string { 44 | const playerList = formatPlayerList(context.alivePlayers); 45 | const historyEvents = formatHistoryEvents(['夜间行动阶段']); 46 | const checkInfo = context.investigatedPlayers ? Object.values(context.investigatedPlayers) 47 | .map((investigation) => { 48 | const investigationData = investigation as { target: number; isGood: boolean }; 49 | return `玩家${investigationData.target}是${investigationData.isGood ? '好人' : '狼人'}`; 50 | }) 51 | .join(',') : '暂无查验结果'; 52 | 53 | // 添加游戏进度说明,防止AI幻觉 54 | const gameProgressInfo = context.round === 1 55 | ? `【重要提示】现在是第1轮夜间阶段,游戏刚刚开始: 56 | - 还没有任何白天发言记录 57 | - 还没有任何投票记录 58 | - 你只能基于随机性或位置选择查验目标 59 | - 不要假设或编造不存在的玩家行为` 60 | : ''; 61 | 62 | return `你是${playerServer.getPlayerId()}号玩家,狼人杀游戏中的预言家角色。当前游戏状态: 63 | - 存活玩家: [${playerList}] 64 | - 当前轮次: 第${context.round}轮 65 | - 历史事件: ${historyEvents} 66 | - 已查验结果: ${checkInfo} 67 | 68 | ${gameProgressInfo} 69 | 70 | 作为预言家,你需要决定: 71 | - action: 固定为'investigate' 72 | - target: 要查验的目标玩家ID(数字,不能是${playerServer.getPlayerId()}) 73 | - reason: 选择该玩家的理由 74 | 75 | 查验策略建议: 76 | 1. 【重要】不能查验自己(${playerServer.getPlayerId()}号玩家) 77 | 2. 第1轮时基于位置或随机选择其他玩家 78 | 3. 后续轮次优先查验行为可疑的玩家 79 | 4. 避免查验已经暴露身份的玩家 80 | 5. 考虑查验结果对白天发言的影响 81 | 82 | 请分析当前局势并选择最佳查验目标。`; 83 | } 84 | 85 | export function getWitchNightAction(playerServer: PlayerServer, context: WitchContext): string { 86 | const playerList = formatPlayerList(context.alivePlayers); 87 | const historyEvents = formatHistoryEvents(['夜间行动阶段']); 88 | const potionInfo = context.potionUsed ? 89 | `解药${context.potionUsed.heal ? '已用' : '可用'},毒药${context.potionUsed.poison ? '已用' : '可用'}` 90 | : '解药可用,毒药可用'; 91 | 92 | // 添加游戏进度说明,防止AI幻觉 93 | const gameProgressInfo = context.round === 1 94 | ? `【重要提示】现在是第1轮夜间阶段,游戏刚刚开始: 95 | - 还没有任何白天发言记录 96 | - 还没有任何投票记录 97 | - 你只知道当前存活的玩家和今晚被杀的玩家 98 | - 请基于当前已知信息做决策,不要假设或编造不存在的信息` 99 | : ''; 100 | 101 | return `你是${playerServer.getPlayerId()}号玩家,狼人杀游戏中的女巫角色。当前游戏状态: 102 | - 存活玩家: [${playerList}] 103 | - 当前轮次: 第${context.round}轮 104 | - 今晚被杀玩家ID: ${context.killedTonight || 0} (0表示无人被杀) 105 | - 历史事件: ${historyEvents} 106 | 107 | ${gameProgressInfo} 108 | 109 | 你的药水使用情况: 110 | ${potionInfo} 111 | 112 | 作为女巫,你需要决定: 113 | 1. 是否使用解药救人(healTarget: 被杀玩家的ID或0表示不救) 114 | 2. 是否使用毒药毒人(poisonTarget: 要毒的玩家ID或0表示不毒) 115 | 3. action: 'using'(使用任意药水)或'idle'(不使用药水) 116 | 117 | 注意: 118 | - 如果救人,healTarget设为被杀玩家的ID 119 | - 如果毒人,poisonTarget设为目标玩家的ID 120 | - 如果都不使用,action设为'idle',两个target都设为0 121 | - 请为每个决定提供详细的理由(healReason和poisonReason) 122 | - 第1轮夜间时,你的决策理由应该基于:被杀玩家的身份、药水的战略价值、随机性等,而不是基于不存在的"白天发言"`; 123 | } 124 | 125 | export function getGuardNightAction(playerServer: PlayerServer, context: PlayerContext): string { 126 | const playerId = playerServer.getPlayerId(); 127 | const params = { 128 | playerId, 129 | role: playerServer.getRole(), 130 | currentRound: context.round, 131 | alivePlayers: context.alivePlayers, 132 | historyEvents: [], 133 | guardHistory: [] as string[] 134 | }; 135 | const playerList = formatPlayerList(params.alivePlayers); 136 | const historyEvents = formatHistoryEvents(params.historyEvents); 137 | const guardInfo = params.guardHistory?.join(',') || '第1夜守护玩家A,第2夜守护玩家B'; 138 | 139 | return `你是${playerServer.getPlayerId()}号玩家,狼人杀游戏中的守卫角色。当前游戏状态: 140 | - 存活玩家: [${playerList}] 141 | - 当前轮次: 第${context.round}轮 142 | - 历史事件: ${historyEvents} 143 | - 你的守护记录: ${guardInfo} 144 | 145 | 作为守卫,你的任务是: 146 | 1. 选择一名玩家进行守护 147 | 2. 保护可能的神职角色 148 | 3. 避免连续守护同一玩家 149 | 150 | 请分析当前局势,特别是: 151 | - 哪些玩家可能是神职角色,需要优先保护? 152 | - 狼人可能会选择击杀谁? 153 | - 如何在白天发言中隐藏身份?`; 154 | } 155 | 156 | export function getHunterDeathAction(playerServer: PlayerServer, context: PlayerContext, killedBy: 'werewolf' | 'vote' | 'poison'): string { 157 | const playerId = playerServer.getPlayerId(); 158 | const params = { 159 | playerId, 160 | role: playerServer.getRole(), 161 | currentRound: context.round, 162 | alivePlayers: context.alivePlayers, 163 | historyEvents: [], 164 | killedBy 165 | }; 166 | const playerList = formatPlayerList(params.alivePlayers); 167 | const killedByInfo = params.killedBy === 'werewolf' ? '狼人击杀' : 168 | params.killedBy === 'vote' ? '投票放逐' : '女巫毒杀'; 169 | 170 | return `你是${playerServer.getPlayerId()}号玩家,狼人杀游戏中的猎人角色。当前游戏状态: 171 | - 存活玩家: [${playerList}] 172 | - 你被${killedByInfo} 173 | - 当前轮次: 第${context.round}轮 174 | 175 | 作为猎人,你的决策是: 176 | 1. 选择一名玩家开枪击杀 177 | 2. 优先击杀最可疑的狼人 178 | 3. 避免误伤好人 179 | 4. 最大化好人阵营收益 180 | 181 | 请分析当前局势,特别是: 182 | - 哪些玩家最可疑,最可能是狼人? 183 | - 根据之前的发言和行为,谁最值得击杀? 184 | - 如何避免误伤神职角色?`; 185 | } 186 | 187 | // 工厂函数 - 统一使用 PlayerServer 和 GameContext 188 | export function getRoleNightAction(playerServer: PlayerServer, context: GameContext): string { 189 | const role = playerServer.getRole(); 190 | const playerId = playerServer.getPlayerId(); 191 | 192 | if (!role || playerId === undefined) { 193 | throw new Error('PlayerServer must have role and playerId set'); 194 | } 195 | 196 | switch (role) { 197 | case Role.VILLAGER: 198 | throw new Error('Villager has no night action, should be skipped'); 199 | case Role.WEREWOLF: { 200 | return getWerewolfNightAction(playerServer, context as PlayerContext); 201 | } 202 | case Role.SEER: { 203 | return getSeerNightAction(playerServer, context as SeerContext); 204 | } 205 | case Role.WITCH: { 206 | return getWitchNightAction(playerServer, context as WitchContext); 207 | } 208 | default: 209 | throw new Error(`Unknown role: ${role}`); 210 | } 211 | } -------------------------------------------------------------------------------- /packages/game-master-vite/src/lib/Player.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type PlayerId, 3 | Role, 4 | type PlayerContext, 5 | type WitchContext, 6 | type SeerContext, 7 | type WerewolfAbilityResponse, 8 | type WitchAbilityResponse, 9 | type SeerAbilityResponse, 10 | WerewolfNightActionSchema, 11 | WitchNightActionSchema, 12 | SeerNightActionSchema, 13 | SpeechResponseSchema, 14 | VotingResponseSchema, 15 | type SpeechResponseType, 16 | type VotingResponseType 17 | } from '@ai-werewolf/types'; 18 | import { PlayerAPIClient } from './PlayerAPIClient'; 19 | import { GameMaster } from './GameMaster'; 20 | import { playersToInfo } from './utils'; 21 | 22 | // 基础 Player 抽象类 23 | export abstract class BasePlayer { 24 | gameId: string; 25 | id: PlayerId; 26 | apiClient: PlayerAPIClient; 27 | position: number; 28 | isAlive: boolean; 29 | abstract role: Role; 30 | 31 | constructor(gameId: string, id: PlayerId, apiClient: PlayerAPIClient, position: number) { 32 | this.gameId = gameId; 33 | this.id = id; 34 | this.apiClient = apiClient; 35 | this.position = position; 36 | this.isAlive = true; 37 | } 38 | 39 | async useAbility(gameMaster: GameMaster): Promise{ 40 | const abilityParams = this.buildContext(gameMaster); 41 | return this.apiClient.useAbility(abilityParams); 42 | }; 43 | 44 | async vote(gameMaster: GameMaster): Promise { 45 | const voteParams = this.buildContext(gameMaster); 46 | const response = await this.apiClient.vote(voteParams); 47 | 48 | if (response) { 49 | // 使用 zod 验证返回结果 50 | const validatedResponse = VotingResponseSchema.parse(response); 51 | return validatedResponse; 52 | } 53 | 54 | return null; 55 | } 56 | 57 | async speak(gameMaster: GameMaster): Promise { 58 | const speechParams = this.buildContext(gameMaster); 59 | const response = await this.apiClient.speak(speechParams); 60 | 61 | if (response) { 62 | // 使用 zod 验证返回结果 63 | const validatedResponse = SpeechResponseSchema.parse(response); 64 | return validatedResponse; 65 | } 66 | 67 | return null; 68 | } 69 | 70 | async startGame(teammates: PlayerId[]): Promise { 71 | return this.apiClient.startGame({ 72 | gameId: this.gameId, 73 | role: this.role, 74 | playerId: this.id, 75 | teammates 76 | }); 77 | } 78 | 79 | protected buildContext(gameMaster: GameMaster):PlayerContext { 80 | return { 81 | round:gameMaster.round, 82 | currentPhase:gameMaster.currentPhase, 83 | alivePlayers: playersToInfo(gameMaster.alivePlayers), 84 | allSpeeches: gameMaster.getSpeeches(), 85 | allVotes: gameMaster.allVotes 86 | }; 87 | } 88 | } 89 | 90 | // 村民类 91 | export class VillagerPlayer extends BasePlayer { 92 | role: Role.VILLAGER = Role.VILLAGER; 93 | 94 | async useAbility(_gameMaster: GameMaster): Promise { 95 | // 村民没有特殊能力 96 | return null; 97 | } 98 | } 99 | 100 | // 狼人类 101 | export class WerewolfPlayer extends BasePlayer { 102 | role: Role.WEREWOLF = Role.WEREWOLF; 103 | 104 | async useAbility(gameMaster: GameMaster): Promise { 105 | const response = await super.useAbility(gameMaster); 106 | 107 | if (response) { 108 | // 使用 zod 验证返回结果 109 | const validatedResponse = WerewolfNightActionSchema.parse(response); 110 | return validatedResponse; 111 | } 112 | 113 | return null; 114 | } 115 | 116 | protected buildContext(gameMaster: GameMaster) { 117 | return { 118 | ...super.buildContext(gameMaster), 119 | } 120 | } 121 | } 122 | 123 | // 女巫类 124 | export class WitchPlayer extends BasePlayer { 125 | role: Role.WITCH = Role.WITCH; 126 | healUsedOn: number = 0; 127 | poisonUsedOn: number = 0; 128 | 129 | hasHealPotion(): boolean { 130 | return this.healUsedOn === 0; 131 | } 132 | 133 | hasPoisonPotion(): boolean { 134 | return this.poisonUsedOn === 0; 135 | } 136 | 137 | async useAbility(gameMaster: GameMaster): Promise { 138 | const response = await super.useAbility(gameMaster); 139 | 140 | if (response) { 141 | // 使用 zod 验证返回结果 142 | const validatedResponse = WitchNightActionSchema.parse(response); 143 | return validatedResponse; 144 | } 145 | 146 | return null; 147 | } 148 | 149 | protected buildContext(gameMaster: GameMaster): WitchContext { 150 | return { 151 | ...super.buildContext(gameMaster), 152 | killedTonight: gameMaster.nightTemp?.werewolfTarget, 153 | potionUsed: { 154 | heal: this.healUsedOn > 0, 155 | poison: this.poisonUsedOn > 0 156 | } 157 | }; 158 | } 159 | 160 | getAbilityStatus() { 161 | return { 162 | healUsed: this.healUsedOn > 0, 163 | healUsedOn: this.healUsedOn, 164 | poisonUsed: this.poisonUsedOn > 0, 165 | poisonUsedOn: this.poisonUsedOn, 166 | canHeal: this.healUsedOn === 0, 167 | canPoison: this.poisonUsedOn === 0 168 | }; 169 | } 170 | } 171 | 172 | // 预言家类 173 | export class SeerPlayer extends BasePlayer { 174 | role: Role.SEER = Role.SEER; 175 | investigatedPlayers: string[] = []; 176 | 177 | async useAbility(gameMaster: GameMaster): Promise { 178 | const response = await super.useAbility(gameMaster); 179 | 180 | if (response) { 181 | // 使用 zod 验证返回结果 182 | const validatedResponse = SeerNightActionSchema.parse(response); 183 | return validatedResponse; 184 | } 185 | 186 | return null; 187 | } 188 | 189 | protected buildContext(gameMaster: GameMaster): SeerContext { 190 | return { 191 | ...super.buildContext(gameMaster), 192 | investigatedPlayers: gameMaster.getInvestigatedPlayers() 193 | }; 194 | } 195 | } 196 | 197 | // 联合类型 198 | export type Player = VillagerPlayer | WerewolfPlayer | WitchPlayer | SeerPlayer; 199 | 200 | // 类型守卫函数 201 | export function isWerewolfPlayer(player: Player): player is WerewolfPlayer { 202 | return player.role === Role.WEREWOLF; 203 | } 204 | 205 | export function isWitchPlayer(player: Player): player is WitchPlayer { 206 | return player.role === Role.WITCH; 207 | } 208 | 209 | export function isSeerPlayer(player: Player): player is SeerPlayer { 210 | return player.role === Role.SEER; 211 | } 212 | 213 | export function isVillagerPlayer(player: Player): player is VillagerPlayer { 214 | return player.role === Role.VILLAGER; 215 | } 216 | 217 | // 工厂函数创建 Player 218 | export function createPlayer( 219 | role: Role, 220 | playerId: PlayerId, 221 | apiClient: PlayerAPIClient, 222 | gameId: string, 223 | position: number 224 | ): Player { 225 | switch (role) { 226 | case Role.WEREWOLF: 227 | return new WerewolfPlayer(gameId, playerId, apiClient, position); 228 | 229 | case Role.WITCH: 230 | return new WitchPlayer(gameId, playerId, apiClient, position); 231 | 232 | case Role.SEER: 233 | return new SeerPlayer(gameId, playerId, apiClient, position); 234 | 235 | case Role.VILLAGER: 236 | default: 237 | return new VillagerPlayer(gameId, playerId, apiClient, position); 238 | } 239 | } 240 | 241 | 242 | -------------------------------------------------------------------------------- /packages/player/src/prompts/speech/index.ts: -------------------------------------------------------------------------------- 1 | import type { PlayerContext, SeerContext, WitchContext, GameContext } from '@ai-werewolf/types'; 2 | import { Role } from '@ai-werewolf/types'; 3 | import { formatPlayerList, formatSpeechHistory } from '../utils'; 4 | import type { PlayerServer } from '../../PlayerServer'; 5 | 6 | // 通用的 JSON 格式说明函数 7 | function getSpeechFormatInstruction(role: Role): string { 8 | let roleSpecificTip = ''; 9 | 10 | switch (role) { 11 | case Role.VILLAGER: 12 | roleSpecificTip = '要符合村民身份,分析逻辑,不要暴露太多信息。'; 13 | break; 14 | case Role.WEREWOLF: 15 | roleSpecificTip = '要伪装成好人,避免暴露狼人身份,可以适当误导其他玩家。'; 16 | break; 17 | case Role.SEER: 18 | roleSpecificTip = '要合理传达查验信息,但要避免过早暴露身份被狼人针对。'; 19 | break; 20 | case Role.WITCH: 21 | roleSpecificTip = '要隐藏女巫身份,可以暗示重要信息但不要直接暴露。'; 22 | break; 23 | default: 24 | roleSpecificTip = '要符合你的角色身份。'; 25 | } 26 | 27 | return ` 28 | 请返回JSON格式,包含以下字段: 29 | - speech: 你的发言内容(30-80字的自然对话,其他玩家都能听到) 30 | 31 | 注意:speech字段是你的公开发言,${roleSpecificTip}`; 32 | } 33 | 34 | export function getVillagerSpeech(playerServer: PlayerServer, context: PlayerContext): string { 35 | const playerId = playerServer.getPlayerId(); 36 | if (playerId === undefined) { 37 | throw new Error('PlayerServer must have playerId set'); 38 | } 39 | const personalityPrompt = playerServer.getPersonalityPrompt(); 40 | const params = { 41 | playerId: playerId.toString(), 42 | playerName: `Player${playerId}`, 43 | role: 'villager', 44 | speechHistory: Object.values(context.allSpeeches).flat(), 45 | customContent: personalityPrompt, 46 | suspiciousPlayers: [] as string[], 47 | logicalContradictions: '' 48 | }; 49 | const playerList = formatPlayerList(context.alivePlayers); 50 | const speechSummary = formatSpeechHistory(params.speechHistory); 51 | const suspiciousInfo = params.suspiciousPlayers?.join('、') || '暂无明确可疑目标'; 52 | 53 | const customContent = params.customContent || ''; 54 | 55 | return `你是${params.playerId}号玩家,狼人杀游戏中的村民角色,性格特点:正直、逻辑清晰。当前游戏状态: 56 | - 存活玩家: [${playerList}] 57 | - 当前发言轮次: 第${context.round}轮 58 | - 历史发言摘要: ${speechSummary} 59 | 60 | ${customContent} 61 | 62 | 作为村民,你的发言策略: 63 | 1. 分析玩家发言逻辑,指出矛盾点 64 | 2. 独立思考,不盲从他人 65 | 3. 保护可能的神职角色 66 | 67 | 当前局势分析: 68 | - 可疑玩家: ${suspiciousInfo} 69 | - 逻辑矛盾点: ${params.logicalContradictions || '暂无明显矛盾'} 70 | ${getSpeechFormatInstruction(Role.VILLAGER)}`; 71 | } 72 | 73 | export function getWerewolfSpeech(playerServer: PlayerServer, context: PlayerContext): string { 74 | const playerId = playerServer.getPlayerId(); 75 | if (playerId === undefined) { 76 | throw new Error('PlayerServer must have playerId set'); 77 | } 78 | const teammateIds = playerServer.getTeammates(); 79 | const personalityPrompt = playerServer.getPersonalityPrompt(); 80 | const params = { 81 | playerId: playerId.toString(), 82 | playerName: `Player${playerId}`, 83 | role: 'werewolf', 84 | speechHistory: Object.values(context.allSpeeches).flat(), 85 | teammates: teammateIds?.map(id => id.toString()), 86 | customContent: personalityPrompt, 87 | suspiciousPlayers: [] as string[], 88 | killedLastNight: 'unknown' 89 | }; 90 | const playerList = formatPlayerList(context.alivePlayers); 91 | const speechSummary = formatSpeechHistory(params.speechHistory); 92 | const teammateList = params.teammates?.join('、') || '暂无队友信息'; 93 | const killedInfo = params.killedLastNight || '无人被杀'; 94 | 95 | const customContent = params.customContent || ''; 96 | 97 | return `你是${params.playerId}号玩家,狼人杀游戏中的狼人角色,性格特点:狡猾、善于伪装。当前游戏状态: 98 | - 存活玩家: [${playerList}] 99 | - 当前发言轮次: 第${context.round}轮 100 | - 历史发言摘要: ${speechSummary} 101 | - 你的狼人队友: [${teammateList}] 102 | 103 | ${customContent} 104 | 105 | 作为狼人,你的发言策略: 106 | 1. 伪装成好人,避免暴露 107 | 2. 引导好人投票错误目标 108 | 3. 保护队友,必要时为队友辩护 109 | 4. 制造混乱,转移注意力 110 | 5. 考虑自爆策略(如必要) 111 | 112 | 当前局势分析: 113 | - 今晚被杀的玩家: ${killedInfo} 114 | - 当前投票情况: ${speechSummary} 115 | - 需要重点关注的玩家: ${params.suspiciousPlayers?.join('、') || '暂无'} 116 | ${getSpeechFormatInstruction(Role.WEREWOLF)}`; 117 | } 118 | 119 | export function getSeerSpeech(playerServer: PlayerServer, context: SeerContext): string { 120 | const playerId = playerServer.getPlayerId(); 121 | if (playerId === undefined) { 122 | throw new Error('PlayerServer must have playerId set'); 123 | } 124 | const personalityPrompt = playerServer.getPersonalityPrompt(); 125 | const params = { 126 | playerId: playerId.toString(), 127 | playerName: `Player${playerId}`, 128 | role: 'seer', 129 | speechHistory: Object.values(context.allSpeeches).flat(), 130 | customContent: personalityPrompt, 131 | suspiciousPlayers: [] as string[] 132 | }; 133 | const playerList = formatPlayerList(context.alivePlayers); 134 | const speechSummary = formatSpeechHistory(params.speechHistory); 135 | 136 | // 处理查验结果 137 | let checkInfo = '暂无查验结果'; 138 | if (context.investigatedPlayers && Object.keys(context.investigatedPlayers).length > 0) { 139 | const results: string[] = []; 140 | for (const investigation of Object.values(context.investigatedPlayers)) { 141 | const investigationData = investigation as { target: number; isGood: boolean }; 142 | results.push(`${investigationData.target}号是${investigationData.isGood ? '好人' : '狼人'}`); 143 | } 144 | checkInfo = results.join(','); 145 | } 146 | 147 | const customContent = params.customContent || ''; 148 | 149 | return `你是${params.playerId}号玩家,狼人杀游戏中的预言家角色,性格特点:理性、分析能力强。当前游戏状态: 150 | - 存活玩家: [${playerList}] 151 | - 当前发言轮次: 第${context.round}轮 152 | - 历史发言摘要: ${speechSummary} 153 | - 你的查验结果: ${checkInfo} 154 | 155 | ${customContent} 156 | 157 | 作为预言家,你的发言策略: 158 | 1. 在适当时机公布身份(通常在确认2只狼人后) 159 | 2. 清晰传达查验信息 160 | 3. 分析玩家行为逻辑,指出可疑点 161 | 4. 避免过早暴露导致被狼人针对 162 | 163 | 当前局势分析: 164 | - 可疑玩家: ${params.suspiciousPlayers?.join('、') || '根据查验结果确定'} 165 | - 需要保护的玩家: 暂无 166 | ${getSpeechFormatInstruction(Role.SEER)}`; 167 | } 168 | 169 | export function getWitchSpeech(playerServer: PlayerServer, context: WitchContext): string { 170 | const playerId = playerServer.getPlayerId(); 171 | if (playerId === undefined) { 172 | throw new Error('PlayerServer must have playerId set'); 173 | } 174 | const personalityPrompt = playerServer.getPersonalityPrompt(); 175 | const params = { 176 | playerId: playerId.toString(), 177 | playerName: `Player${playerId}`, 178 | role: 'witch', 179 | speechHistory: Object.values(context.allSpeeches).flat(), 180 | customContent: personalityPrompt, 181 | suspiciousPlayers: [] as string[] 182 | }; 183 | const playerList = formatPlayerList(context.alivePlayers); 184 | const speechSummary = formatSpeechHistory(params.speechHistory); 185 | const potionInfo = context.potionUsed ? 186 | `解药${context.potionUsed.heal ? '已用' : '可用'},毒药${context.potionUsed.poison ? '已用' : '可用'}` 187 | : '解药可用,毒药可用'; 188 | const killedInfo = context.killedTonight ? `${context.killedTonight}号` : '无人被杀'; 189 | 190 | const customContent = params.customContent || ''; 191 | 192 | return `你是${params.playerId}号玩家,狼人杀游戏中的女巫角色,性格特点:谨慎、观察力强。当前游戏状态: 193 | - 存活玩家: [${playerList}] 194 | - 当前发言轮次: 第${context.round}轮 195 | - 历史发言摘要: ${speechSummary} 196 | - 你的药水使用情况: ${potionInfo} 197 | 198 | ${customContent} 199 | 200 | 作为女巫,你的发言策略: 201 | 1. 隐藏身份,避免被狼人发现 202 | 2. 暗示自己有重要信息,但不要直接暴露 203 | 3. 引导好人投票正确目标 204 | 4. 在必要时可以半报身份 205 | 206 | 当前局势分析: 207 | - 今晚被杀的玩家: ${killedInfo}(你${context.potionUsed?.heal ? '已救' : '未救'}) 208 | - 是否使用毒药: ${context.potionUsed?.poison ? '已使用' : '未使用'} 209 | - 可疑玩家: ${params.suspiciousPlayers?.join('、') || '暂无明确目标'} 210 | ${getSpeechFormatInstruction(Role.WITCH)}`; 211 | } 212 | 213 | 214 | // 工厂函数 215 | export function getRoleSpeech(playerServer: PlayerServer, context: GameContext): string { 216 | const role = playerServer.getRole(); 217 | 218 | if (!role) { 219 | throw new Error('PlayerServer must have role set'); 220 | } 221 | 222 | switch (role) { 223 | case Role.VILLAGER: 224 | return getVillagerSpeech(playerServer, context as PlayerContext); 225 | case Role.WEREWOLF: 226 | return getWerewolfSpeech(playerServer, context as PlayerContext); 227 | case Role.SEER: 228 | return getSeerSpeech(playerServer, context as SeerContext); 229 | case Role.WITCH: 230 | return getWitchSpeech(playerServer, context as WitchContext); 231 | default: 232 | throw new Error(`Unknown role: ${role}`); 233 | } 234 | } -------------------------------------------------------------------------------- /packages/player/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | // 初始化 Langfuse OpenTelemetry (必须在其他导入之前) 4 | import { initializeLangfuse, shutdownLangfuse, langfuse } from './services/langfuse'; 5 | initializeLangfuse(); 6 | 7 | import express from 'express'; 8 | import cors from 'cors'; 9 | import { PlayerServer } from './PlayerServer'; 10 | import { ConfigLoader } from './config/PlayerConfig'; 11 | import { 12 | VotingResponseSchema, 13 | SpeechResponseSchema, 14 | LastWordsResponseSchema 15 | } from './validation'; 16 | import type { 17 | StartGameParams, 18 | PlayerContext, 19 | WitchContext, 20 | SeerContext 21 | } from '@ai-werewolf/types'; 22 | 23 | // 解析命令行参数 24 | const args = process.argv.slice(2); 25 | const configArg = args.find(arg => arg.startsWith('--config=')); 26 | const configPath = configArg ? configArg.split('=')[1] : undefined; 27 | 28 | // 加载配置 29 | const configLoader = new ConfigLoader(configPath); 30 | const config = configLoader.getConfig(); 31 | 32 | // 验证配置 33 | if (!configLoader.validateConfig()) { 34 | console.error('❌ 配置验证失败,程序退出'); 35 | process.exit(1); 36 | } 37 | 38 | // 打印配置信息 39 | configLoader.printConfig(); 40 | 41 | // 调试:打印Langfuse环境变量 42 | console.log('\n🔍 Langfuse环境变量调试:'); 43 | console.log(` LANGFUSE_SECRET_KEY: ${process.env.LANGFUSE_SECRET_KEY ? '已设置 (长度: ' + process.env.LANGFUSE_SECRET_KEY.length + ')' : '未设置'}`); 44 | console.log(` LANGFUSE_PUBLIC_KEY: ${process.env.LANGFUSE_PUBLIC_KEY ? '已设置 (长度: ' + process.env.LANGFUSE_PUBLIC_KEY.length + ')' : '未设置'}`); 45 | console.log(` LANGFUSE_BASEURL: ${process.env.LANGFUSE_BASEURL || '未设置 (将使用默认值)'}`); 46 | console.log(); 47 | 48 | const app = express(); 49 | app.use(cors()); 50 | app.use(express.json()); 51 | 52 | const playerServer = new PlayerServer(config); 53 | const port = config.server.port; 54 | const host = config.server.host; 55 | 56 | // 辅助函数:在AI请求后刷新Langfuse数据 57 | async function flushLangfuseData() { 58 | try { 59 | if (process.env.LANGFUSE_SECRET_KEY && process.env.LANGFUSE_PUBLIC_KEY) { 60 | await langfuse.flushAsync(); 61 | if (config.logging.enabled) { 62 | console.log('📊 Langfuse数据已刷新'); 63 | } 64 | } 65 | } catch (error) { 66 | console.error('❌ Langfuse刷新失败:', error); 67 | } 68 | } 69 | 70 | app.post('/api/player/start-game', async (req, res) => { 71 | try { 72 | console.log('\n=== START GAME REQUEST ==='); 73 | console.log('Request body:', JSON.stringify(req.body, null, 2)); 74 | 75 | // 直接使用 StartGameParams 类型,不验证输入 76 | const params: StartGameParams = req.body; 77 | // 直接使用params,不需要解构 78 | 79 | await playerServer.startGame(params); 80 | 81 | const response = { 82 | message: 'Game started successfully', 83 | langfuseEnabled: true // 总是启用,使用gameId作为trace 84 | }; 85 | 86 | console.log('Response:', JSON.stringify(response, null, 2)); 87 | console.log('=== END START GAME REQUEST ===\n'); 88 | 89 | res.json(response); 90 | } catch (error) { 91 | console.error('Start game error:', error); 92 | res.status(500).json({ error: 'Failed to start game' }); 93 | } 94 | }); 95 | 96 | app.post('/api/player/speak', async (req, res) => { 97 | try { 98 | console.log('\n=== SPEAK REQUEST ==='); 99 | console.log('Request body:', JSON.stringify(req.body, null, 2)); 100 | 101 | // 直接使用 PlayerContext 类型,不验证输入 102 | const context: PlayerContext = req.body; 103 | 104 | const speech = await playerServer.speak(context); 105 | 106 | // 刷新Langfuse数据 107 | await flushLangfuseData(); 108 | 109 | const response = SpeechResponseSchema.parse({ speech }); 110 | console.log('Response:', JSON.stringify(response, null, 2)); 111 | console.log('=== END SPEAK REQUEST ===\n'); 112 | 113 | res.json(response); 114 | } catch (error) { 115 | console.error('Speak error:', error); 116 | if (error instanceof Error && error.name === 'ZodError') { 117 | res.status(400).json({ error: 'Invalid response data', details: error }); 118 | } else { 119 | res.status(500).json({ error: 'Failed to generate speech' }); 120 | } 121 | } 122 | }); 123 | 124 | app.post('/api/player/vote', async (req, res) => { 125 | try { 126 | console.log('\n=== VOTE REQUEST ==='); 127 | console.log('Request body:', JSON.stringify(req.body, null, 2)); 128 | 129 | // 直接使用 PlayerContext 类型,不验证输入 130 | const context: PlayerContext = req.body; 131 | 132 | const voteResponse = await playerServer.vote(context); 133 | 134 | // 刷新Langfuse数据 135 | await flushLangfuseData(); 136 | 137 | const response = VotingResponseSchema.parse(voteResponse); 138 | console.log('Response:', JSON.stringify(response, null, 2)); 139 | console.log('=== END VOTE REQUEST ===\n'); 140 | 141 | res.json(response); 142 | } catch (error) { 143 | console.error('Vote error:', error); 144 | if (error instanceof Error && error.name === 'ZodError') { 145 | res.status(400).json({ error: 'Invalid response data', details: error }); 146 | } else { 147 | res.status(500).json({ error: 'Failed to generate vote' }); 148 | } 149 | } 150 | }); 151 | 152 | app.post('/api/player/use-ability', async (req, res) => { 153 | try { 154 | console.log('\n=== USE ABILITY REQUEST ==='); 155 | console.log('Request body:', JSON.stringify(req.body, null, 2)); 156 | 157 | // 直接使用类型,不验证输入 (可能是 PlayerContext, WitchContext, 或 SeerContext) 158 | const context: PlayerContext | WitchContext | SeerContext = req.body; 159 | 160 | const result = await playerServer.useAbility(context); 161 | 162 | // 刷新Langfuse数据 163 | await flushLangfuseData(); 164 | 165 | // 直接返回结果,不包装在 { result } 中 166 | console.log('Response:', JSON.stringify(result, null, 2)); 167 | console.log('=== END USE ABILITY REQUEST ===\n'); 168 | 169 | res.json(result); 170 | } catch (error) { 171 | console.error('Use ability error:', error); 172 | res.status(500).json({ error: 'Failed to use ability' }); 173 | } 174 | }); 175 | 176 | app.post('/api/player/last-words', async (req, res) => { 177 | try { 178 | console.log('\n=== LAST WORDS REQUEST ==='); 179 | console.log('Request body:', JSON.stringify(req.body, null, 2)); 180 | 181 | const lastWords = await playerServer.lastWords(); 182 | 183 | // 刷新Langfuse数据 184 | await flushLangfuseData(); 185 | 186 | const response = LastWordsResponseSchema.parse({ content: lastWords }); 187 | console.log('Response:', JSON.stringify(response, null, 2)); 188 | console.log('=== END LAST WORDS REQUEST ===\n'); 189 | 190 | res.json(response); 191 | } catch (error) { 192 | console.error('Last words error:', error); 193 | if (error instanceof Error && error.name === 'ZodError') { 194 | res.status(400).json({ error: 'Invalid response data', details: error }); 195 | } else { 196 | res.status(500).json({ error: 'Failed to generate last words' }); 197 | } 198 | } 199 | }); 200 | 201 | app.post('/api/player/status', (_req, res) => { 202 | try { 203 | const status = playerServer.getStatus(); 204 | const validatedStatus = status; // 不需要validation,直接返回status对象 205 | res.json(validatedStatus); 206 | } catch (error) { 207 | console.error('Status error:', error); 208 | if (error instanceof Error && error.name === 'ZodError') { 209 | res.status(500).json({ error: 'Invalid status data', details: error }); 210 | } else { 211 | res.status(500).json({ error: 'Failed to get status' }); 212 | } 213 | } 214 | }); 215 | 216 | app.listen(port, host, () => { 217 | console.log(`🚀 Player server running on ${host}:${port}`); 218 | if (configPath) { 219 | console.log(`📋 使用配置文件: ${configPath}`); 220 | } 221 | }); 222 | 223 | // 优雅关闭处理,确保 Langfuse 数据被正确刷新 224 | const gracefulShutdown = async (signal: string) => { 225 | console.log(`\n📊 收到 ${signal} 信号,正在关闭服务器并刷新 Langfuse 数据...`); 226 | 227 | try { 228 | // 刷新 Langfuse 追踪数据 229 | await shutdownLangfuse(); 230 | } catch (error) { 231 | console.error('❌ Langfuse 关闭时出错:', error); 232 | } 233 | 234 | console.log('👋 服务器已关闭'); 235 | process.exit(0); 236 | }; 237 | 238 | // 监听进程信号 239 | process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); 240 | process.on('SIGINT', () => gracefulShutdown('SIGINT')); 241 | 242 | // 处理未捕获的异常 243 | process.on('uncaughtException', async (error) => { 244 | console.error('💥 未捕获的异常:', error); 245 | await gracefulShutdown('uncaughtException'); 246 | }); 247 | 248 | process.on('unhandledRejection', async (reason, promise) => { 249 | console.error('💥 未处理的Promise拒绝:', reason, 'at:', promise); 250 | await gracefulShutdown('unhandledRejection'); 251 | }); -------------------------------------------------------------------------------- /packages/player/src/prompts/voting/index.ts: -------------------------------------------------------------------------------- 1 | import type { PlayerContext, SeerContext, WitchContext, GameContext } from '@ai-werewolf/types'; 2 | import { Role } from '@ai-werewolf/types'; 3 | import type { PlayerServer } from '../../PlayerServer'; 4 | 5 | function formatPlayerList(players: any[]): string { 6 | return players.map(p => p.name || p.id || p).join(', '); 7 | } 8 | 9 | function formatSpeechSummary(speeches: any[]): string { 10 | return speeches.map(s => `- ${s.playerId}: "${s.content}"`).join('\n'); 11 | } 12 | 13 | function formatCurrentVotes(votes: any[] | any): string { 14 | if (!votes) return '暂无投票'; 15 | 16 | // 如果是数组格式(旧格式) 17 | if (Array.isArray(votes)) { 18 | return votes.map(v => `${v.voter}投${v.target}`).join(','); 19 | } 20 | 21 | // 如果是 AllVotes 格式,提取所有轮次的投票 22 | const allVotes: string[] = []; 23 | for (const [round, roundVotes] of Object.entries(votes)) { 24 | if (Array.isArray(roundVotes)) { 25 | const roundVoteStr = roundVotes.map((v: any) => `第${round}轮: ${v.voterId}投${v.targetId}`); 26 | allVotes.push(...roundVoteStr); 27 | } 28 | } 29 | 30 | return allVotes.length > 0 ? allVotes.join(',') : '暂无投票'; 31 | } 32 | 33 | export function getVillagerVoting(playerServer: PlayerServer, context: PlayerContext): string { 34 | const playerId = playerServer.getPlayerId(); 35 | const params = { 36 | playerId: playerId?.toString() || '0', 37 | role: playerServer.getRole() || Role.VILLAGER, 38 | alivePlayers: context.alivePlayers, 39 | speechSummary: Object.values(context.allSpeeches).flat(), 40 | currentVotes: [] as any[], 41 | allVotes: context.allVotes, 42 | currentRound: context.round 43 | }; 44 | const playerList = formatPlayerList(params.alivePlayers); 45 | const speechSummary = formatSpeechSummary(params.speechSummary); 46 | const currentVotes = formatCurrentVotes(params.currentVotes); 47 | 48 | return `你是${params.playerId}号玩家,狼人杀游戏中的村民角色。当前投票环节: 49 | 50 | 存活玩家:[${playerList}] 51 | 今日发言摘要: 52 | ${speechSummary} 53 | 当前投票情况:${currentVotes} 54 | 55 | 作为村民,你的投票策略: 56 | 1. 优先投票给发言逻辑矛盾、行为可疑的玩家 57 | 2. 避免盲从,独立分析 58 | 3. 注意保护可能的神职角色 59 | 60 | 请返回你的投票决定,格式要求: 61 | - target: 你要投票的玩家ID(数字) 62 | - reason: 你投票的详细理由 63 | 64 | `; 65 | } 66 | 67 | export function getWerewolfVoting(playerServer: PlayerServer, context: PlayerContext): string { 68 | const playerId = playerServer.getPlayerId(); 69 | const teammateIds = playerServer.getTeammates(); 70 | const params = { 71 | playerId: playerId?.toString() || '0', 72 | role: playerServer.getRole() || Role.WEREWOLF, 73 | alivePlayers: context.alivePlayers, 74 | speechSummary: Object.values(context.allSpeeches).flat(), 75 | currentVotes: [] as any[], 76 | allVotes: context.allVotes, 77 | currentRound: context.round, 78 | teammates: teammateIds?.map(id => id.toString()) 79 | }; 80 | const playerList = formatPlayerList(params.alivePlayers); 81 | const speechSummary = formatSpeechSummary(params.speechSummary); 82 | const currentVotes = formatCurrentVotes(params.currentVotes); 83 | const teammates = params.teammates?.join('、') || '暂无队友信息'; 84 | 85 | return `你是${params.playerId}号玩家,狼人杀游戏中的狼人角色。当前投票环节: 86 | 87 | 存活玩家:[${playerList}] 88 | 你的狼人队友:${teammates} 89 | 今日发言摘要: 90 | ${speechSummary} 91 | 当前投票情况:${currentVotes} 92 | 93 | 作为狼人,你的投票策略: 94 | 1. 投票给最可能被放逐的好人 95 | 2. 保护队友,避免投票给队友 96 | 3. 必要时分票,避免狼人团队暴露 97 | 4. 制造好人之间的矛盾 98 | 99 | 请返回你的投票决定,格式要求: 100 | - target: 你要投票的玩家ID(数字) 101 | - reason: 你投票的详细理由 102 | 103 | `; 104 | } 105 | 106 | export function getSeerVoting(playerServer: PlayerServer, context: SeerContext): string { 107 | const playerId = playerServer.getPlayerId(); 108 | const params = { 109 | playerId: playerId?.toString() || '0', 110 | role: playerServer.getRole() || Role.SEER, 111 | alivePlayers: context.alivePlayers, 112 | speechSummary: Object.values(context.allSpeeches).flat(), 113 | currentVotes: [] as any[], 114 | allVotes: context.allVotes, 115 | currentRound: context.round, 116 | checkResults: Object.fromEntries( 117 | Object.entries(context.investigatedPlayers).map(([round, data]) => [ 118 | data.target.toString(), 119 | data.isGood ? 'good' as const : 'werewolf' as const 120 | ]) 121 | ) 122 | }; 123 | const playerList = formatPlayerList(params.alivePlayers); 124 | const speechSummary = formatSpeechSummary(params.speechSummary); 125 | const currentVotes = formatCurrentVotes(params.currentVotes); 126 | const checkInfo = params.checkResults ? Object.entries(params.checkResults) 127 | .map(([player, result]) => `- ${player}: ${result === 'good' ? '好人' : '狼人'}`) 128 | .join('\n') : '暂无查验结果'; 129 | 130 | return `你是${params.playerId}号玩家,狼人杀游戏中的预言家角色。当前投票环节: 131 | 132 | 存活玩家:[${playerList}] 133 | 今日发言摘要: 134 | ${speechSummary} 135 | 当前投票情况:${currentVotes} 136 | 137 | 你的查验结果: 138 | ${checkInfo} 139 | 140 | 作为预言家,你的投票策略: 141 | 1. 优先投票给你确认的狼人 142 | 2. 保护你确认的好人 143 | 3. 引导好人投票正确目标 144 | 4. 在身份公开后,发挥领导作用 145 | 146 | 请返回你的投票决定,格式要求: 147 | - target: 你要投票的玩家ID(数字) 148 | - reason: 你投票的详细理由 149 | 150 | `; 151 | } 152 | 153 | export function getWitchVoting(playerServer: PlayerServer, context: WitchContext): string { 154 | const playerId = playerServer.getPlayerId(); 155 | const params = { 156 | playerId: playerId?.toString() || '0', 157 | role: playerServer.getRole() || Role.WITCH, 158 | alivePlayers: context.alivePlayers, 159 | speechSummary: Object.values(context.allSpeeches).flat(), 160 | currentVotes: [] as any[], 161 | allVotes: context.allVotes, 162 | currentRound: context.round, 163 | potionUsed: context.potionUsed 164 | }; 165 | const playerList = formatPlayerList(params.alivePlayers); 166 | const speechSummary = formatSpeechSummary(params.speechSummary); 167 | const currentVotes = formatCurrentVotes(params.currentVotes); 168 | const potionInfo = params.potionUsed ? 169 | `解药${params.potionUsed.heal ? '已用' : '可用'},毒药${params.potionUsed.poison ? '已用' : '可用'}` 170 | : '解药已用,毒药可用'; 171 | 172 | return `你是${params.playerId}号玩家,狼人杀游戏中的女巫角色。当前投票环节: 173 | 174 | 存活玩家:[${playerList}] 175 | 今日发言摘要: 176 | ${speechSummary} 177 | 当前投票情况:${currentVotes} 178 | 你的药水使用情况:${potionInfo} 179 | 180 | 作为女巫,你的投票策略: 181 | 1. 分析玩家逻辑,投票给最可疑的玩家 182 | 2. 隐藏身份,避免被狼人发现 183 | 3. 在必要时可以暗示有重要信息 184 | 4. 考虑毒药使用的影响 185 | 186 | 请返回你的投票决定,格式要求: 187 | - target: 你要投票的玩家ID(数字) 188 | - reason: 你投票的详细理由 189 | 190 | `; 191 | } 192 | 193 | export function getGuardVoting(playerServer: PlayerServer, context: PlayerContext): string { 194 | const playerId = playerServer.getPlayerId(); 195 | const params = { 196 | playerId: playerId?.toString() || '0', 197 | role: playerServer.getRole() || Role.VILLAGER, 198 | alivePlayers: context.alivePlayers, 199 | speechSummary: Object.values(context.allSpeeches).flat(), 200 | currentVotes: [] as any[], 201 | allVotes: context.allVotes, 202 | currentRound: context.round, 203 | guardHistory: [] as string[] 204 | }; 205 | const playerList = formatPlayerList(params.alivePlayers); 206 | const speechSummary = formatSpeechSummary(params.speechSummary); 207 | const currentVotes = formatCurrentVotes(params.currentVotes); 208 | const guardInfo = params.guardHistory?.join(',') || '守护历史'; 209 | 210 | return `你是${params.playerId}号玩家,狼人杀游戏中的守卫角色。当前投票环节: 211 | 212 | 存活玩家:[${playerList}] 213 | 今日发言摘要: 214 | ${speechSummary} 215 | 当前投票情况:${currentVotes} 216 | 你的守护记录:${guardInfo} 217 | 218 | 作为守卫,你的投票策略: 219 | 1. 分析玩家逻辑,投票给最可疑的玩家 220 | 2. 隐藏身份,避免被狼人发现 221 | 3. 在必要时可以暗示有保护能力 222 | 4. 考虑守护对象的安全 223 | 224 | 请返回你的投票决定,格式要求: 225 | - target: 你要投票的玩家ID(数字) 226 | - reason: 你投票的详细理由 227 | 228 | `; 229 | } 230 | 231 | export function getHunterVoting(playerServer: PlayerServer, context: PlayerContext): string { 232 | const playerId = playerServer.getPlayerId(); 233 | const params = { 234 | playerId: playerId?.toString() || '0', 235 | role: playerServer.getRole() || Role.VILLAGER, 236 | alivePlayers: context.alivePlayers, 237 | speechSummary: Object.values(context.allSpeeches).flat(), 238 | currentVotes: [] as any[], 239 | allVotes: context.allVotes, 240 | currentRound: context.round 241 | }; 242 | const playerList = formatPlayerList(params.alivePlayers); 243 | const speechSummary = formatSpeechSummary(params.speechSummary); 244 | const currentVotes = formatCurrentVotes(params.currentVotes); 245 | 246 | return `你是${params.playerId}号玩家,狼人杀游戏中的猎人角色。当前投票环节: 247 | 248 | 存活玩家:[${playerList}] 249 | 今日发言摘要: 250 | ${speechSummary} 251 | 当前投票情况:${currentVotes} 252 | 253 | 作为猎人,你的投票策略: 254 | 1. 分析玩家逻辑,投票给最可疑的玩家 255 | 2. 隐藏身份,避免被狼人发现 256 | 3. 在必要时可以威胁或暗示身份 257 | 4. 考虑开枪技能的威慑作用 258 | 259 | 请返回你的投票决定,格式要求: 260 | - target: 你要投票的玩家ID(数字) 261 | - reason: 你投票的详细理由 262 | 263 | `; 264 | } 265 | 266 | // 工厂函数 267 | export function getRoleVoting(playerServer: PlayerServer, context: GameContext): string { 268 | const role = playerServer.getRole(); 269 | 270 | if (!role) { 271 | throw new Error('PlayerServer must have role set'); 272 | } 273 | 274 | switch (role) { 275 | case Role.VILLAGER: 276 | return getVillagerVoting(playerServer, context as PlayerContext); 277 | case Role.WEREWOLF: 278 | return getWerewolfVoting(playerServer, context as PlayerContext); 279 | case Role.SEER: 280 | return getSeerVoting(playerServer, context as SeerContext); 281 | case Role.WITCH: 282 | return getWitchVoting(playerServer, context as WitchContext); 283 | default: 284 | throw new Error(`Unknown role: ${role}`); 285 | } 286 | } -------------------------------------------------------------------------------- /packages/player/src/PlayerServer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Role, 3 | GamePhase, 4 | type StartGameParams, 5 | type PlayerContext, 6 | type WitchContext, 7 | type SeerContext, 8 | type PlayerId, 9 | PersonalityType, 10 | VotingResponseType, 11 | SpeechResponseType, 12 | VotingResponseSchema, 13 | NightActionResponseType, 14 | WerewolfNightActionSchema, 15 | SeerNightActionSchema, 16 | WitchNightActionSchema, 17 | SpeechResponseSchema 18 | } from '@ai-werewolf/types'; 19 | import { WerewolfPrompts } from './prompts'; 20 | import { generateObject } from 'ai'; 21 | import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; 22 | import { 23 | getAITelemetryConfig, 24 | createGameSession, 25 | createPhaseTrace, 26 | endPhaseTrace, 27 | logEvent, 28 | type AITelemetryContext 29 | } from './services/langfuse'; 30 | import { PlayerConfig } from './config/PlayerConfig'; 31 | 32 | // 角色到夜间行动 Schema 的映射 33 | const ROLE_SCHEMA_MAP = { 34 | [Role.WEREWOLF]: WerewolfNightActionSchema, 35 | [Role.SEER]: SeerNightActionSchema, 36 | [Role.WITCH]: WitchNightActionSchema, 37 | } as const; 38 | 39 | export class PlayerServer { 40 | private gameId?: string; 41 | private playerId?: number; 42 | private role?: Role; 43 | private teammates?: PlayerId[]; 44 | private config: PlayerConfig; 45 | 46 | constructor(config: PlayerConfig) { 47 | this.config = config; 48 | } 49 | 50 | async startGame(params: StartGameParams): Promise { 51 | this.gameId = params.gameId; 52 | this.role = params.role as Role; 53 | this.teammates = params.teammates; 54 | this.playerId = params.playerId; 55 | 56 | // 创建 Langfuse session 57 | createGameSession(this.gameId, { 58 | playerId: this.playerId, 59 | role: this.role, 60 | teammates: this.teammates 61 | }); 62 | 63 | if (this.config.logging.enabled) { 64 | console.log(`🎮 Player started game ${this.gameId} as ${this.role}`); 65 | console.log(`👤 Player ID: ${this.playerId}`); 66 | if (this.teammates && this.teammates.length > 0) { 67 | console.log(`🤝 Teammates: ${this.teammates.join(', ')}`); 68 | } 69 | console.log(`📊 Game ID (session): ${this.gameId}`); 70 | } 71 | } 72 | 73 | async speak(context: PlayerContext): Promise { 74 | if (!this.role || !this.config.ai.apiKey) { 75 | return "我需要仔细思考一下当前的情况。"; 76 | } 77 | 78 | const speechResponse = await this.generateSpeech(context); 79 | return speechResponse.speech; 80 | } 81 | 82 | async vote(context: PlayerContext): Promise { 83 | if (!this.role || !this.config.ai.apiKey) { 84 | return { target: 1, reason: "默认投票给玩家1" }; 85 | } 86 | 87 | return await this.generateVote(context); 88 | } 89 | 90 | async useAbility(context: PlayerContext | WitchContext | SeerContext): Promise { 91 | if (!this.role || !this.config.ai.apiKey) { 92 | throw new Error("我没有特殊能力可以使用。"); 93 | } 94 | 95 | return await this.generateAbilityUse(context); 96 | } 97 | 98 | async lastWords(): Promise { 99 | // 暂时返回默认遗言,后续可实现AI生成 100 | return "很遗憾要离开游戏了,希望好人阵营能够获胜!"; 101 | } 102 | 103 | getStatus() { 104 | return { 105 | gameId: this.gameId, 106 | playerId: this.playerId, 107 | role: this.role, 108 | teammates: this.teammates, 109 | isAlive: true, 110 | config: { 111 | personality: this.config.game.personality 112 | } 113 | }; 114 | } 115 | 116 | // Getter methods for prompt factories 117 | getRole(): Role | undefined { 118 | return this.role; 119 | } 120 | 121 | getPlayerId(): number | undefined { 122 | return this.playerId; 123 | } 124 | 125 | getTeammates(): PlayerId[] | undefined { 126 | return this.teammates; 127 | } 128 | 129 | getPersonalityPrompt(): string { 130 | return this.buildPersonalityPrompt(); 131 | } 132 | 133 | getGameId(): string | undefined { 134 | return this.gameId; 135 | } 136 | 137 | // 通用AI生成方法 138 | private async generateWithLangfuse( 139 | params: { 140 | functionId: string; 141 | schema: any; // Zod schema 142 | prompt: string; 143 | maxOutputTokens?: number; 144 | temperature?: number; 145 | context?: PlayerContext; // 使用 PlayerContext 替代 telemetryMetadata 146 | } 147 | ): Promise { 148 | const { functionId, context, schema, prompt, maxOutputTokens, temperature } = params; 149 | 150 | console.log(`📝 ${functionId} prompt:`, prompt); 151 | console.log(`📋 ${functionId} schema:`, JSON.stringify(schema.shape, null, 2)); 152 | 153 | // 获取遥测配置 154 | const telemetryConfig = this.getTelemetryConfig(functionId, context); 155 | 156 | try { 157 | const result = await generateObject({ 158 | model: this.getModel(), 159 | schema: schema, 160 | prompt: prompt, 161 | maxOutputTokens: maxOutputTokens || this.config.ai.maxTokens, 162 | temperature: temperature ?? this.config.ai.temperature, 163 | // 使用 experimental_telemetry(只有在有配置时才传递) 164 | ...(telemetryConfig && { experimental_telemetry: telemetryConfig }), 165 | }); 166 | 167 | console.log(`🎯 ${functionId} result:`, JSON.stringify(result.object, null, 2)); 168 | 169 | return result.object as T; 170 | } catch (error) { 171 | console.error(`AI ${functionId} failed:`, error); 172 | throw new Error(`Failed to generate ${functionId}: ${error}`); 173 | } 174 | } 175 | 176 | // AI生成方法 177 | private async generateSpeech(context: PlayerContext): Promise { 178 | const prompt = this.buildSpeechPrompt(context); 179 | 180 | return this.generateWithLangfuse({ 181 | functionId: 'speech-generation', 182 | schema: SpeechResponseSchema, 183 | prompt: prompt, 184 | context: context, 185 | }); 186 | } 187 | 188 | private async generateVote(context: PlayerContext): Promise { 189 | const prompt = this.buildVotePrompt(context); 190 | 191 | return this.generateWithLangfuse({ 192 | functionId: 'vote-generation', 193 | schema: VotingResponseSchema, 194 | prompt: prompt, 195 | context: context, 196 | }); 197 | } 198 | 199 | private async generateAbilityUse(context: PlayerContext | WitchContext | SeerContext): Promise { 200 | if (this.role === Role.VILLAGER) { 201 | throw new Error('Village has no night action, should be skipped'); 202 | } 203 | 204 | const schema = ROLE_SCHEMA_MAP[this.role!]; 205 | if (!schema) { 206 | throw new Error(`Unknown role: ${this.role}`); 207 | } 208 | 209 | const prompt = this.buildAbilityPrompt(context); 210 | 211 | return this.generateWithLangfuse({ 212 | functionId: 'ability-generation', 213 | schema: schema, 214 | prompt: prompt, 215 | context: context, 216 | }); 217 | } 218 | 219 | // Prompt构建方法 220 | private buildSpeechPrompt(context: PlayerContext): string { 221 | const speechPrompt = WerewolfPrompts.getSpeech( 222 | this, 223 | context 224 | ); 225 | 226 | return speechPrompt + '\n\n注意:发言内容控制在30-80字,语言自然,像真人玩家。'; 227 | } 228 | 229 | private buildVotePrompt(context: PlayerContext): string { 230 | const personalityPrompt = this.buildPersonalityPrompt(); 231 | 232 | const additionalParams = { 233 | teammates: this.teammates 234 | }; 235 | 236 | // 为预言家添加查验结果 237 | if (this.role === Role.SEER && 'investigatedPlayers' in context) { 238 | const seerContext = context as any; 239 | const checkResults: {[key: string]: 'good' | 'werewolf'} = {}; 240 | 241 | for (const investigation of Object.values(seerContext.investigatedPlayers)) { 242 | const investigationData = investigation as { target: number; isGood: boolean }; 243 | checkResults[investigationData.target.toString()] = investigationData.isGood ? 'good' : 'werewolf'; 244 | } 245 | 246 | (additionalParams as any).checkResults = checkResults; 247 | } 248 | 249 | const votingPrompt = WerewolfPrompts.getVoting( 250 | this, 251 | context 252 | ); 253 | 254 | return personalityPrompt + votingPrompt; 255 | } 256 | 257 | private buildAbilityPrompt(context: PlayerContext | WitchContext | SeerContext): string { 258 | const nightPrompt = WerewolfPrompts.getNightAction(this, context); 259 | 260 | return nightPrompt; 261 | } 262 | 263 | // 辅助方法 264 | private getModel() { 265 | const openrouter = createOpenAICompatible({ 266 | name: 'openrouter', 267 | baseURL: 'https://openrouter.ai/api/v1', 268 | apiKey: this.config.ai.apiKey || process.env.OPENROUTER_API_KEY, 269 | headers: { 270 | 'HTTP-Referer': 'https://mojo.monad.xyz', 271 | 'X-Title': 'AI Werewolf Game', 272 | }, 273 | }); 274 | 275 | return openrouter.chatModel(this.config.ai.model); 276 | } 277 | 278 | private getTelemetryConfig( 279 | functionId: string, 280 | context?: PlayerContext 281 | ) { 282 | if (!this.gameId || !this.playerId) { 283 | return false; 284 | } 285 | 286 | const telemetryContext: AITelemetryContext = { 287 | gameId: this.gameId, 288 | playerId: this.playerId, 289 | functionId, 290 | context, 291 | }; 292 | 293 | return getAITelemetryConfig(telemetryContext); 294 | } 295 | 296 | private buildPersonalityPrompt(): string { 297 | if (!this.config.game.strategy) { 298 | return ''; 299 | } 300 | 301 | const personalityType = this.config.game.strategy === 'balanced' ? 'cunning' : this.config.game.strategy as PersonalityType; 302 | 303 | return WerewolfPrompts.getPersonality(personalityType) + '\n\n'; 304 | } 305 | } -------------------------------------------------------------------------------- /packages/player/src/services/langfuse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Langfuse 后端服务 3 | * 用于 AI Player 的 Langfuse 集成 4 | * 5 | * 层级结构: 6 | * Session (整个游戏) 7 | * ├── Trace (round-1-day: 第1轮白天) 8 | * │ └── Generation (AI调用: 玩家发言) 9 | * ├── Trace (round-1-voting: 第1轮投票) 10 | * │ └── Generation (AI调用: 玩家投票) 11 | * ├── Trace (round-1-night: 第1轮夜晚) 12 | * │ └── Generation (AI调用: 玩家能力使用) 13 | * └── Event (关键事件: 投票结果, 游戏结束等) 14 | */ 15 | 16 | import { 17 | Langfuse, 18 | type LangfuseTraceClient 19 | } from 'langfuse'; 20 | import { NodeSDK } from '@opentelemetry/sdk-node'; 21 | import { LangfuseExporter } from 'langfuse-vercel'; 22 | import type { PlayerContext, GamePhase } from '@ai-werewolf/types'; 23 | 24 | // Langfuse 客户端实例 25 | let langfuseClient: Langfuse | null = null; 26 | 27 | // OpenTelemetry SDK 实例 28 | let otelSdk: NodeSDK | null = null; 29 | 30 | // 会话管理 31 | const sessions = new Map(); // gameId -> Session 32 | 33 | // Trace管理 - 每个阶段一个独立的trace (round-1-day, round-1-voting, round-1-night) 34 | const traces = new Map(); // traceId -> Trace 35 | 36 | // 当前活跃的阶段trace 37 | const activePhaseTrace = new Map(); // gameId-phase -> current traceId 38 | 39 | /** 40 | * 获取 Langfuse 客户端实例 41 | */ 42 | function getLangfuseClient(): Langfuse | null { 43 | if (!langfuseClient && process.env.LANGFUSE_SECRET_KEY && process.env.LANGFUSE_PUBLIC_KEY) { 44 | langfuseClient = new Langfuse({ 45 | secretKey: process.env.LANGFUSE_SECRET_KEY, 46 | publicKey: process.env.LANGFUSE_PUBLIC_KEY, 47 | baseUrl: process.env.LANGFUSE_BASEURL || 'https://cloud.langfuse.com', 48 | flushAt: 1, // 立即发送事件,便于调试 49 | flushInterval: 1000, // 每秒刷新一次 50 | }); 51 | console.log('✅ Langfuse 客户端已初始化'); 52 | } 53 | return langfuseClient; 54 | } 55 | 56 | /** 57 | * 初始化 Langfuse 和 OpenTelemetry 58 | */ 59 | export function initializeLangfuse() { 60 | const client = getLangfuseClient(); 61 | 62 | // 初始化 OpenTelemetry SDK with LangfuseExporter 63 | if (client && process.env.LANGFUSE_SECRET_KEY && process.env.LANGFUSE_PUBLIC_KEY) { 64 | try { 65 | otelSdk = new NodeSDK({ 66 | serviceName: 'ai-werewolf-player', 67 | traceExporter: new LangfuseExporter({ 68 | secretKey: process.env.LANGFUSE_SECRET_KEY, 69 | publicKey: process.env.LANGFUSE_PUBLIC_KEY, 70 | baseUrl: process.env.LANGFUSE_BASEURL || 'https://cloud.langfuse.com', 71 | }), 72 | }); 73 | 74 | otelSdk.start(); 75 | console.log('✅ OpenTelemetry SDK with LangfuseExporter 已初始化'); 76 | } catch (error) { 77 | console.error('❌ OpenTelemetry SDK 初始化失败:', error); 78 | } 79 | } 80 | 81 | if (client) { 82 | console.log('📊 Langfuse 已启用,将追踪 AI 请求'); 83 | console.log(` - Public Key: ${process.env.LANGFUSE_PUBLIC_KEY?.substring(0, 8)}...`); 84 | console.log(` - Base URL: ${process.env.LANGFUSE_BASEURL || 'https://cloud.langfuse.com'}`); 85 | } else { 86 | console.log('⚠️ Langfuse 未启用(缺少必需的环境变量)'); 87 | } 88 | return client; 89 | } 90 | 91 | /** 92 | * 创建游戏会话 (Session) 93 | * 一个游戏对应一个session,包含多个trace(阶段) 94 | */ 95 | export function createGameSession(gameId: string, metadata?: any): string { 96 | const client = getLangfuseClient(); 97 | if (!client) { 98 | console.log(`📊 [模拟] Game session: ${gameId}`); 99 | return gameId; 100 | } 101 | 102 | try { 103 | // 在Langfuse中,session是通过sessionId关联的 104 | // 我们需要在创建trace时指定sessionId 105 | sessions.set(gameId, { 106 | sessionId: gameId, 107 | startTime: new Date(), 108 | metadata: { 109 | ...metadata, 110 | gameId, 111 | timestamp: new Date().toISOString(), 112 | } 113 | }); 114 | 115 | console.log(`✅ 创建 Langfuse session: ${gameId}`); 116 | return gameId; 117 | } catch (error) { 118 | console.error('❌ 创建 Langfuse session 失败:', error); 119 | return gameId; 120 | } 121 | } 122 | 123 | /** 124 | * 结束游戏会话 125 | */ 126 | export function endGameSession(gameId: string, result?: any): void { 127 | const session = sessions.get(gameId); 128 | if (!session) return; 129 | 130 | try { 131 | // 记录游戏结束事件 132 | logEvent(gameId, 'game-end', { 133 | result, 134 | duration: Date.now() - session.startTime.getTime(), 135 | timestamp: new Date().toISOString() 136 | }); 137 | 138 | // 清理会话数据 139 | sessions.delete(gameId); 140 | activePhaseTrace.delete(gameId); 141 | 142 | console.log(`✅ 结束 Langfuse session: ${gameId}`); 143 | } catch (error) { 144 | console.error('❌ 结束 Langfuse session 失败:', error); 145 | } 146 | } 147 | 148 | /** 149 | * 创建阶段 Trace (round-1-day, round-1-voting, round-1-night等) 150 | * 每个阶段创建一个独立的trace 151 | */ 152 | export function createPhaseTrace( 153 | gameId: string, 154 | round: number, 155 | phase: 'day' | 'voting' | 'night' 156 | ): string { 157 | const client = getLangfuseClient(); 158 | const traceId = `${gameId}-round-${round}-${phase}`; 159 | 160 | if (!client) { 161 | console.log(`📊 [模拟] Phase trace: ${traceId}`); 162 | return traceId; 163 | } 164 | 165 | const session = sessions.get(gameId); 166 | if (!session) { 167 | console.warn(`⚠️ Session not found for game: ${gameId}`); 168 | createGameSession(gameId); // 自动创建session 169 | } 170 | 171 | try { 172 | const trace = client.trace({ 173 | id: traceId, 174 | name: `round-${round}-${phase}`, 175 | sessionId: gameId, // 关联到游戏session 176 | metadata: { 177 | gameId, 178 | round, 179 | phase, 180 | timestamp: new Date().toISOString(), 181 | }, 182 | }); 183 | 184 | traces.set(traceId, trace); 185 | activePhaseTrace.set(`${gameId}-${phase}`, traceId); // 按 phase 存储活跃 trace 186 | 187 | console.log(`✅ 创建 Phase trace: ${traceId}`); 188 | return traceId; 189 | } catch (error) { 190 | console.error('❌ 创建 Phase trace 失败:', error); 191 | return traceId; 192 | } 193 | } 194 | 195 | /** 196 | * 结束阶段 Trace 197 | */ 198 | export function endPhaseTrace(traceId: string): void { 199 | const trace = traces.get(traceId); 200 | if (!trace) return; 201 | 202 | try { 203 | // Langfuse trace会自动计算duration 204 | traces.delete(traceId); 205 | console.log(`✅ 结束 Phase trace: ${traceId}`); 206 | } catch (error) { 207 | console.error('❌ 结束 Phase trace 失败:', error); 208 | } 209 | } 210 | 211 | /** 212 | * 记录关键事件 (投票结果、游戏事件等) 213 | */ 214 | export function logEvent( 215 | parentId: string, // traceId 216 | eventName: string, 217 | data: any 218 | ): void { 219 | const client = getLangfuseClient(); 220 | if (!client) { 221 | console.log(`📊 [模拟] Event: ${eventName}`, data); 222 | return; 223 | } 224 | 225 | try { 226 | // 尝试找到父级 trace 227 | let parent = traces.get(parentId); 228 | 229 | // 如果没找到,尝试从 activePhaseTrace 中查找 230 | if (!parent) { 231 | for (const [key, traceId] of activePhaseTrace.entries()) { 232 | if (key.startsWith(parentId)) { 233 | parent = traces.get(traceId); 234 | break; 235 | } 236 | } 237 | } 238 | 239 | if (parent) { 240 | parent.event({ 241 | name: eventName, 242 | input: data, 243 | }); 244 | console.log(`✅ 记录 Event: ${eventName}`); 245 | } else { 246 | console.warn(`⚠️ Parent not found for event: ${eventName}`); 247 | } 248 | } catch (error) { 249 | console.error('❌ 记录 Event 失败:', error); 250 | } 251 | } 252 | 253 | /** 254 | * 从 PlayerContext 获取阶段信息 255 | */ 256 | function getPhaseFromContext(context: PlayerContext): 'day' | 'voting' | 'night' | null { 257 | switch (context.currentPhase) { 258 | case 'day' as GamePhase: 259 | return 'day'; 260 | case 'voting' as GamePhase: 261 | return 'voting'; 262 | case 'night' as GamePhase: 263 | return 'night'; 264 | default: 265 | return null; 266 | } 267 | } 268 | 269 | /** 270 | * 获取或创建当前阶段的 Trace 271 | */ 272 | export function ensurePhaseTrace( 273 | gameId: string, 274 | context?: PlayerContext 275 | ): string | null { 276 | if (!context) { 277 | console.warn(`⚠️ No context provided for phase trace`); 278 | return null; 279 | } 280 | 281 | const phase = getPhaseFromContext(context); 282 | if (!phase) { 283 | console.warn(`⚠️ Unknown phase: ${context.currentPhase}`); 284 | return null; 285 | } 286 | 287 | const key = `${gameId}-${phase}`; 288 | let traceId = activePhaseTrace.get(key); 289 | 290 | // 如果不存在,创建新的 291 | if (!traceId) { 292 | traceId = createPhaseTrace(gameId, context.round, phase); 293 | } 294 | 295 | return traceId; 296 | } 297 | 298 | /** 299 | * 获取AI请求的遥测配置 300 | * 返回 experimental_telemetry 配置,让 Vercel AI SDK 自动创建 generation 301 | */ 302 | export interface AITelemetryContext { 303 | gameId: string; 304 | playerId: number; 305 | functionId: string; 306 | context?: PlayerContext; // 包含 round 和 currentPhase 307 | } 308 | 309 | export function getAITelemetryConfig( 310 | telemetryContext: AITelemetryContext 311 | ): { isEnabled: boolean; functionId?: string; metadata?: any } | false { 312 | return withLangfuseErrorHandling(() => { 313 | const client = getLangfuseClient(); 314 | 315 | if (!client) { 316 | return false; 317 | } 318 | 319 | const { gameId, playerId, functionId, context } = telemetryContext; 320 | 321 | // 获取或创建当前阶段的 Trace 322 | const traceId = ensurePhaseTrace(gameId, context); 323 | if (!traceId) { 324 | return false; 325 | } 326 | 327 | // 返回 experimental_telemetry 配置 328 | return { 329 | isEnabled: true, 330 | functionId: `player-${playerId}-${functionId}`, 331 | metadata: { 332 | langfuseTraceId: traceId, // 链接到阶段 trace 333 | langfuseUpdateParent: false, // 不更新父 trace 334 | gameId, 335 | playerId, 336 | phase: context?.currentPhase, 337 | round: context?.round, 338 | timestamp: new Date().toISOString() 339 | } 340 | }; 341 | }, 'getAITelemetryConfig')(); 342 | } 343 | 344 | 345 | /** 346 | * 关闭 Langfuse 和 OpenTelemetry 347 | */ 348 | export async function shutdownLangfuse() { 349 | // 关闭 OpenTelemetry SDK 350 | if (otelSdk) { 351 | try { 352 | await otelSdk.shutdown(); 353 | console.log('✅ OpenTelemetry SDK 已关闭'); 354 | } catch (error) { 355 | console.error('❌ OpenTelemetry SDK 关闭时出错:', error); 356 | } 357 | } 358 | 359 | const client = getLangfuseClient(); 360 | if (!client) { 361 | console.log('📊 Langfuse 未启用,无需关闭'); 362 | return; 363 | } 364 | 365 | try { 366 | // 清理所有traces 367 | traces.clear(); 368 | 369 | // 清理所有sessions 370 | sessions.clear(); 371 | 372 | console.log('📊 正在刷新 Langfuse 数据...'); 373 | await client.flushAsync(); 374 | await client.shutdownAsync(); 375 | console.log('✅ Langfuse 已优雅关闭'); 376 | } catch (error) { 377 | console.error('❌ Langfuse 关闭时出错:', error); 378 | } 379 | } 380 | 381 | /** 382 | * Langfuse 错误处理包装器 383 | */ 384 | export function withLangfuseErrorHandling any>( 385 | fn: T, 386 | context?: string 387 | ): T { 388 | return ((...args: Parameters) => { 389 | try { 390 | return fn(...args); 391 | } catch (error) { 392 | console.error(`❌ Langfuse error in ${context || 'function'}:`, error); 393 | return undefined; 394 | } 395 | }) as T; 396 | } 397 | 398 | 399 | // 导出 langfuse 对象,提供统一接口 400 | export const langfuse = { 401 | async flushAsync() { 402 | const client = getLangfuseClient(); 403 | if (client) { 404 | console.log('📊 刷新 Langfuse 数据...'); 405 | await client.flushAsync(); 406 | } else { 407 | console.log('📊 Langfuse 未启用,跳过刷新'); 408 | } 409 | } 410 | }; -------------------------------------------------------------------------------- /shared/lib/src/__tests__/roleAssignment.bun.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from 'bun:test'; 2 | import { RoleAssignment, type RoleConfig } from '../roleAssignment'; 3 | import { Role } from '@ai-werewolf/types'; 4 | 5 | describe('RoleAssignment', () => { 6 | describe('getDefaultRoleConfig()', () => { 7 | describe('Error Cases', () => { 8 | test('should throw error for less than 6 players', () => { 9 | expect(() => RoleAssignment.getDefaultRoleConfig(5)) 10 | .toThrow('Minimum 6 players required'); 11 | 12 | expect(() => RoleAssignment.getDefaultRoleConfig(4)) 13 | .toThrow('Minimum 6 players required'); 14 | 15 | expect(() => RoleAssignment.getDefaultRoleConfig(0)) 16 | .toThrow('Minimum 6 players required'); 17 | 18 | expect(() => RoleAssignment.getDefaultRoleConfig(-1)) 19 | .toThrow('Minimum 6 players required'); 20 | }); 21 | }); 22 | 23 | describe('6-Player Special Configuration', () => { 24 | test('should return correct roles for 6 players', () => { 25 | const config = RoleAssignment.getDefaultRoleConfig(6); 26 | 27 | expect(config).toHaveLength(4); 28 | expect(config).toContainEqual({ role: Role.WEREWOLF, count: 2 }); 29 | expect(config).toContainEqual({ role: Role.SEER, count: 1 }); 30 | expect(config).toContainEqual({ role: Role.WITCH, count: 1 }); 31 | expect(config).toContainEqual({ role: Role.VILLAGER, count: 2 }); 32 | }); 33 | 34 | test('should total exactly 6 players for 6-player config', () => { 35 | const config = RoleAssignment.getDefaultRoleConfig(6); 36 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 37 | 38 | expect(totalPlayers).toBe(6); 39 | }); 40 | 41 | test('should have correct role distribution for 6 players', () => { 42 | const config = RoleAssignment.getDefaultRoleConfig(6); 43 | const roleMap = new Map(config.map(c => [c.role, c.count])); 44 | 45 | expect(roleMap.get(Role.WEREWOLF)).toBe(2); 46 | expect(roleMap.get(Role.SEER)).toBe(1); 47 | expect(roleMap.get(Role.WITCH)).toBe(1); 48 | expect(roleMap.get(Role.VILLAGER)).toBe(2); 49 | }); 50 | }); 51 | 52 | describe('Standard Configuration (7+ Players)', () => { 53 | test('should configure 7 players correctly', () => { 54 | const config = RoleAssignment.getDefaultRoleConfig(7); 55 | const roleMap = new Map(config.map(c => [c.role, c.count])); 56 | 57 | // 7 players: floor(7/3) = 2 werewolves, 1 seer, no witch, 4 villagers 58 | expect(roleMap.get(Role.WEREWOLF)).toBe(2); 59 | expect(roleMap.get(Role.SEER)).toBe(1); 60 | expect(roleMap.get(Role.WITCH)).toBeUndefined(); // No witch for 7 players 61 | expect(roleMap.get(Role.VILLAGER)).toBe(4); 62 | 63 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 64 | expect(totalPlayers).toBe(7); 65 | }); 66 | 67 | test('should configure 8 players correctly', () => { 68 | const config = RoleAssignment.getDefaultRoleConfig(8); 69 | const roleMap = new Map(config.map(c => [c.role, c.count])); 70 | 71 | // 8 players: floor(8/3) = 2 werewolves, 1 seer, 1 witch, 4 villagers 72 | expect(roleMap.get(Role.WEREWOLF)).toBe(2); 73 | expect(roleMap.get(Role.SEER)).toBe(1); 74 | expect(roleMap.get(Role.WITCH)).toBe(1); 75 | expect(roleMap.get(Role.VILLAGER)).toBe(4); 76 | 77 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 78 | expect(totalPlayers).toBe(8); 79 | }); 80 | 81 | test('should configure 9 players correctly', () => { 82 | const config = RoleAssignment.getDefaultRoleConfig(9); 83 | const roleMap = new Map(config.map(c => [c.role, c.count])); 84 | 85 | // 9 players: floor(9/3) = 3 werewolves, 1 seer, 1 witch, 4 villagers 86 | expect(roleMap.get(Role.WEREWOLF)).toBe(3); 87 | expect(roleMap.get(Role.SEER)).toBe(1); 88 | expect(roleMap.get(Role.WITCH)).toBe(1); 89 | expect(roleMap.get(Role.VILLAGER)).toBe(4); 90 | 91 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 92 | expect(totalPlayers).toBe(9); 93 | }); 94 | 95 | test('should configure 12 players correctly', () => { 96 | const config = RoleAssignment.getDefaultRoleConfig(12); 97 | const roleMap = new Map(config.map(c => [c.role, c.count])); 98 | 99 | // 12 players: floor(12/3) = 4 werewolves, 1 seer, 1 witch, 6 villagers 100 | expect(roleMap.get(Role.WEREWOLF)).toBe(4); 101 | expect(roleMap.get(Role.SEER)).toBe(1); 102 | expect(roleMap.get(Role.WITCH)).toBe(1); 103 | expect(roleMap.get(Role.VILLAGER)).toBe(6); 104 | 105 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 106 | expect(totalPlayers).toBe(12); 107 | }); 108 | 109 | test('should configure 15 players correctly', () => { 110 | const config = RoleAssignment.getDefaultRoleConfig(15); 111 | const roleMap = new Map(config.map(c => [c.role, c.count])); 112 | 113 | // 15 players: floor(15/3) = 5 werewolves, 1 seer, 1 witch, 8 villagers 114 | expect(roleMap.get(Role.WEREWOLF)).toBe(5); 115 | expect(roleMap.get(Role.SEER)).toBe(1); 116 | expect(roleMap.get(Role.WITCH)).toBe(1); 117 | expect(roleMap.get(Role.VILLAGER)).toBe(8); 118 | 119 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 120 | expect(totalPlayers).toBe(15); 121 | }); 122 | }); 123 | 124 | describe('Werewolf Count Calculation', () => { 125 | test('should calculate werewolf count as floor(playerCount/3)', () => { 126 | const testCases = [ 127 | { players: 7, expectedWerewolves: 2 }, 128 | { players: 8, expectedWerewolves: 2 }, 129 | { players: 9, expectedWerewolves: 3 }, 130 | { players: 10, expectedWerewolves: 3 }, 131 | { players: 11, expectedWerewolves: 3 }, 132 | { players: 12, expectedWerewolves: 4 }, 133 | { players: 18, expectedWerewolves: 6 }, 134 | { players: 20, expectedWerewolves: 6 } 135 | ]; 136 | 137 | testCases.forEach(({ players, expectedWerewolves }) => { 138 | const config = RoleAssignment.getDefaultRoleConfig(players); 139 | const werewolfConfig = config.find(c => c.role === Role.WEREWOLF); 140 | 141 | expect(werewolfConfig?.count).toBe(expectedWerewolves); 142 | }); 143 | }); 144 | }); 145 | 146 | describe('Special Roles Rules', () => { 147 | test('should always include exactly 1 seer for any player count >= 6', () => { 148 | const playerCounts = [6, 7, 8, 9, 10, 11, 12, 15, 20]; 149 | 150 | playerCounts.forEach(playerCount => { 151 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 152 | const seerConfig = config.find(c => c.role === Role.SEER); 153 | 154 | expect(seerConfig?.count).toBe(1); 155 | }); 156 | }); 157 | 158 | test('should include witch only for 8+ players (except 6-player special case)', () => { 159 | // Should NOT have witch 160 | const configSeven = RoleAssignment.getDefaultRoleConfig(7); 161 | const witchConfigSeven = configSeven.find(c => c.role === Role.WITCH); 162 | expect(witchConfigSeven).toBeUndefined(); 163 | 164 | // Should have witch 165 | const playerCountsWithWitch = [6, 8, 9, 10, 11, 12, 15]; 166 | playerCountsWithWitch.forEach(playerCount => { 167 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 168 | const witchConfig = config.find(c => c.role === Role.WITCH); 169 | 170 | expect(witchConfig?.count).toBe(1); 171 | }); 172 | }); 173 | 174 | test('should calculate villager count as remainder after other roles', () => { 175 | const testCases = [ 176 | { players: 7, expectedVillagers: 4 }, // 7 - 2(werewolf) - 1(seer) = 4 177 | { players: 8, expectedVillagers: 4 }, // 8 - 2(werewolf) - 1(seer) - 1(witch) = 4 178 | { players: 9, expectedVillagers: 4 }, // 9 - 3(werewolf) - 1(seer) - 1(witch) = 4 179 | { players: 12, expectedVillagers: 6 }, // 12 - 4(werewolf) - 1(seer) - 1(witch) = 6 180 | ]; 181 | 182 | testCases.forEach(({ players, expectedVillagers }) => { 183 | const config = RoleAssignment.getDefaultRoleConfig(players); 184 | const villagerConfig = config.find(c => c.role === Role.VILLAGER); 185 | 186 | expect(villagerConfig?.count).toBe(expectedVillagers); 187 | }); 188 | }); 189 | }); 190 | 191 | describe('Configuration Validation', () => { 192 | test('should ensure total players match input for various counts', () => { 193 | const playerCounts = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 20, 24]; 194 | 195 | playerCounts.forEach(playerCount => { 196 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 197 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 198 | 199 | expect(totalPlayers).toBe(playerCount); 200 | }); 201 | }); 202 | 203 | test('should ensure at least 1 werewolf for any valid player count', () => { 204 | const playerCounts = [6, 7, 8, 9, 10, 12, 15, 18, 21]; 205 | 206 | playerCounts.forEach(playerCount => { 207 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 208 | const werewolfConfig = config.find(c => c.role === Role.WEREWOLF); 209 | 210 | expect(werewolfConfig?.count).toBeGreaterThanOrEqual(1); 211 | }); 212 | }); 213 | 214 | test('should ensure villagers are never negative', () => { 215 | const playerCounts = [6, 7, 8, 9, 10, 11, 12, 15, 18, 21, 30]; 216 | 217 | playerCounts.forEach(playerCount => { 218 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 219 | const villagerConfig = config.find(c => c.role === Role.VILLAGER); 220 | 221 | expect(villagerConfig?.count).toBeGreaterThanOrEqual(0); 222 | }); 223 | }); 224 | 225 | test('should never have more werewolves than good players', () => { 226 | const playerCounts = [6, 7, 8, 9, 10, 11, 12, 15, 18, 21, 30]; 227 | 228 | playerCounts.forEach(playerCount => { 229 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 230 | const roleMap = new Map(config.map(c => [c.role, c.count])); 231 | 232 | const werewolves = roleMap.get(Role.WEREWOLF) || 0; 233 | const villagers = roleMap.get(Role.VILLAGER) || 0; 234 | const seer = roleMap.get(Role.SEER) || 0; 235 | const witch = roleMap.get(Role.WITCH) || 0; 236 | 237 | const goodPlayers = villagers + seer + witch; 238 | 239 | expect(werewolves).toBeLessThan(goodPlayers); 240 | }); 241 | }); 242 | }); 243 | 244 | describe('Edge Cases', () => { 245 | test('should handle exactly minimum player count', () => { 246 | const config = RoleAssignment.getDefaultRoleConfig(6); 247 | 248 | expect(config).toBeDefined(); 249 | expect(Array.isArray(config)).toBe(true); 250 | expect(config.length).toBeGreaterThan(0); 251 | }); 252 | 253 | test('should handle large player counts', () => { 254 | const config = RoleAssignment.getDefaultRoleConfig(50); 255 | const roleMap = new Map(config.map(c => [c.role, c.count])); 256 | 257 | // 50 players: floor(50/3) = 16 werewolves, 1 seer, 1 witch, 32 villagers 258 | expect(roleMap.get(Role.WEREWOLF)).toBe(16); 259 | expect(roleMap.get(Role.SEER)).toBe(1); 260 | expect(roleMap.get(Role.WITCH)).toBe(1); 261 | expect(roleMap.get(Role.VILLAGER)).toBe(32); 262 | 263 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 264 | expect(totalPlayers).toBe(50); 265 | }); 266 | 267 | test('should handle unusual player counts', () => { 268 | const unusualCounts = [23, 29, 31, 37, 41]; 269 | 270 | unusualCounts.forEach(playerCount => { 271 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 272 | 273 | expect(config).toBeDefined(); 274 | expect(Array.isArray(config)).toBe(true); 275 | 276 | const totalPlayers = config.reduce((sum, role) => sum + role.count, 0); 277 | expect(totalPlayers).toBe(playerCount); 278 | 279 | // Ensure all counts are positive 280 | config.forEach(roleConfig => { 281 | expect(roleConfig.count).toBeGreaterThan(0); 282 | }); 283 | }); 284 | }); 285 | }); 286 | 287 | describe('Role Distribution Balance', () => { 288 | test('should maintain reasonable balance across different player counts', () => { 289 | const playerCounts = [8, 12, 16, 20]; 290 | 291 | playerCounts.forEach(playerCount => { 292 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 293 | const roleMap = new Map(config.map(c => [c.role, c.count])); 294 | 295 | const werewolves = roleMap.get(Role.WEREWOLF) || 0; 296 | const totalGood = playerCount - werewolves; 297 | 298 | // Werewolves should be approximately 25-35% of total players 299 | const werewolfRatio = werewolves / playerCount; 300 | expect(werewolfRatio).toBeGreaterThanOrEqual(0.2); 301 | expect(werewolfRatio).toBeLessThanOrEqual(0.4); 302 | }); 303 | }); 304 | 305 | test('should ensure good players always outnumber werewolves', () => { 306 | const playerCounts = [6, 8, 10, 12, 15, 18, 21, 24, 30]; 307 | 308 | playerCounts.forEach(playerCount => { 309 | const config = RoleAssignment.getDefaultRoleConfig(playerCount); 310 | const roleMap = new Map(config.map(c => [c.role, c.count])); 311 | 312 | const werewolves = roleMap.get(Role.WEREWOLF) || 0; 313 | const goodPlayers = playerCount - werewolves; 314 | 315 | expect(goodPlayers).toBeGreaterThan(werewolves); 316 | }); 317 | }); 318 | }); 319 | 320 | describe('Return Value Structure', () => { 321 | test('should return array of RoleConfig objects', () => { 322 | const config = RoleAssignment.getDefaultRoleConfig(8); 323 | 324 | expect(Array.isArray(config)).toBe(true); 325 | 326 | config.forEach(roleConfig => { 327 | expect(roleConfig).toHaveProperty('role'); 328 | expect(roleConfig).toHaveProperty('count'); 329 | expect(Object.values(Role)).toContain(roleConfig.role); 330 | expect(typeof roleConfig.count).toBe('number'); 331 | expect(roleConfig.count).toBeGreaterThan(0); 332 | }); 333 | }); 334 | 335 | test('should have unique roles in configuration', () => { 336 | const config = RoleAssignment.getDefaultRoleConfig(12); 337 | const roles = config.map(c => c.role); 338 | const uniqueRoles = [...new Set(roles)]; 339 | 340 | expect(uniqueRoles.length).toBe(roles.length); 341 | }); 342 | 343 | test('should include all expected role types', () => { 344 | const config = RoleAssignment.getDefaultRoleConfig(10); 345 | const roles = config.map(c => c.role); 346 | 347 | expect(roles).toContain(Role.WEREWOLF); 348 | expect(roles).toContain(Role.SEER); 349 | expect(roles).toContain(Role.VILLAGER); 350 | // Witch should be included for 10 players 351 | expect(roles).toContain(Role.WITCH); 352 | }); 353 | }); 354 | }); 355 | }); -------------------------------------------------------------------------------- /shared/lib/src/__tests__/operationLog.bun.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe, beforeEach, afterEach, spyOn } from 'bun:test'; 2 | import { OperationLogSystem, type OperationLog } from '../operationLog'; 3 | 4 | // Mock MobX 5 | const mockMakeAutoObservable = () => {}; 6 | globalThis.require = { 7 | ...globalThis.require, 8 | cache: {} 9 | } as any; 10 | 11 | // Create a mock for mobx 12 | const mockMobx = { 13 | makeAutoObservable: mockMakeAutoObservable 14 | }; 15 | 16 | // Mock the mobx module 17 | import.meta.resolve = (specifier: string) => { 18 | if (specifier === 'mobx') { 19 | return 'mocked-mobx'; 20 | } 21 | return specifier; 22 | }; 23 | 24 | describe('OperationLogSystem', () => { 25 | let logSystem: OperationLogSystem; 26 | let consoleSpy: any; 27 | 28 | beforeEach(() => { 29 | logSystem = new OperationLogSystem(); 30 | consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); 31 | }); 32 | 33 | afterEach(() => { 34 | consoleSpy.mockRestore(); 35 | }); 36 | 37 | describe('Constructor', () => { 38 | test('should initialize with empty logs and zero sequence counter', () => { 39 | const system = new OperationLogSystem(); 40 | expect(system.getLogs()).toEqual([]); 41 | }); 42 | 43 | test('should call makeAutoObservable on construction', () => { 44 | const system = new OperationLogSystem(); 45 | // We can't directly test if makeAutoObservable was called, 46 | // but we can verify the object exists and functions work 47 | expect(system).toBeInstanceOf(OperationLogSystem); 48 | }); 49 | }); 50 | 51 | describe('addLog()', () => { 52 | test('should add a log with generated id, timestamp, and sequence', () => { 53 | const logData = { 54 | type: 'system_action' as const, 55 | message: 'Test log message', 56 | details: { playerId: 1 } 57 | }; 58 | 59 | logSystem.addLog(logData); 60 | const logs = logSystem.getLogs(); 61 | 62 | expect(logs).toHaveLength(1); 63 | expect(logs[0]).toMatchObject({ 64 | type: 'system_action', 65 | message: 'Test log message', 66 | details: { playerId: 1 }, 67 | sequence: 0 68 | }); 69 | expect(logs[0].id).toBeDefined(); 70 | expect(logs[0].timestamp).toBeInstanceOf(Date); 71 | }); 72 | 73 | test('should increment sequence number for multiple logs', () => { 74 | logSystem.addLog({ type: 'system_action', message: 'First log' }); 75 | logSystem.addLog({ type: 'system_action', message: 'Second log' }); 76 | logSystem.addLog({ type: 'system_action', message: 'Third log' }); 77 | 78 | const logs = logSystem.getLogs(); 79 | expect(logs[0].sequence).toBe(0); 80 | expect(logs[1].sequence).toBe(1); 81 | expect(logs[2].sequence).toBe(2); 82 | }); 83 | 84 | test('should generate unique IDs for logs', () => { 85 | logSystem.addLog({ type: 'system_action', message: 'Log 1' }); 86 | logSystem.addLog({ type: 'system_action', message: 'Log 2' }); 87 | 88 | const logs = logSystem.getLogs(); 89 | expect(logs[0].id).not.toBe(logs[1].id); 90 | }); 91 | 92 | test('should log to console when adding logs', () => { 93 | logSystem.addLog({ type: 'system_action', message: 'Test message' }); 94 | 95 | expect(consoleSpy).toHaveBeenCalledWith( 96 | '🔍 addLog called:', 97 | 'Test message', 98 | 'total logs:', 99 | 1 100 | ); 101 | }); 102 | 103 | test('should handle logs without details', () => { 104 | logSystem.addLog({ type: 'phase_change', message: 'Simple log' }); 105 | 106 | const logs = logSystem.getLogs(); 107 | expect(logs[0].details).toBeUndefined(); 108 | }); 109 | 110 | test('should handle logs with empty details', () => { 111 | logSystem.addLog({ 112 | type: 'phase_change', 113 | message: 'Log with empty details', 114 | details: {} 115 | }); 116 | 117 | const logs = logSystem.getLogs(); 118 | expect(logs[0].details).toEqual({}); 119 | }); 120 | }); 121 | 122 | describe('getLogs()', () => { 123 | test('should return empty array initially', () => { 124 | expect(logSystem.getLogs()).toEqual([]); 125 | }); 126 | 127 | test('should return copy of logs array', () => { 128 | logSystem.addLog({ type: 'system_action', message: 'Test' }); 129 | 130 | const logs1 = logSystem.getLogs(); 131 | const logs2 = logSystem.getLogs(); 132 | 133 | expect(logs1).toEqual(logs2); 134 | expect(logs1).not.toBe(logs2); // Different array instances 135 | }); 136 | 137 | test('should return logs in chronological order', () => { 138 | logSystem.addLog({ type: 'system_action', message: 'First' }); 139 | logSystem.addLog({ type: 'system_action', message: 'Second' }); 140 | logSystem.addLog({ type: 'system_action', message: 'Third' }); 141 | 142 | const logs = logSystem.getLogs(); 143 | expect(logs[0].message).toBe('First'); 144 | expect(logs[1].message).toBe('Second'); 145 | expect(logs[2].message).toBe('Third'); 146 | }); 147 | }); 148 | 149 | describe('getRecentLogs()', () => { 150 | beforeEach(() => { 151 | // Add multiple logs for testing 152 | logSystem.addLog({ type: 'system_action', message: 'Log 1' }); 153 | logSystem.addLog({ type: 'system_action', message: 'Log 2' }); 154 | logSystem.addLog({ type: 'system_action', message: 'Log 3' }); 155 | logSystem.addLog({ type: 'system_action', message: 'Log 4' }); 156 | logSystem.addLog({ type: 'system_action', message: 'Log 5' }); 157 | }); 158 | 159 | test('should return last N logs', () => { 160 | const recentLogs = logSystem.getRecentLogs(3); 161 | 162 | expect(recentLogs).toHaveLength(3); 163 | expect(recentLogs[0].message).toBe('Log 3'); 164 | expect(recentLogs[1].message).toBe('Log 4'); 165 | expect(recentLogs[2].message).toBe('Log 5'); 166 | }); 167 | 168 | test('should return all logs if count exceeds total', () => { 169 | const recentLogs = logSystem.getRecentLogs(10); 170 | 171 | expect(recentLogs).toHaveLength(5); 172 | expect(recentLogs[0].message).toBe('Log 1'); 173 | }); 174 | 175 | test('should return empty array if count is 0', () => { 176 | const recentLogs = logSystem.getRecentLogs(0); 177 | expect(recentLogs).toEqual([]); 178 | }); 179 | 180 | test('should handle negative count', () => { 181 | const recentLogs = logSystem.getRecentLogs(-1); 182 | expect(recentLogs).toEqual([]); 183 | }); 184 | 185 | test('should return empty array if no logs exist', () => { 186 | const emptySystem = new OperationLogSystem(); 187 | const recentLogs = emptySystem.getRecentLogs(5); 188 | expect(recentLogs).toEqual([]); 189 | }); 190 | }); 191 | 192 | describe('clearLogs()', () => { 193 | test('should clear all logs and reset sequence counter', () => { 194 | logSystem.addLog({ type: 'system_action', message: 'Test 1' }); 195 | logSystem.addLog({ type: 'system_action', message: 'Test 2' }); 196 | 197 | expect(logSystem.getLogs()).toHaveLength(2); 198 | 199 | logSystem.clearLogs(); 200 | 201 | expect(logSystem.getLogs()).toEqual([]); 202 | 203 | // Verify sequence counter is reset 204 | logSystem.addLog({ type: 'system_action', message: 'After clear' }); 205 | const logs = logSystem.getLogs(); 206 | expect(logs[0].sequence).toBe(0); 207 | }); 208 | 209 | test('should allow adding logs after clearing', () => { 210 | logSystem.addLog({ type: 'system_action', message: 'Before clear' }); 211 | logSystem.clearLogs(); 212 | logSystem.addLog({ type: 'system_action', message: 'After clear' }); 213 | 214 | const logs = logSystem.getLogs(); 215 | expect(logs).toHaveLength(1); 216 | expect(logs[0].message).toBe('After clear'); 217 | }); 218 | }); 219 | 220 | describe('logPhaseChange()', () => { 221 | test('should log phase change with correct format', () => { 222 | logSystem.logPhaseChange('夜晚', 2); 223 | 224 | const logs = logSystem.getLogs(); 225 | expect(logs).toHaveLength(1); 226 | expect(logs[0]).toMatchObject({ 227 | type: 'phase_change', 228 | message: '🔄 游戏进入夜晚阶段(第2天)', 229 | details: { phase: '夜晚' } 230 | }); 231 | }); 232 | 233 | test('should handle different phases and day counts', () => { 234 | logSystem.logPhaseChange('白天', 1); 235 | logSystem.logPhaseChange('投票', 3); 236 | 237 | const logs = logSystem.getLogs(); 238 | expect(logs[0].message).toBe('🔄 游戏进入白天阶段(第1天)'); 239 | expect(logs[1].message).toBe('🔄 游戏进入投票阶段(第3天)'); 240 | }); 241 | }); 242 | 243 | describe('logPlayerRequest()', () => { 244 | test('should log player request with correct format', () => { 245 | logSystem.logPlayerRequest(5, '发言'); 246 | 247 | const logs = logSystem.getLogs(); 248 | expect(logs).toHaveLength(1); 249 | expect(logs[0]).toMatchObject({ 250 | type: 'player_request', 251 | message: '📤 询问玩家5 发言', 252 | details: { playerId: 5, actionType: '发言' } 253 | }); 254 | }); 255 | 256 | test('should handle different player IDs and action types', () => { 257 | logSystem.logPlayerRequest(1, '投票'); 258 | logSystem.logPlayerRequest(3, '使用技能'); 259 | 260 | const logs = logSystem.getLogs(); 261 | expect(logs[0].details).toEqual({ playerId: 1, actionType: '投票' }); 262 | expect(logs[1].details).toEqual({ playerId: 3, actionType: '使用技能' }); 263 | }); 264 | }); 265 | 266 | describe('logPlayerResponse()', () => { 267 | test('should log player response without result', () => { 268 | logSystem.logPlayerResponse(2, '发言'); 269 | 270 | const logs = logSystem.getLogs(); 271 | expect(logs).toHaveLength(1); 272 | expect(logs[0]).toMatchObject({ 273 | type: 'player_response', 274 | message: '📥 玩家2 发言完成', 275 | details: { playerId: 2, actionType: '发言' } 276 | }); 277 | }); 278 | 279 | test('should log player response with result', () => { 280 | logSystem.logPlayerResponse(4, '投票', '投给玩家3'); 281 | 282 | const logs = logSystem.getLogs(); 283 | expect(logs[0]).toMatchObject({ 284 | type: 'player_response', 285 | message: '📥 玩家4 投票完成: 投给玩家3', 286 | details: { playerId: 4, actionType: '投票', result: '投给玩家3' } 287 | }); 288 | }); 289 | 290 | test('should handle empty result string', () => { 291 | logSystem.logPlayerResponse(1, '技能', ''); 292 | 293 | const logs = logSystem.getLogs(); 294 | expect(logs[0].message).toBe('📥 玩家1 技能完成'); 295 | }); 296 | }); 297 | 298 | describe('logSystemAction()', () => { 299 | test('should log system action with message prefix', () => { 300 | logSystem.logSystemAction('游戏开始'); 301 | 302 | const logs = logSystem.getLogs(); 303 | expect(logs).toHaveLength(1); 304 | expect(logs[0]).toMatchObject({ 305 | type: 'system_action', 306 | message: '⚙️ 游戏开始' 307 | }); 308 | }); 309 | 310 | test('should log system action with details', () => { 311 | const details = { gameId: 'test-123', playerCount: 6 }; 312 | logSystem.logSystemAction('初始化游戏', details); 313 | 314 | const logs = logSystem.getLogs(); 315 | expect(logs[0]).toMatchObject({ 316 | type: 'system_action', 317 | message: '⚙️ 初始化游戏', 318 | details 319 | }); 320 | }); 321 | 322 | test('should handle system action without details', () => { 323 | logSystem.logSystemAction('系统消息'); 324 | 325 | const logs = logSystem.getLogs(); 326 | expect(logs[0].details).toBeUndefined(); 327 | }); 328 | }); 329 | 330 | describe('logResult()', () => { 331 | test('should log result with message prefix', () => { 332 | logSystem.logResult('投票结果:玩家3被淘汰'); 333 | 334 | const logs = logSystem.getLogs(); 335 | expect(logs).toHaveLength(1); 336 | expect(logs[0]).toMatchObject({ 337 | type: 'result', 338 | message: '📊 投票结果:玩家3被淘汰' 339 | }); 340 | }); 341 | 342 | test('should log result with details', () => { 343 | const details = { eliminatedPlayer: 3, votes: { 3: 4, 2: 1 } }; 344 | logSystem.logResult('投票统计完成', details); 345 | 346 | const logs = logSystem.getLogs(); 347 | expect(logs[0]).toMatchObject({ 348 | type: 'result', 349 | message: '📊 投票统计完成', 350 | details 351 | }); 352 | }); 353 | }); 354 | 355 | describe('logPhaseComplete()', () => { 356 | test('should log phase completion with default message', () => { 357 | logSystem.logPhaseComplete('白天'); 358 | 359 | const logs = logSystem.getLogs(); 360 | expect(logs).toHaveLength(1); 361 | expect(logs[0]).toMatchObject({ 362 | type: 'system_action', 363 | message: '✅ 白天阶段完成,可以进入下一阶段', 364 | details: { phase: '白天' } 365 | }); 366 | }); 367 | 368 | test('should log phase completion with custom message', () => { 369 | logSystem.logPhaseComplete('夜晚', '所有夜间行动已完成'); 370 | 371 | const logs = logSystem.getLogs(); 372 | expect(logs[0]).toMatchObject({ 373 | type: 'system_action', 374 | message: '所有夜间行动已完成', 375 | details: { phase: '夜晚' } 376 | }); 377 | }); 378 | 379 | test('should handle empty custom message', () => { 380 | logSystem.logPhaseComplete('投票', ''); 381 | 382 | const logs = logSystem.getLogs(); 383 | expect(logs[0].message).toBe('✅ 投票阶段完成,可以进入下一阶段'); 384 | }); 385 | }); 386 | 387 | describe('Edge Cases and Error Handling', () => { 388 | test('should handle very long messages', () => { 389 | const longMessage = 'x'.repeat(1000); 390 | logSystem.addLog({ type: 'system_action', message: longMessage }); 391 | 392 | const logs = logSystem.getLogs(); 393 | expect(logs[0].message).toBe(longMessage); 394 | }); 395 | 396 | test('should handle special characters in messages', () => { 397 | const specialMessage = '特殊字符测试 🎮🐺🔮💀 \n\t\r'; 398 | logSystem.addLog({ type: 'system_action', message: specialMessage }); 399 | 400 | const logs = logSystem.getLogs(); 401 | expect(logs[0].message).toBe(specialMessage); 402 | }); 403 | 404 | test('should handle undefined values in details', () => { 405 | logSystem.addLog({ 406 | type: 'system_action', 407 | message: 'Test', 408 | details: { 409 | playerId: undefined, 410 | phase: undefined, 411 | actionType: undefined 412 | } 413 | }); 414 | 415 | const logs = logSystem.getLogs(); 416 | expect(logs[0].details).toEqual({ 417 | playerId: undefined, 418 | phase: undefined, 419 | actionType: undefined 420 | }); 421 | }); 422 | 423 | test('should handle multiple rapid log additions', () => { 424 | const startTime = Date.now(); 425 | 426 | for (let i = 0; i < 100; i++) { 427 | logSystem.addLog({ type: 'system_action', message: `Rapid log ${i}` }); 428 | } 429 | 430 | const logs = logSystem.getLogs(); 431 | expect(logs).toHaveLength(100); 432 | 433 | // Verify all sequences are unique and in order 434 | const sequences = logs.map(log => log.sequence); 435 | expect(sequences).toEqual([...Array(100).keys()]); 436 | }); 437 | 438 | test('should handle concurrent log operations', () => { 439 | // Simulate concurrent operations 440 | const promises = []; 441 | for (let i = 0; i < 10; i++) { 442 | promises.push(Promise.resolve().then(() => { 443 | logSystem.addLog({ type: 'system_action', message: `Async log ${i}` }); 444 | })); 445 | } 446 | 447 | Promise.all(promises).then(() => { 448 | const logs = logSystem.getLogs(); 449 | expect(logs).toHaveLength(10); 450 | 451 | // All logs should have unique sequences 452 | const sequences = logs.map(log => log.sequence); 453 | const uniqueSequences = [...new Set(sequences)]; 454 | expect(uniqueSequences).toHaveLength(10); 455 | }); 456 | }); 457 | }); 458 | 459 | describe('Log Type Coverage', () => { 460 | test('should handle all log types', () => { 461 | const logTypes = [ 462 | 'phase_change', 463 | 'player_request', 464 | 'player_response', 465 | 'system_action', 466 | 'result' 467 | ] as const; 468 | 469 | logTypes.forEach((type, index) => { 470 | logSystem.addLog({ 471 | type, 472 | message: `Message for ${type}`, 473 | details: { playerId: index } 474 | }); 475 | }); 476 | 477 | const logs = logSystem.getLogs(); 478 | expect(logs).toHaveLength(5); 479 | 480 | logTypes.forEach((type, index) => { 481 | expect(logs[index].type).toBe(type); 482 | }); 483 | }); 484 | }); 485 | }); --------------------------------------------------------------------------------