├── 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 |
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 | });
--------------------------------------------------------------------------------