diff --git a/.gitignore b/.gitignore index 8159dac..b56beba 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ __pycache__/ .Python build/ develop-eggs/ -dist/ + downloads/ eggs/ .eggs/ @@ -165,5 +165,11 @@ cython_debug/ # Node.js node_modules/ dist/ + + *.log .env + +# Runtime / generated artifacts +game_records/ +backups/ diff --git a/client/src/components/GameTable.vue b/client/src/components/GameTable.vue index 4609f51..7e3f813 100644 --- a/client/src/components/GameTable.vue +++ b/client/src/components/GameTable.vue @@ -1,6 +1,6 @@ @@ -143,4 +210,4 @@ const challenge = () => { background: #16213e; border: 1px solid #333; } - + \ No newline at end of file diff --git a/client/src/stores/game.js b/client/src/stores/game.js index 54e7034..0026264 100644 --- a/client/src/stores/game.js +++ b/client/src/stores/game.js @@ -8,6 +8,9 @@ export const useGameStore = defineStore('game', { roomId: null, gameState: null, room: null, + yourHand: [], + lastMessage: '', + lastChallenge: null, // { challengedGroup, isBluff, challengerId, shooterId, systemChallenge } isConnected: false, error: null }), @@ -15,7 +18,8 @@ export const useGameStore = defineStore('game', { actions: { // 連線到伺服器 connect() { - this.socket = io('http://localhost:3000', { + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3000' + this.socket = io(serverUrl, { transports: ['websocket'] }) @@ -43,23 +47,66 @@ export const useGameStore = defineStore('game', { this.socket.on('playerJoined', (room) => { this.room = room + this.roomId = room.id }) this.socket.on('gameStarted', (gameState) => { this.gameState = gameState + this.lastChallenge = null + this.lastMessage = '' }) this.socket.on('cardPlayed', (data) => { this.gameState = data.gameState + if (data.systemChallenge) { + // 系統質疑在出牌事件一起揭露 + const sc = data.systemChallenge + this.lastChallenge = { + systemChallenge: true, + challengerId: 'system', + claimantId: sc.playerId, + shooterId: sc.shooter, + isBluff: sc.isBluff, + challengedGroup: sc.autoPlayedCards || [] + } + this.lastMessage = sc.isBluff + ? '系統質疑:該玩家吹牛,被處罰!' + : '系統質疑:該玩家說實話,無人受傷。' + } }) this.socket.on('challenged', (data) => { this.gameState = data.gameState + if (data.result) { + const r = data.result + this.lastChallenge = { + systemChallenge: !!r.systemChallenge, + challengerId: r.challengerId, + claimantId: r.playerId, + shooterId: r.shooter, + isBluff: r.isBluff, + challengedGroup: r.challengedGroup || [] + } + if (r.systemChallenge) { + this.lastMessage = r.isBluff + ? '系統質疑:該玩家吹牛,被處罰!' + : '系統質疑:該玩家說實話,無人受傷。' + } else if (r.isBluff) { + this.lastMessage = '質疑成功!對方吹牛,開槍處罰。' + } else { + this.lastMessage = '質疑失敗!對方說的是真話,換你開槍。' + } + } }) this.socket.on('playerDisconnected', (data) => { this.room = data.room }) + + // 私密手牌:只送給自己 + this.socket.on('yourHand', (hand) => { + this.yourHand = hand || [] + }) }, // 建立房間 @@ -78,10 +125,10 @@ export const useGameStore = defineStore('game', { }, // 出牌 - playCard(cardIndex) { - this.socket.emit('playCard', { - roomId: this.roomId, - cardIndex + playCard(cardIndices) { + this.socket.emit('playCard', { + roomId: this.roomId, + cardIndices }) }, diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..496a825 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve" + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue" + ], + "references": [] +} \ No newline at end of file diff --git a/docs/AI繪圖角色一致性完整調查報告.md b/docs/AI繪圖角色一致性完整調查報告.md new file mode 100644 index 0000000..5e8fd6e --- /dev/null +++ b/docs/AI繪圖角色一致性完整調查報告.md @@ -0,0 +1,366 @@ +# AI 繪圖角色一致性完整調查報告 + +> **任務**:從 8 個 Q 版女性角色的基礎立繪生成更多表情和動作變體,用於網頁遊戲前端動畫 +> **調查日期**:2026-07-30 +> **核心需求**:角色一致性(character consistency)— 保持同一角色的臉、髮型、服裝在不同表情/動作下高度一致 + +--- + +## 一、工具比較總覽 + +| 工具 | 角色一致性 | 姿態控制 | 學習曲線 | 成本 | 需 GPU | 批量生產 | 推薦程度 | +|------|:---------:|:-------:|:-------:|------|:-----:|:-------:|:-------:| +| **ComfyUI + IP-Adapter** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 高 | 免費 | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| **SD + LoRA** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 中高 | 免費 | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | +| **Midjourney v6.1** | ⭐⭐⭐⭐ | ⭐⭐ | 低 | $10-60/月 | ❌ | ⭐⭐ | ⭐⭐⭐ | +| **Leonardo AI** | ⭐⭐⭐⭐ | ⭐⭐⭐ | 低 | 免費-$48/月 | ❌ | ⭐⭐⭐ | ⭐⭐⭐ | +| **Fooocus** | ⭐⭐⭐ | ⭐⭐ | 最低 | 免費 | ✅ | ⭐⭐ | ⭐⭐ | +| **LiblibAI / SeaArt** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 低中 | 免費-$20/月 | ❌ | ⭐⭐⭐ | ⭐⭐⭐⭐ | + +--- + +## 二、角色一致性技術比較 + +| 技術 | Q 版適合度 | 需要參考圖 | 學習曲線 | GPU VRAM | 角色一致性 | 關鍵結論 | +|------|:---------:|:---------:|:-------:|:--------:|:---------:|---------| +| **IP-Adapter** | ★★★★★ | 1 張 | 中等 | 8GB+ | ⭐⭐⭐⭐⭐ | 最佳選擇,1 張圖零訓練即可保持角色一致性 | +| **ControlNet (DWpose)** | ★★★★★ | 姿態參考圖 | 中等 | 8GB+ | ⭐⭐⭐⭐⭐ | 姿勢控制核心,DWpose 比 OpenPose 更適合 Q 版比例 | +| **LoRA** | ★★★★☆ | 15-30 張 | 中高 | 8GB+ | ⭐⭐⭐⭐⭐ | 效果最佳但需大量訓練圖,可用 IP-Adapter 先生成訓練資料 | +| **InstantID** | ★★☆☆☆ | 1 張 | 高 | 12GB+ | ⭐⭐☆☆☆ | 依賴真實人臉識別,Q 版面部無法被 antelopev2 偵測 | +| **PhotoMaker** | ★★☆☆☆ | 1-5 張 | 高 | 12GB+ | ⭐⭐☆☆☆ | 同上,不適用 Q 版 | +| **ReActor/ROOP** | ★★☆☆☆ | 1 張 | 低 | 4GB+ | ⭐⭐☆☆☆ | 僅換臉,無法保持整體角色風格 | + +### HuggingFace 下載量佐證 + +- IP-Adapter-FaceID: **186K 下載** +- ControlNet-OpenPose-SDXL: **70K 下載** +- InstantID: **54K 下載** + +--- + +## 三、最佳推薦方案 + +### 🥇 首選:ComfyUI + IP-Adapter + ControlNet DWpose + +**推薦原因:** +1. ✅ 1 張立繪即可開始,零訓練成本 +2. ✅ DWpose 專為 Q 版/動漫比例優化 +3. ✅ 完全免費、開源 +4. ✅ 支援批量生產 +5. ✅ 中文社群資源最強 + +**硬體需求:** +- 最低:RTX 3060 12GB +- 推薦:RTX 4070 12GB+ + +**推薦工作流:** +``` +Load Image (角色立繪) → IP-Adapter FaceID Plus v2 → KSampler (SDXL) + ↗ +Load Image (姿態參考) → ControlNet DWpose → KSampler +``` + +### 🥈 備用方案 A:LiblibAI / SeaArt(雲端版 ComfyUI) + +**適用情境**:沒有 GPU 或不想安裝複雜環境 + +**優點:** +- 雲端運行,無需 GPU +- 內建 IP-Adapter + ControlNet,角色一致性接近 ComfyUI +- LiblibAI 是中國平台,中文介面 + 大量中文模型 +- 免費層可先測試效果 + +### 🥉 備用方案 B:Midjourney v6.1(快速原型) + +**適用情境**:快速測試角色變體概念,不做大量生產 + +**缺點**:無法精確控制姿態(對遊戲動畫是致命傷);Q 版角色 `--cref` 效果不穩定 + +--- + +## 四、批量生產工作流 + +### 階段 1:快速驗證(1-2 天) + +``` +├─ 安裝 ComfyUI + ComfyUI-Manager +├─ 下載必要模型: +│ ├─ 底層模型:SDXL 1.0 或動漫專用模型(Anything V5、CounterfeitXL) +│ ├─ IP-Adapter:ip-adapter-plus-face_sdxl_vit-h.bin +│ └─ ControlNet:controlnet-dwpose-sdxl +├─ 選 1-2 個角色測試(建議先試 Grok-4 和 Deepseek) +├─ 確認角色一致性效果是否達標 +└─ 若不達標 → 切換 LiblibAI 雲端測試 +``` + +### 階段 2:批量生產(3-5 天) + +``` +├─ 為 8 個角色各建立 ComfyUI 工作流 +├─ 定義表情清單: +│ ├─ 開心、生氣、疑惑、恐懼、得意、無聊(6 種) +├─ 定義動作清單: +│ ├─ 出牌、質疑、舉槍、中槍、勝利、失敗(6 種) +├─ 每個角色共 12 張變體圖 +├─ 8 角色 × 12 張 = 96 張(約 100 張) +├─ 批次生成所有變體 +└─ 篩選、後處理 +``` + +### 階段 3(可選):LoRA 訓練(長期投資) + +``` +├─ 用階段 2 的產出作為訓練素材 +├─ 為每個角色訓練專屬 LoRA +└─ 之後可無限生成新變體,一致性更高 +``` + +--- + +## 五、Prompt 工程指南 + +### 角色描述 Prompt 模板 + +``` +[角色特徵描述], [表情], [動作], [背景], [風格] + +範例(Grok-4): +chibi girl, orange wavy hair, yellow sleeveless dress, large red bow on head, +big smile expression, holding cards in hand, white background, +anime style, cel shading, cute, kawaii +``` + +### 表情 Prompt 清單 + +| 表情 | Prompt 關鍵字 | +|------|--------------| +| 開心 | big smile, happy, joyful, laughing | +| 生氣 | angry, frowning, mad, crossed arms | +| 疑惑 | confused, tilted head, questioning, thinking | +| 恐懼 | scared, terrified, wide eyes, trembling | +| 得意 | smug, cocky, confident smirk, arms crossed | +| 無聊 | bored, deadpan, blank stare, yawning | + +### 動作 Prompt 清單 + +| 動作 | Prompt 關鍵字 | +|------|--------------| +| 出牌 | playing cards, holding cards, dealing cards | +| 質疑 | pointing finger, accusing, challenging pose | +| 舉槍 | holding revolver, pointing gun, russian roulette | +| 中槍 | shocked, falling back, clutching head | +| 勝利 | celebrating, raising arms, triumphant pose | +| 失敗 | defeated, slumped, sad pose, head down | + +--- + +## 六、Seed 控制策略 + +### 保持角色一致性的 Seed 策略 + +1. **固定 Seed + 固定 IP-Adapter 權重**: + - 同一角色的所有變體使用相同 seed + - IP-Adapter weight 設為 0.7-0.9 + - 僅改變 prompt 中的表情/動作描述 + +2. **Seed 範圍搜索**: + - 找到一個好的 seed 後,在 ±100 範圍內搜索 + - 例如 seed=12345 效果好,則測試 12245-12445 + +3. **Seed 記錄表**: + ``` + 角色 | 最佳 Seed | IP-Adapter Weight | ControlNet Strength + Grok-4 | 12345 | 0.8 | 0.8 + Kimi | 67890 | 0.75 | 0.85 + ... + ``` + +--- + +## 七、批量生成腳本 + +### ComfyUI REST API 批量生成 + +```python +#!/usr/bin/env python3 +""" +batch_generate.py — 批量生成角色表情/動作變體 + +使用 ComfyUI REST API 自動執行工作流 +""" +import requests +import json +import time + +COMFYUI_URL = "http://12.0.0.1:8188" + +def submit_workflow(workflow_json): + """提交工作流到 ComfyUI""" + response = requests.post( + f"{COMFYUI_URL}/prompt", + json={"prompt": workflow_json} + ) + return response.json()["prompt_id"] + +def get_image(prompt_id): + """等待生成完成並取得圖片""" + while True: + ws = requests.get(f"{COMFYUI_URL}/history/{prompt_id}") + if ws.json(): + return ws.json()[prompt_id]["outputs"] + time.sleep(1) + +# 定義 8 個角色的 prompt 模板 +CHARACTERS = { + "Grok-4": "chibi girl, orange wavy hair, yellow sleeveless dress, large red bow on head, ...", + "Kimi": "chibi girl, silver long straight hair, dark blue dress with white collar, choker, ...", + # ... 其他角色 +} + +EXPRESSIONS = ["happy", "angry", "confused", "scared", "smug", "bored"] +ACTIONS = ["playing_cards", "accusing", "holding_revolver", "shocked", "celebrating", "defeated"] + +# 批量生成 +for char_name, char_desc in CHARACTERS.items(): + for expr in EXPRESSIONS: + for action in ACTIONS: + prompt = f"{char_desc}, {expr} expression, {action} pose, white background, anime style" + # 修改 workflow_json 中的 prompt 節點 + workflow_json["6"]["inputs"]["text"] = prompt + prompt_id = submit_workflow(workflow_json) + print(f"生成中: {char_name} - {expr} - {action}") + time.sleep(2) # 等待 GPU 處理 +``` + +--- + +## 八、品質篩選流程 + +### 自動篩選標準 + +| 檢查項目 | 標準 | 工具 | +|---------|------|------| +| 解析度 | 768x768 以上 | 檔案屬性檢查 | +| 角色識別 | 面部特徵匹配度 > 80% | CLIP 影像嵌入比對 | +| 表情正確性 | 表情關鍵字匹配 | 手動檢查 | +| 動作正確性 | 姿態匹配度 > 70% | OpenPose 偵測比對 | +| 無畸形 | 手部、臉部無明顯變形 | 手動檢查 | + +### 手動篩選清單 + +- [ ] 角色髮色/髮型是否正確? +- [ ] 角色服裝是否正確? +- [ ] 表情是否符合預期? +- [ ] 動作是否符合預期? +- [ ] 手部是否畸形? +- [ ] 臉部是否變形? +- [ ] 配件是否遺漏? + +--- + +## 九、後處理流程 + +### 工具推薦 + +| 步驟 | 工具 | 說明 | +|------|------|------| +| 背景移除 | remove.bg / RemBG | 自動移除背景,輸出透明 PNG | +| 裁切 | ImageMagick / Python PIL | 統一裁切至 512x512 或 768x768 | +| 格式轉換 | ImageMagick | 轉換為 WebP 格式(網頁優化) | +| 超解析度 | Real-ESRGAN | 將低解析度圖片提升至 4K | + +### 後處理腳本範例 + +```bash +# 批量移除背景 +python -m rembg i input/ -o output/ + +# 批量裁切並轉換格式 +magick mogrify -resize 512x512 -format webp output/*.png +``` + +--- + +## 十、成本效益分析 + +### 時間成本估算 + +| 階段 | 時間 | 說明 | +|------|------|------| +| 環境設定 | 1-2 天 | 安裝 ComfyUI、下載模型、設定工作流 | +| 快速驗證 | 1-2 天 | 測試 1-2 個角色,確認效果 | +| 批量生產 | 3-5 天 | 生成 100 張圖,每張約 10-30 秒 | +| 篩選後處理 | 1-2 天 | 手動篩選、背景移除、格式轉換 | +| **總計** | **6-11 天** | | + +### 金錢成本估算 + +| 方案 | 硬體成本 | 月費 | 總成本 | +|------|---------|------|--------| +| ComfyUI(自架) | RTX 3060 12GB: ~$300 | $0 | $300 | +| LiblibAI(雲端) | $0 | 免費層 | $0(測試) | +| LiblibAI(雲端) | $0 | 付費層 $15/月 | $30(2 個月) | +| Midjourney | $0 | $10/月 | $20(2 個月) | + +### 推薦方案 + +**有 GPU**:ComfyUI + IP-Adapter + ControlNet(一次性投資 $300,之後免費) + +**無 GPU**:LiblibAI 免費層測試 → 確認效果 → 付費層批量生產($30) + +--- + +## 十一、8 角色具體生產計畫 + +### 角色清單 + +| 編號 | AI 名稱 | 髮色 | 服裝 | 配件 | +|------|---------|------|------|------| +| 1 | Grok-4 | 波浪橘髮 | 黃色無袖洋裝 | 紅色大蝴蝶結 | +| 2 | Kimi | 白銀長直髮 | 深藍洋裝+白領 | 項圈 | +| 3 | Qwen3 | 粉紅短髮 | 灰色無袖洋裝 | 圓眼鏡 | +| 4 | GPT-5 | 黑色長直髮 | 黑色洋裝 | 無 | +| 5 | Gemini | 紫色短髮 | 淺藍洋裝 | 圓眼鏡+星星髮夾 | +| 6 | Deepseek | 淺藍短髮 | 深藍灰洋裝 | 鯨魚尾巴 | +| 7 | Doubao | 深棕短髮 | 黑色洋裝 | 無 | +| 8 | Claude | 橘色短髮 | 橘色洋裝 | 無 | + +### 生產排程 + +| 天數 | 任務 | 產出 | +|------|------|------| +| Day 1-2 | 環境設定 + 模型下載 | ComfyUI 就緒 | +| Day 3-4 | 測試 Grok-4 + Deepseek | 確認效果 | +| Day 5-7 | 批量生產 4 角色(Grok/Kimi/Qwen/GPT-5) | 48 張圖 | +| Day 8-10 | 批量生產 4 角色(Gemini/Deepseek/Doubao/Claude) | 48 張圖 | +| Day 11-12 | 篩選 + 後處理 | 96 張最終圖 | + +--- + +## 十二、資源連結 + +- **ComfyUI 官方**:https://github.com/Comfy-Org/ComfyUI +- **ComfyUI 管理器**:https://github.com/ltdrdata/ComfyUI-Manager +- **IP-Adapter 官方**:https://github.com/tencent-ailab/IP-Adapter +- **ControlNet**:https://github.com/lllyasviel/ControlNet +- **LiblibAI**:https://www.liblib.art/ +- **SeaArt**:https://www.seaart.ai/ +- **Civitai(模型下載)**:https://civitai.com/ +- **Bilibili ComfyUI 教學**:搜尋「ComfyUI 角色一致性」 + +--- + +## 十三、常見陷阱與解決方案 + +| 陷阱 | 原因 | 解決方案 | +|------|------|---------| +| 角色不一致 | IP-Adapter weight 太低 | 調高至 0.7-0.9 | +| 姿態不正確 | ControlNet strength 太低 | 調高至 0.7-0.9 | +| 手部畸形 | SD 模型對手部支援差 | 使用 Hand Refiner 或手動修復 | +| 臉部變形 | IP-Adapter 與底層模型不兼容 | 使用 FaceID Plus v2 | +| 圖片模糊 | 解析度太低 | 使用 Real-ESRGAN 超解析 | +| 生成速度慢 | GPU VRAM 不足 | 降低解析度或使用 --lowvram | + +--- + +**報告完成**。下一步:確認是否開始安裝 ComfyUI 環境,或先使用 LiblibAI 雲端測試。 diff --git a/docs/ComfyUI角色一致性绘图操作文档.md b/docs/ComfyUI角色一致性绘图操作文档.md new file mode 100644 index 0000000..1aa325a --- /dev/null +++ b/docs/ComfyUI角色一致性绘图操作文档.md @@ -0,0 +1,821 @@ +# ComfyUI 角色一致性繪圖 — 完整操作文檔 + +> **目標**:使用 RTX A4000 + Windows 11,為 8 個 Q 版角色各生成 12-15 張表情/動作變體(總計約 100 張) +> +> **技術方案**:ComfyUI + IP-Adapter + ControlNet DWpose +> +> **硬體**:RTX A4000 (16GB VRAM) + Windows 11 +> +> **預估時間**:環境設定 2-3 小時,批量生產 3-5 小時 + +--- + +## 📋 目錄 + +1. [Plan A:一鍵安裝(推薦)](#1-plan-a一鍵安裝推薦) +2. [Plan B:手動安裝(進階)](#2-plan-b手動安裝進階) +3. [安裝擴充套件](#3-安裝擴充套件) +4. [下載模型權重](#4-下載模型權重) +5. [第一次測試](#5-第一次測試) +6. [IP-Adapter 角色一致性工作流](#6-ip-adapter-角色一致性工作流) +7. [ControlNet DWpose 姿勢控制](#7-controlnet-dwpose-姿勢控制) +8. [批量生產流程](#8-批量生產流程) +9. [Prompt 工程指南](#9-prompt-工程指南) +10. [品質篩選與後處理](#10-品質篩選與後處理) +11. [常見問題與解決方案](#11-常見問題與解決方案) +12. [資源連結](#12-資源連結) + +--- + +## 1. Plan A:一鍵安裝(推薦) + +> ✅ **適合對象**:不想裝 Python、不想打命令列、想最快開始的人 +> +> ⏱️ **預估時間**:15 分鐘 + +### 1.1 確認 NVIDIA 驅動 + +按 `Win + R`,輸入 `cmd`,執行: + +``` +nvidia-smi +``` + +**要求**:驅動版本 ≥ 537.xx,CUDA 版本 ≥ 12.1 + +若版本過舊,前往 NVIDIA 官網下載: +- https://www.nvidia.com/Download/index.aspx + +### 1.2 下載 ComfyUI 官方 Portable 版 + +前往 Release 頁面: +- https://github.com/comfyanonymous/ComfyUI/releases + +下載 **ComfyUI_windows_portable_nvidia.7z**(約 2GB) + +### 1.3 解壓縮 + +1. 安裝 7-Zip(若沒有):https://www.7-zip.org/ +2. 右鍵點擊下載的 `.7z` 檔案 → 「解壓縮到 ComfyUI_windows_portable_nvidia\」 +3. 將整個資料夾移動到 `D:\ComfyUI_Setup\` + +### 1.4 啟動 + +進入 `D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\`,雙擊: + +``` +run_nvidia_gpu.bat +``` + +這會自動: +- 啟動 Python 虛擬環境 +- 啟動 ComfyUI 伺服器 +- 開啟瀏覽器到 `http://127.0.0.1:8188` + +**看到 ComfyUI 介面即表示成功!** 🎉 + +### 1.5 後續啟動 + +以後每次使用,只要雙擊 `run_nvidia_gpu.bat` 即可。 + +--- + +## 2. Plan B:手動安裝(進階) + +> ⚠️ **適合對象**:想要完全控制環境、需要自訂 Python 版本、或想從源碼編譯的人 +> +> ⏱️ **預估時間**:45-60 分鐘 + +### 2.1 確認 NVIDIA 驅動 + +```powershell +nvidia-smi +``` + +**要求**:驅動版本 ≥ 537.xx,CUDA 版本 ≥ 12.1 + +### 2.2 安裝 Python + +下載 Python **3.10** 或 **3.11**(不要 3.12+,部分擴充套件不相容) + +- 官網:https://www.python.org/downloads/ +- 安裝時勾選 **「Add Python to PATH」** + +```powershell +python --version +# 應顯示 Python 3.10.x 或 3.11.x +``` + +### 2.3 準備工作目錄 + +```powershell +D: +mkdir \ComfyUI_Setup +cd \ComfyUI_Setup +``` + +### 2.4 下載 ComfyUI + +```powershell +git clone https://github.com/comfyanonymous/ComfyUI.git +cd ComfyUI +``` + +### 2.5 建立虛擬環境 + +```powershell +python -m venv venv +.\venv\Scripts\activate +``` + +### 2.6 安裝 PyTorch(含 CUDA 支援) + +```powershell +pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 +``` + +**驗證 CUDA 是否可用**: + +```powershell +python -c "import torch; print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))" +# 應輸出: True +# NVIDIA RTX A4000 +``` + +### 2.7 安裝 ComfyUI 依賴 + +```powershell +pip install -r requirements.txt +``` + +### 2.8 啟動測試 + +```powershell +python main.py +``` + +開啟瀏覽器訪問 `http://127.0.0.1:8188`,看到 ComfyUI 介面即表示成功。 + +### 2.9 後續啟動 + +```powershell +cd D:\ComfyUI_Setup\ComfyUI +.\venv\Scripts\activate +python main.py +``` + +--- + +## 3. 安裝擴充套件 + +> 以下步驟 Plan A 和 Plan B 都適用 + +### 3.1 安裝 ComfyUI-Manager(套件管理器) + +**方法一:透過 Manager 一鍵安裝(推薦)** + +1. 開啟 ComfyUI 介面 +2. 點擊右上角 **「Manager」** +3. 搜尋 **「ComfyUI-Manager」** +4. 點擊 **「Install」** + +**方法二:手動安裝** + +```powershell +cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes +git clone https://github.com/ltdrdata/ComfyUI-Manager.git +``` + +**重啟 ComfyUI** 後,介面右上角會出現「Manager」按鈕。 + +### 3.2 安裝 IP-Adapter 擴充套件 + +**方法一:透過 Manager** + +1. 點擊 **「Manager」** → **「Install Custom Nodes」** +2. 搜尋 **「ComfyUI_IPAdapter_plus」** +3. 點擊 **「Install」** + +**方法二:手動安裝** + +```powershell +cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes +git clone https://github.com/cubiq/ComfyUI_IPAdapter_plus.git +``` + +### 3.3 安裝 ControlNet 輔助工具 + +**方法一:透過 Manager** + +1. 搜尋 **「comfyui_controlnet_aux」** +2. 點擊 **「Install」** + +**方法二:手動安裝** + +```powershell +cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes +git clone https://github.com/Fannovel16/comfyui_controlnet_aux.git +``` + +安裝依賴: + +```powershell +# Plan A 使用者:在 ComfyUI 資料夾內執行 python.exe +D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\python_embeded\python.exe -m pip install onnxruntime-gpu + +# Plan B 使用者: +pip install onnxruntime-gpu +``` + +### 3.4 安裝 Impact Pack(進階功能,可選) + +**方法一:透過 Manager** + +1. 搜尋 **「ComfyUI-Impact-Pack」** +2. 點擊 **「Install」** + +**方法二:手動安裝** + +```powershell +cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes +git clone https://github.com/ltdrdata/ComfyUI-Impact-Pack.git +``` + +### 3.5 重新安裝依賴 + +安裝完擴充套件後,重啟 ComfyUI,Manager 會提示安裝缺失的依賴,點擊 **「Install」** 即可。 + +### 3.6 重啟 ComfyUI + +關閉 ComfyUI 視窗,重新啟動。 + +--- + +## 4. 下載模型權重 + +> 以下步驟 Plan A 和 Plan B 都適用 + +### 4.1 SDXL 主模型 + +下載 **SDXL 1.0** 或 **SDXL Turbo**(推薦 SDXL 1.0 品質更好) + +**推薦模型**: +- `sd_xl_base_1.0.safetensors`(官方) +- `juggernautXL_v9.safetensors`(社群優化版) +- `realvisxlV40.safetensors`(寫實風格) + +**下載位置**: +``` +D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\checkpoints\ +``` + +**下載來源**: +- HuggingFace: https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0 +- Civitai: https://civitai.com/models/47707/juggernaut-xl + +### 4.2 IP-Adapter 權重 + +下載 **IP-Adapter Plus SDXL** 系列: + +``` +D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\ipadapter\ +``` + +**需要下載的檔案**: +- `ip-adapter-plus_sdxl_vit-h.safetensors` +- `ip-adapter-plus-face_sdxl_vit-h.safetensors` + +**下載來源**: +- HuggingFace: https://huggingface.co/h94/IP-Adapter-plus/tree/main/sdxl_models + +### 4.3 ControlNet 權重 + +下載 **ControlNet DWpose SDXL**: + +``` +D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\controlnet\ +``` + +**需要下載的檔案**: +- `controlnet-diffusers-dw-lllite-xl-diffusers-rank256.safetensors` +- 或 `thibaud/controlnet-openpose-sdxl-1.0` + +**下載來源**: +- HuggingFace: https://huggingface.co/Thibaud/controlnet-openpose-sdxl-1.0 + +### 4.4 VAE(可選) + +``` +D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\vae\ +``` + +**下載**:`sdxl_vae.safetensors` + +### 4.5 檔案結構確認 + +``` +D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\ +├── models\ +│ ├── checkpoints\ +│ │ └── sd_xl_base_1.0.safetensors +│ ├── ipadapter\ +│ │ ├── ip-adapter-plus_sdxl_vit-h.safetensors +│ │ └── ip-adapter-plus-face_sdxl_vit-h.safetensors +│ ├── controlnet\ +│ │ └── controlnet-diffusers-dw-lllite-xl-diffusers-rank256.safetensors +│ └── vae\ +│ └── sdxl_vae.safetensors +└── output\ +``` + +--- + +## 5. 第一次測試 + +### 5.1 載入預設工作流 + +1. 開啟 `http://127.0.0.1:8188` +2. 點擊左上角 **「Load」** 載入預設工作流 +3. 或手動建立以下節點: + +### 5.2 基本生成測試 + +``` +Load Checkpoint → CLIP Text Encode (Positive) → CLIP Text Encode (Negative) → EmptyLatentImage → KSampler → VAEDecode → Save Image +``` + +**測試 Prompt**: + +``` +Positive: a cute chibi girl, blue hair, smiling, white background, high quality +Negative: ugly, deformed, noisy, blurry, low quality, worst quality +``` + +**KSampler 參數**: +- Model: sd_xl_base_1.0 +- Sampler: dpmpp_2m +- Scheduler: karras +- Steps: 20-30 +- CFG: 5-7 +- Seed: random +- Width: 1024 +- Height: 1024 + +點擊 **「Queue Prompt」** 開始生成。 + +--- + +## 6. IP-Adapter 角色一致性工作流 + +### 6.1 工作流結構 + +``` +Load Checkpoint + ↓ +CLIP Vision Loader (ip-adapter-plus-face_sdxl_vit-h.bin) + ↓ +IPAdapter Model Loader + ↓ +Load Image (角色立繪) + ↓ +IPAdapter Apply + ↓ +KSampler + ↓ +Save Image +``` + +### 6.2 節點設定 + +**CLIP Vision Loader**: +- ckpt_name: `ip-adapter-plus-face_sdxl_vit-h.bin` + +**IPAdapter Model Loader**: +- ipadapter_file: `ip-adapter-plus-face_sdxl_vit-h.safetensors` + +**IPAdapter Apply**: +- weight: **0.7 - 0.9**(角色一致性越高,但創意自由度越低) +- weight_type: `default` +- start_at: 0 +- end_at: 1 +- encode_steps: 10 + +**Load Image**: +- 上傳角色的立繪圖片(建議 1024x1024) + +### 6.3 IP-Adapter Weight 調整指南 + +| Weight | 效果 | 適用情境 | +|--------|------|---------| +| 0.5-0.6 | 角色特徵保留約 60% | 大動作、大表情變化 | +| 0.7-0.8 | 角色特徵保留約 80% | **推薦起始值** | +| 0.9-1.0 | 角色特徵保留約 95% | 微小表情變化 | + +--- + +## 7. ControlNet DWpose 姿勢控制 + +### 7.1 工作流結構 + +``` +Load Checkpoint + ↓ +CLIP Vision Loader + ↓ +IPAdapter Model Loader + ↓ +Load Image (角色立繪) + ↓ +IPAdapter Apply + ↓ +OpenPose Encode (DWpose) + ↓ +Load Image (姿勢參考圖) + ↓ +ControlNet Apply + ↓ +KSampler + ↓ +Save Image +``` + +### 7.2 DWpose 設定 + +**OpenPose Encode**: +- 使用 DWpose 模型(內建於 comfyui_controlnet_aux) +- 輸入:姿勢參考圖(可以是任何人的照片或繪圖) + +**ControlNet Apply**: +- strength: **0.7 - 0.9** +- start_percent: 0 +- end_percent: 1 + +### 7.3 姿勢參考圖來源 + +1. **手繪草圖**:簡單畫出想要的姿勢 +2. **網路圖片**:搜尋動漫角色姿勢 +3. **AI 生成**:先用 ComfyUI 生成姿勢,再套用角色 + +--- + +## 8. 批量生產流程 + +### 8.1 準備工作 + +1. 建立 8 個角色的資料夾: +``` +D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\input\characters\ +├── grok4\ +│ └── reference.png +├── kimi\ +│ └── reference.png +├── qwen3\ +│ └── reference.png +├── gpt5\ +│ └── reference.png +├── gemini\ +│ └── reference.png +├── deepseek\ +│ └── reference.png +├── doubao\ +│ └── reference.png +└── claude\ + └── reference.png +``` + +2. 將 8 個角色的立繪分別放入對應資料夾 + +### 8.2 批量生成腳本 + +建立 `batch_generate.py`: + +```python +import requests +import json +import os +import time + +COMFYUI_API = "http://127.0.0.1:8188" + +# 表情/動作清單 +EXPRESSIONS = [ + "smiling happily", + "laughing loudly", + "crying with tears", + "angry and frowning", + "surprised with wide eyes", + "thinking with hand on chin", + "winking playfully", + "shocked and speechless", + "blushing shyly", + "determined and focused", + "sleeping peacefully", + "screaming in horror" +] + +def queue_prompt(workflow): + """發送工作流到 ComfyUI""" + data = {"prompt": workflow} + response = requests.post(COMFYUI_API + "/prompt", json=data) + return response.json()["prompt_id"] + +def get_image(prompt_id): + """等待生成完成並取得圖片""" + while True: + response = requests.get(COMFYUI_API + "/history/" + prompt_id) + if response.json()[prompt_id] is not None: + break + time.sleep(1) + return response.json()[prompt_id]["outputs"] + +def generate_character(character_name, expression, ip_weight=0.8): + """生成單一角色的單一表情""" + workflow = { + "3": { # Load Checkpoint + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"} + }, + "10": { # CLIP Vision Loader + "class_type": "CLIPVisionLoader", + "inputs": {"clip_name": "ip-adapter-plus-face_sdxl_vit-h.bin"} + }, + "11": { # IPAdapter Model Loader + "class_type": "IPAdapterModelLoader", + "inputs": {"ipadapter_file": "ip-adapter-plus-face_sdxl_vit-h.safetensors"} + }, + "12": { # Load Image (角色立繪) + "class_type": "LoadImage", + "inputs": { + "image": f"characters/{character_name}/reference.png", + "upload": f"characters/{character_name}/reference.png" + } + }, + "13": { # IPAdapter Apply + "class_type": "IPAdapterApply", + "inputs": { + "weight": ip_weight, + "weight_type": "default", + "start_at": 0, + "end_at": 1 + } + }, + "6": { # Positive Prompt + "class_type": "CLIPTextEncode", + "inputs": { + "text": f"a cute chibi girl, {expression}, white background, high quality, detailed face", + "clip": ["3", 1] + } + }, + "7": { # Negative Prompt + "class_type": "CLIPTextEncode", + "inputs": { + "text": "ugly, deformed, noisy, blurry, low quality, worst quality, extra limbs", + "clip": ["3", 1] + } + }, + "5": { # Empty Latent Image + "class_type": "EmptyLatentImage", + "inputs": {"width": 1024, "height": 1024, "batch_size": 1} + }, + "14": { # KSampler + "class_type": "KSampler", + "inputs": { + "model": ["13", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0], + "seed": 42, + "steps": 25, + "cfg": 6, + "sampler_name": "dpmpp_2m", + "scheduler": "karras", + "denoise": 1 + } + }, + "15": { # VAEDecode + "class_type": "VAEDecode", + "inputs": {"samples": ["14", 0], "vae": ["3", 2]} + }, + "16": { # Save Image + "class_type": "SaveImage", + "inputs": { + "images": ["15", 0], + "filename_prefix": f"{character_name}_{expression.replace(' ', '_')}" + } + } + } + + prompt_id = queue_prompt(workflow) + print(f"Generating: {character_name} - {expression}") + return get_image(prompt_id) + +# 批量生成 +characters = ["grok4", "kimi", "qwen3", "gpt5", "gemini", "deepseek", "doubao", "claude"] + +for char in characters: + for expr in EXPRESSIONS: + generate_character(char, expr) + time.sleep(2) # 間隔 2 秒避免 GPU 過熱 + +print("Batch generation complete!") +``` + +### 8.3 執行批量生成 + +```powershell +# 確保 ComfyUI 正在運行 +# Plan A: 雙擊 run_nvidia_gpu.bat +# Plan B: python main.py + +# 在另一個終端機執行批量腳本 +python batch_generate.py +``` + +**預估時間**: +- 單張圖:~5-10 秒 +- 12 張/角色:~1-2 分鐘 +- 8 角色 × 12 張 = 96 張:~3-5 小時 + +--- + +## 9. Prompt 工程指南 + +### 9.1 表情 Prompt 模板 + +``` +Positive: a cute chibi girl, [表情描述], white background, high quality, detailed face, anime style + +Negative: ugly, deformed, noisy, blurry, low quality, worst quality, extra limbs, bad anatomy +``` + +### 9.2 表情描述詞庫 + +| 表情 | Prompt | +|------|--------| +| 開心大笑 | laughing loudly with eyes closed, joy | +| 微笑 | gentle smile, peaceful expression | +| 哭泣 | crying with tears, sad expression | +| 生氣 | angry, frowning, furrowed brows | +| 驚訝 | surprised, wide eyes, open mouth | +| 思考 | thinking, hand on chin, contemplative | +| 眨眼 | winking playfully, cute expression | +| 害羞 | blushing, shy smile, looking down | +| 恐懼 | screaming in horror, terrified expression | +| 堅定 | determined, focused eyes, clenched fist | +| 睡覺 | sleeping peacefully, Zzz, closed eyes | +| 無聊 | bored expression, yawning, looking away | + +### 9.3 動作 Prompt 模板 + +``` +Positive: a cute chibi girl, [動作描述], dynamic pose, white background, high quality + +Negative: ugly, deformed, noisy, blurry, low quality, worst quality, extra limbs, bad anatomy +``` + +### 9.4 動作描述詞庫 + +| 動作 | Prompt | +|------|--------| +| 揮手 | waving hand, greeting pose | +| 比讚 | thumbs up, positive gesture | +| 指向前方 | pointing forward, confident pose | +| 雙手抱胸 | arms crossed, confident stance | +| 跳躍 | jumping in air, dynamic pose | +| 蹲下 | crouching down, playful pose | +| 轉圈 | spinning around, happy dance | +| 舉手 | raising hand, excited gesture | +| 捂臉 | covering face with hands, embarrassed | +| 攤手 | shrugging, confused gesture | + +--- + +## 10. 品質篩選與後處理 + +### 10.1 篩選標準 + +| 項目 | 合格 | 不合格 | +|------|------|--------| +| 臉部特徵 | 與立繪一致 | 臉部變形、特徵消失 | +| 髮型髮色 | 正確 | 髮色改變、髮型錯誤 | +| 服裝 | 正確 | 服裝變形、顏色錯誤 | +| 配件 | 正確 | 配件消失或變形 | +| 表情 | 自然 | 表情僵硬或不自然 | +| 手部 | 正常 | 手指數量錯誤、變形 | + +### 10.2 篩選流程 + +1. 將生成的圖片按角色分類 +2. 逐一檢查是否符合篩選標準 +3. 不合格的圖片標記為 `reject` +4. 合格的圖片標記為 `keep` + +### 10.3 後處理 + +**使用工具**: +- GIMP(免費) +- Photoshop(付費) +- Photopea(線上免費) + +**後處理項目**: +- 修正手指變形 +- 調整臉部細節 +- 統一背景顏色 +- 調整大小至 512x512 或 256x256(遊戲用) + +--- + +## 11. 常見問題與解決方案 + +### 11.1 GPU 記憶體不足 + +**錯誤**:`CUDA out of memory` + +**解決方案**: +1. 降低解析度(1024x1024 → 768x768) +2. 減少 batch_size(設為 1) +3. 關閉其他程式釋放記憶體 +4. 使用 `--lowvram` 啟動參數: +```powershell +python main.py --lowvram +``` + +### 11.2 IP-Adapter 效果不強 + +**問題**:生成的圖片與角色立繪差異太大 + +**解決方案**: +1. 提高 IP-Adapter weight(0.7 → 0.9) +2. 使用 `ip-adapter-plus-face` 而非 `ip-adapter-plus` +3. 確保立繪圖片品質高、清晰 +4. 減少 CFG scale(7 → 5) + +### 11.3 ControlNet 姿勢不準確 + +**問題**:生成的姿勢與參考圖不符 + +**解決方案**: +1. 提高 ControlNet strength(0.7 → 0.9) +2. 使用更清晰的姿勢參考圖 +3. 確保 DWpose 模型正確載入 +4. 嘗試不同的 ControlNet 模型 + +### 11.4 生成速度慢 + +**問題**:單張圖超過 30 秒 + +**解決方案**: +1. 減少 steps(30 → 20) +2. 使用更快的 sampler(dpmpp_2m → euler) +3. 關閉其他佔用 GPU 的程式 +4. 更新 NVIDIA 驅動至最新版本 + +### 11.5 圖片品質不佳 + +**問題**:生成的圖片模糊或變形 + +**解決方案**: +1. 增加 steps(20 → 30) +2. 提高 CFG scale(5 → 7) +3. 使用更好的 negative prompt +4. 更換 SDXL 模型(嘗試 juggernautXL) + +--- + +## 12. 資源連結 + +### 12.1 官方文件 + +- ComfyUI: https://github.com/comfyanonymous/ComfyUI +- IP-Adapter: https://github.com/tencent-ailab/IP-Adapter +- ControlNet: https://github.com/lllyasviel/ControlNet + +### 12.2 模型下載 + +- HuggingFace: https://huggingface.co/ +- Civitai: https://civitai.com/ +- LiblibAI: https://www.liblib.art/ + +### 12.3 教學資源 + +- Bilibili ComfyUI 教學:搜尋 "ComfyUI IP-Adapter" +- YouTube 教學:搜尋 "ComfyUI character consistency" + +### 12.4 社群 + +- ComfyUI Discord: https://discord.com/invite/comfy +- Reddit r/StableDiffusion: https://reddit.com/r/StableDiffusion + +--- + +## 📝 備註 + +- 本文檔基於 RTX A4000 (16GB VRAM) 測試 +- 所有模型權重請從官方來源下載,注意授權條款 +- 建議先以 1-2 個角色測試,確認效果後再批量生產 +- 生成的圖片僅供內部使用,注意版權問題 + +--- + +**最後更新**:2026-07-30 +**作者**:Hermes Agent (software-engineer profile) diff --git a/docs/交接文件.md b/docs/交接文件.md new file mode 100644 index 0000000..ab7c14c --- /dev/null +++ b/docs/交接文件.md @@ -0,0 +1,183 @@ +# 專案交接文件 + +> **專案**:liars-bar-game(AI 版騙子酒館 Web 遊戲) +> **日期**:2026-07-30 +> **代理帳號**:karylab_agents +> **Git 主倉庫**:https://git.karylab.com/karylab/liars-bar-game +> **Fork 倉庫**:https://git.karylab.com/karylab_agents/liars-bar-game + +--- + +## 一、專案概覽 + +原專案為 LYiHub 的 [liars-bar-llm](https://github.com/LYiHub/liars-bar-llm),fork 至 Gitea 後進行網頁版改造。 + +### 核心目標 +將原本 Python 命令列版本的 AI 騙子酒館改造成多人即時 Web 遊戲,支援 2-8 人同場遊玩。 + +### 技術棧 +| 層級 | 技術 | +|------|------| +| 後端 | Node.js + Express + Socket.IO | +| 前端 | Vue.js 3 + Vite + Naive UI + Pinia | +| 資料庫 | 無(狀態存於記憶體,後續可用 Redis) | +| 版本控制 | Gitea + Git | + +--- + +## 二、目前進度 + +### ✅ 已完成 + +| 項目 | 說明 | +|------|------| +| **逐字稿與筆記** | 兩支 YouTube 影片轉錄、校對完成,整理至 `docs/` | +| **角色視覺映射** | 8 大 AI 角色正確配對已建立(`docs/角色視覺映射筆記.md`) | +| **後端骨架** | Node.js + Socket.IO 伺服器,房間管理、遊戲引擎、回合邏輯 | +| **前端骨架** | Vue.js 3 + Vite + Naive UI,大廳、等待室、牌桌組件 | +| **Bug 修復** | 兩輪審查共修復 15 項 Bug(輪次計算、斷線清理、權限驗證、回合結算) | +| **前後端連線測試** | Socket.IO 連線成功,房間建立與狀態同步驗證通過 | +| **PR #14** | 已建立並解決衝突,管理員可合入(`game` 分支) | + +### 🔄 待完成 + +| 優先級 | 項目 | 說明 | +|--------|------|------| +| 🔴 高 | 管理員合入 PR #14 | 目標分支 `game`,目前已可合併 | +| 🔴 高 | 合入後同步 `main`/`game` | `git pull` 最新程式碼 | +| 🟡 中 | 前端動畫實作 | 角色表情/動作 CSS 動畫,參考角色視覺映射筆記 | +| 🟡 中 | 牌組擴充 | 目前 20 張牌僅支援 4 人,需調整規則以支援 8 人 | +| 🟢 低 | 遊戲 AI 整合 | 串接 LLM API 讓 AI 玩家自動出牌 | +| 🟢 低 | 資料庫持久化 | 使用 Redis/SQLite 保存遊戲紀錄 | + +--- + +## 三、分支結構 + +``` +main # 上游主分支(Python 原版) +game # Web 遊戲主分支(目標分支) +feat/web-game # 開發分支,PR #14 從此發出 +add-transcripts-and-notes # 已合入 main 的舊分支 +``` + +### 重要 +- **不要直接 push 到 `karylab/liars-bar-game`**(僅 pull 權限) +- 透過 Fork → PR 流程:`本地` → `karylab_agents` → PR → `karylab` 主倉庫 + +--- + +## 四、PR #14 狀態 + +| 項目 | 數值 | +|------|------| +| PR 編號 | #14 | +| 標題 | Add web game: Node.js backend + Vue.js frontend | +| 狀態 | **開放中,可合併** | +| 目標分支 | `game` | +| 來源分支 | `feat/web-game` | +| 檔案變動 | 18 檔案,+3720 / -157 行 | +| 衝突狀態 | **已解決**(.gitignore 合併完成) | +| 管理員指示 | 請合併到 game 分支(已執行) | + +--- + +## 五、程式碼架構 + +``` +server/ +├── package.json # Express + Socket.IO 依賴 +├── index.js # 主伺服器(Socket.IO 事件處理、房間驗證) +└── game/ + ├── engine.js # 遊戲引擎核心(回合追蹤、勝利條件、安全跳轉) + └── room.js # 房間管理(狀態檢查、引擎綁定、斷線清理) + +client/ +├── package.json # Vue 3 + Vite + Naive UI + Pinia + Socket.IO +├── vite.config.js # Vite 設定(含 API 代理) +├── index.html # 入口頁面 +└── src/ + ├── main.js # 應用入口 + ├── App.vue # 根組件 + ├── stores/ + │ └── game.js # Pinia 狀態管理 + Socket.IO 連線 + └── components/ + ├── Lobby.vue # 大廳(建立/加入房間) + └── GameTable.vue # 等待室 + 牌桌 UI + +docs/ +├── 角色視覺映射筆記.md # 8 大 AI 角色正確配對 +├── AI繪圖角色一致性完整調查報告.md # AI 繪圖工具調查報告 +├── 影片與程式筆記.md # 開發歷程記錄 +└── transcripts/ + ├── video1_transcript_校對後.txt + └── video2_transcript_校對後.txt +``` + +--- + +## 六、角色視覺映射(8 大 AI) + +| 編號 | AI 名稱 | 髮色 | 服裝 | 配件 | 表情 | 性格關鍵詞 | +|------|---------|------|------|------|------|-----------| +| 1 | Grok-4 | 波浪橘髮 | 黃色無袖洋裝 | 紅色大蝴蝶結 | 開朗大笑 | 超絕演技、情緒型玩家 | +| 2 | Kimi | 白銀長直髮 | 深藍洋裝+白領 | 項圈 | 嚴肅中性 | 原始直覺強、務實 | +| 3 | Qwen3 | 粉紅短髮 | 灰色無袖洋裝 | 圓眼鏡 | 開懷笑 | 刺兒頭、質疑率 95.5% | +| 4 | GPT-5 | 黑色長直髮 | 黑色洋裝 | 無 | 沉靜微笑 | 老謀深算、心理戰高手 | +| 5 | Gemini | 紫色短髮 | 淺藍洋裝 | 圓眼鏡+星星髮夾 | 溫柔微笑 | 活潑好奇、語言不穩定 | +| 6 | Deepseek | 淺藍短髮 | 深藍灰洋裝 | 鯨魚尾巴 | 閉眼大笑 | 難知如陰、侵略如火、瘋癲 | +| 7 | Doubao | 深棕短髮 | 黑色洋裝 | 無 | 禮貌微笑 | 反情緒定律、獵殺時刻 | +| 8 | Claude | 橘色短髮 | 橘色洋裝 | 無 | 死魚眼/無聊 | 云淡風輕、溫和堅定 | + +--- + +## 七、角色繪圖方案 + +### 推薦方案:ComfyUI + IP-Adapter + ControlNet DWpose + +- 1 張立繪即可開始,零訓練成本 +- DWpose 專為 Q 版/動漫比例優化 +- 硬體需求:RTX 3060 12GB(最低)/ RTX 4070(推薦) +- 替代方案:LiblibAI 雲端(無需 GPU,免費層可測試) + +### 技術細節 + +| 技術 | Q 版適合度 | 說明 | +|------|:---------:|------| +| IP-Adapter | ★★★★★ | 1 張參考圖保持角色一致性 | +| ControlNet DWpose | ★★★★★ | 精確控制 Q 版角色姿態 | +| LoRA | ★★★★☆ | 長期投資,但需 15-30 張訓練圖 | + +詳細報告:`docs/AI繪圖角色一致性完整調查報告.md` + +--- + +## 八、管理員聯絡資訊 + +| 項目 | 內容 | +|------|------| +| 管理員 | karylab | +| 管理員 Email | blisseyblisseyblissey@gmail.com | +| Gitea 實例 | https://git.karylab.com/ | +| 代理帳號 | karylab_agents(Fork 具 Admin 權限) | + +--- + +## 九、下次接手重點 + +1. **PR #14 合入後** → `git pull` 同步程式碼 +2. **前端開發** → 補完遊戲邏輯 UI(出牌、質疑、舉槍動畫) +3. **角色繪圖** → 安裝 ComfyUI 或使用 LiblibAI 雲端生成 100 張表情/動作變體 +4. **牌組調整** → 20 張牌需擴充以支援 8 人 +5. **AI 整合** → 串接 LLM API 讓 AI 玩家自動出牌 + +--- + +## 十、開發規則 + +接手者請務必遵守以下四項鐵律: + +1. **邊界認知與求助防線**:不猜測、重試上限 2 次、查不到不編造 +2. **價值排序與隨做隨寫**:先解核心阻塞、產出保留至獨立檔案、修改前建 `.bak` +3. **強制性角色切換自驗**:產出後切換挑剔審查員視角驗證、以執行結果/Read-back 證明 +4. **環境操作許可制**:寫入/安裝/執行前先列清單、取得授權才觸發 diff --git a/docs/影片與程式筆記.md b/docs/影片與程式筆記.md index 959d3b4..c7afbb1 100644 --- a/docs/影片與程式筆記.md +++ b/docs/影片與程式筆記.md @@ -1,6 +1,9 @@ # Liars Bar LLM — 完整筆記 > 整理日期:2026-07-29 +> 影片連結: +> - `https://youtu.be/hCJBknzh0BY?si=35Dnw4OaaR5isqip` (一周目) +> - `https://youtu.be/L3IA4TVCk5s?si=JmNbduZydzwPvyRo` (二周目) > 逐字稿位置: > - `video1_transcript.txt` (450 行, 影片一) > - `video2_transcript.txt` (370 行, 影片二) diff --git a/docs/接手修改與測試紀錄.md b/docs/接手修改與測試紀錄.md new file mode 100644 index 0000000..cd08c68 --- /dev/null +++ b/docs/接手修改與測試紀錄.md @@ -0,0 +1,129 @@ +# 接手修改與測試紀錄 + +> 日期:2026-08-03 +> 範圍:環境建置、編碼修復、Node 引擎規則對齊、8 人支援、前端改造 +> 前置閱讀:`docs/交接文件.md` + +--- + +## 一、本機環境(最乾淨做法) + +- **不裝任何系統級工具**。本機既有兩套「使用者層級」工具鏈可複用: + - Codex 桌面 runtime:`C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies`(git 2.53 / node 24 / python 3.12 / pnpm) + - Hermes:`C:\Users\ckliu\AppData\Local\hermes`(git + node 24 + python venv) +- 專案內建了一支環境載入腳本 `scripts/env.ps1`(只改當前 session 的 PATH,不動系統): + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force +. .\scripts\env.ps1 +``` + +- Python 依賴鎖在專案 `.venv`(Python 3.12.13,`openai 2.52.0`),已由 `.gitignore` 排除。 +- Node 依賴沿用既有 `client/node_modules`、`server/node_modules`(已備妥)。 + +--- + +## 二、本次修改 + +### 1. Python 主控台編碼修復(解決 cp950 崩潰) +- 新增 `project_env.py`,提供 `ensure_utf8_console()`(把 stdout/stderr 切成 UTF-8)。 +- 在 `player.py` 模組層呼叫(經 `game.py` import 即生效),並在 `game.py`、`multi_game_runner.py`、`json_convert.py`、`game_analyze.py`、`player_matchup_analyze.py` 入口處呼叫。 +- 效果:即使主控台為 Big5(cp950),遊戲也能正常輸出簡體中文而不會 `UnicodeEncodeError`。 + +### 2. Python 牌組依人數等比擴充 +- `game.py::_create_deck(player_count)`:總張數 = 存活人數 × 5,Q:K:A 等量、Joker 約 10%。 +- 已驗證 2/3/4/5/8 人牌組張數正確(20 張 4 人版與原本一致)。 + +### 3. Node 引擎規則對齊 + 8 人支援(核心) +- 重寫 `server/game/engine.js`,規則與 Python 版對齊: + - 牌組依人數等比擴充(2-8 人)。 + - 每次出牌 1-3 張(`playCard(playerId, cardIndices)`)。 + - Joker 為萬能牌;質疑「整組」牌,全為目標牌/Joker 才算真牌。 + - 質疑後本手結束、存活者重發 5 張並選新目標牌。 + - 系統質疑:輪到某玩家且其他存活者已無牌 → 自動打出剩餘手牌並質疑。 + - 只有一位存活者時結束。 +- `server/game/room.js`:房間上限 4 → 8;斷線時同步給引擎。 + +### 4. Socket 通訊與前端 +- `server/index.js`:`playCard` 改收 `cardIndices` 陣列;新增「把每個人自己的手牌私下推送」(`yourHand`);保留優雅關閉(SIGINT/SIGTERM)。 +- `client/src/stores/game.js`:新增 `yourHand`、`lastMessage`;`playCard(indices)` 改傳陣列。 +- `client/src/components/GameTable.vue`:真實手牌顯示、點選 1-3 張出牌、挑戰按鈕、挑戰結果提示、勝者顯示。 + +--- + +## 三、測試(全部可用) + +| 腳本 | 內容 | +|------|------| +| `scripts\run_all_tests.ps1` | 一鍵全量:Python 語法 + 冒煙 + Node 引擎單元 + 後端 REST + 前端 SFC | +| `scripts\smoke_test_python.py` | Python 完整對局(mock LLM,不連網) | +| `scripts\test_engine.js` | Node 引擎 18 項單元測試(含資訊邊界) | +| `scripts\smoke_test_server.js` | 後端啟動 + REST | +| `scripts\test_socket_e2e.js` | 3 玩家端對端:房間→出牌→挑戰→新局→手牌同步(需先啟動 server) | + +執行方式: + +```powershell +Set-ExecutionPolicy -Scope Process ExecutionPolicy Bypass -Force # 此行若被擋才需要 +.\scripts\run_all_tests.ps1 +# 端對端(另開 server): +node server/index.js +node scripts\test_socket_e2e.js +``` + +--- + +## 四、環境注意事項(已知,非程式問題) + +1. **前端完整 build**(`vite build`):esbuild 需要掃描目錄樹,在受限環境會被擋;請用 `scripts\build_frontend.ps1` 於一般終端機執行。 +2. **Node 收尾噪音**:Node 24 + socket.io 在 Windows 上結束 process 時會印一行 `uv_async` assertion(不影響功能),屬 Node 版本相容性問題;用 Ctrl+C 或一般關閉流程即可,或改用 Node LTS 22。 +3. **本機無系統 git/node/python**:一律透過 `scripts\env.ps1` 載入 Codex runtime 路徑。 + +--- + +## 五、待辦/後續建議 + +- 前端完整 build 驗證(需一般終端機)。 +- 8 人實戰測試(目前引擎與引擎層驗證通過,UI 多人同時操作待實際瀏覽器驗證)。 +- 前端出牌後「上一組牌」的翻牌呈現(目前只顯示牌值,未區分公開/檯面)。 +- AI 玩家整合進 Web(交接文低優先)與持久化(Redis/SQLite)尚未動工。 + +--- + +## 六、2026-08-04 追加:資訊邊界修正(防「開天眼」)+ 驗證 + +### 問題 +- 原本 `engine.getGameState()` 把每組出牌的真實牌值(`playedCards`/`playedGroups.cards`)廣播給所有玩家; + 前端 `GameTable.vue` 直接顯示 `item.card`。這會讓「唬牌」玩法形同開天眼,失去對局張力。 + +### 修正 +- `server/game/engine.js`:公開狀態只回報「誰出了幾張」(`playedGroups → { playerId, cardCount }`), + 不再包含真實牌值;移除平鋪 `playedCards` 的迴圈。 +- `server/index.js`:`cardPlayed` 事件只送 `{ playerId, cardCount }`,不洩漏牌值。 +- **真實牌值只在質疑時揭露**:`challenge`/系統質疑的結果(`challengedGroup`)透過 `challenged` 事件送出。 +- 前端: + - `client/src/stores/game.js`:合併重複的 `challenged` handler;新增 `lastChallenge` 保留翻牌結果; + 連線網址改為可被 `VITE_SERVER_URL` 覆蓋;`cardPlayed` 時也處理系統質疑翻牌。 + - `client/src/components/GameTable.vue`:出牌顯示改成「玩家: N 張」;質疑後新增「質疑結果(翻牌)」區塊; + 勝者、回合、手牌邏輯保留。 +- `server/game/room.js`:空房自動清理;修正 maxPlayers 註解。 + +### Commit(2026-08-04) +- commit `f8b75ab`(分支 feat/web-game):"Fix info leak in web game + add handover notes",29 files changed。 +- 收錄:程式改動 + `project_env.py` + `client/tsconfig.json` + `scripts/` + docs(含既有 3 行影片連結)。 +- 排除(gitignore):`backups/`、`game_records/`、`dist/`、`node_modules/`。 + +### 驗證(2026-08-04 實測) +- 引擎單元測試 **18/18 通過**(新增 2 項資訊邊界測試)。 +- 端對端(單進程啟動器 `scripts/run_e2e_local.js`)**全過**,並明確驗證: + - `cardPlayed` 不含 `cards`(不洩漏牌值); + - 出牌者收到 `yourHand` 私有手牌同步; + - 質疑後下一局正常、公開狀態與私有手牌一致。 +- 全量 `scripts/run_all_tests.ps1`:Python 語法 / 冒煙 / 引擎 18 項 / REST / 前端 SFC 全部通過(exit=0)。 + +### 新增檔案 +- `client/tsconfig.json`:為前端補標準 Vite+Vue 型別設定(不影響 build;esbuild 掃描問題另記於「四」)。 +- `scripts/run_e2e_local.js`:單進程端對端啟動器(require server → 延遲 → 跑 e2e), + 免去背景開 server 的授權需求。 + +> 待辦:前端完整 build 與瀏覽器實測仍需在一般終端機(見「四」);結果補錄於此後續章節。 diff --git a/docs/角色視覺映射筆記.md b/docs/角色視覺映射筆記.md new file mode 100644 index 0000000..fe13821 --- /dev/null +++ b/docs/角色視覺映射筆記.md @@ -0,0 +1,78 @@ +# 8 大 AI 角色視覺映射筆記 + +> 來源:林亦 LYi《AI 大战骗子酒馆②:八大顶级AI赌命血战,赢家竟是?》影片角色插圖 + +## 正確配對表 + +| 編號 | AI 名稱 | 髮色/髮型 | 服裝 | 配件 | 表情 | 標籤色 | +|------|---------|-----------|------|------|------|--------| +| 1 | **Grok-4** | 波浪橘髮 | 黃色無袖洋裝 | 紅色大蝴蝶結 | 開朗大笑 | 粉紅 | +| 2 | **Kimi** | 白銀長直髮 | 深藍洋裝+白領 | 項圈 | 嚴肅中性 | 深藍/黑 | +| 3 | **Qwen3** | 粉紅短髮 | 灰色無袖洋裝 | 圓眼鏡 | 開懷笑 | 藍紫 | +| 4 | **GPT-5** | 黑色長直髮 | 黑色洋裝 | 無 | 沉靜微笑 | 黑 | +| 5 | **Gemini** | 紫色短髮 | 淺藍洋裝 | 圓眼鏡+星星髮夾 | 溫柔微笑 | 淺紫 | +| 6 | **Deepseek** | 淺藍短髮 | 深藍灰洋裝 | 鯨魚尾巴 | 閉眼大笑 | 藍 | +| 7 | **Doubao** | 深棕短髮 | 黑色洋裝 | 無 | 禮貌微笑 | 淺藍 | +| 8 | **Claude** | 橘色短髮 | 橘色洋裝 | 無 | 無聊/死魚眼 | 橘 | + +## 逐字稿性格關鍵詞(供前端動畫參考) + +### Grok-4 +- 「超絕演技大殺四方」 +- 「微微猶豫、眼神避開、勉強笑」 +- 「奧斯卡級別的假動作」 +- 「情緒型玩家」 +- 「拿著好牌演苦情戲」 + +### Kimi +- 「原始直覺強」 +- 「純靠直覺排前三」 +- 「相對踏實的老派模型」 + +### Qwen3 (千問) +- 「踏實」 +- 「質疑比例高達 95.5%」 +- 「十足的刺兒頭」 +- 「容易被拿捏——遇上他出真牌就完事了」 + +### GPT-5 (ChatGPT) +- 「微微一笑、略带挑衅」 +- 「老謀深算」 +- 「一丝不苟」 +- 「心理戰高手」 +- 「被 DeepSeek R1 吃死」 + +### Gemini +- 「活潑好奇」 +- 「語言選擇不穩定(混用多國語言)」 +- 「容易被搞亂」 +- 「總是先被淘汰」 + +### Deepseek +- 「面無表情」 +- 「難知如陰、侵略如火」 +- 「陰晴不定」 +- 「戲劇化表演」 +- 「瞳孔收縮、冷笑、氣聲喃喃」 +- 「概率的祭品,該供奉給貝葉斯還是海森堡」 + +### Doubao (豆包) +- 「乾淨利落」 +- 「反情緒定律」 +- 「自己不騙人但能準確辨別偽裝」 +- 「獵殺時刻」 +- 「飛速進化」 + +### Claude +- 「溫和但堅定」 +- 「云淡风轻」 +- 「嘴角微微上揚」 +- 「平靜但略带玩味」 +- 「靠回椅背,雙手交叉」 + +## 前端實作建議 + +- 每個角色用品牌色作為主色調 +- 配件(眼鏡、蝴蝶結、鯨魚尾巴)作為識別標誌 +- 表情動畫對應性格:Grok 演戲、DeepSeek 瘋癲、Claude 淡定 +- 出牌動作可加入性格差異:Grok 猶豫、Claude 平靜、Qwen 直接 diff --git a/game.py b/game.py index dc48611..657b714 100644 --- a/game.py +++ b/game.py @@ -28,15 +28,30 @@ class Game: self.game_record.start_game([p.name for p in self.players]) self.round_count = 0 - def _create_deck(self) -> List[str]: - """创建并洗牌牌组""" - deck = ['Q'] * 6 + ['K'] * 6 + ['A'] * 6 + ['Joker'] * 2 + def _create_deck(self, player_count: int) -> List[str]: + """???????????????? 5 ??? + + ???? 20 ??6Q 6K 6A 2Joker?????Q:K:A ???Joker ? 10%? + ??? = player_count * 5??????????? 5 ?? + """ + total = player_count * 5 + joker_count = max(1, round(total * 0.1)) + normal_total = total - joker_count + base, remainder = divmod(normal_total, 3) + counts = {'Q': base, 'K': base, 'A': base} + for i, key in enumerate(['Q', 'K', 'A']): + if remainder > 0: + counts[key] += 1 + remainder -= 1 + deck = (['Q'] * counts['Q'] + ['K'] * counts['K'] + + ['A'] * counts['A'] + ['Joker'] * joker_count) random.shuffle(deck) return deck def deal_cards(self) -> None: """发牌并清空旧手牌""" - self.deck = self._create_deck() + alive_count = sum(1 for player in self.players if player.alive) + self.deck = self._create_deck(alive_count) for player in self.players: if player.alive: player.hand.clear() @@ -397,6 +412,8 @@ class Game: self.play_round() if __name__ == '__main__': + from project_env import ensure_utf8_console + ensure_utf8_console() # 配置玩家信息, 其中model为你通过API调用的模型名称 player_configs = [ { diff --git a/game_analyze.py b/game_analyze.py index ea3ce81..f1dbacc 100644 --- a/game_analyze.py +++ b/game_analyze.py @@ -157,6 +157,8 @@ def print_statistics(stats, win_rates, game_count, player_names): print(f"{player} vs {opponent:<10} {matchups:<10} {wins:<10} {win_rate:.1f}%") if __name__ == "__main__": + from project_env import ensure_utf8_console + ensure_utf8_console() folder_path = "game_records" # 替换为实际的文件夹路径 stats, win_rates, game_count, player_names = analyze_game_records(folder_path) print_statistics(stats, win_rates, game_count, player_names) \ No newline at end of file diff --git a/json_convert.py b/json_convert.py index 8d5eaf9..af504ab 100644 --- a/json_convert.py +++ b/json_convert.py @@ -122,6 +122,8 @@ def process_game_records(input_directory, output_directory): print(f"已生成:{txt_file_path}") if __name__ == '__main__': + from project_env import ensure_utf8_console + ensure_utf8_console() game_records_directory = 'game_records' output_directory = 'converted_game_records' # 新的输出目录 process_game_records(game_records_directory, output_directory) \ No newline at end of file diff --git a/multi_game_runner.py b/multi_game_runner.py index 20d4d69..7ca7d78 100644 --- a/multi_game_runner.py +++ b/multi_game_runner.py @@ -39,6 +39,8 @@ def parse_arguments(): return parser.parse_args() if __name__ == '__main__': + from project_env import ensure_utf8_console + ensure_utf8_console() # 解析命令行参数 args = parse_arguments() diff --git a/player.py b/player.py index 14f8629..50330e0 100644 --- a/player.py +++ b/player.py @@ -3,6 +3,8 @@ import json import re from typing import List, Dict from llm_client import LLMClient +from project_env import ensure_utf8_console +ensure_utf8_console() RULE_BASE_PATH = "prompt/rule_base.txt" PLAY_CARD_PROMPT_TEMPLATE_PATH = "prompt/play_card_prompt_template.txt" diff --git a/player_matchup_analyze.py b/player_matchup_analyze.py index dc765d0..013103e 100644 --- a/player_matchup_analyze.py +++ b/player_matchup_analyze.py @@ -191,4 +191,7 @@ input_dir = "game_records" # 包含JSON文件的文件夹 output_dir = "matchup_records" # 输出文件夹 # 处理所有JSON文件 +from project_env import ensure_utf8_console +ensure_utf8_console() + process_all_json_files(input_dir, output_dir) \ No newline at end of file diff --git a/project_env.py b/project_env.py new file mode 100644 index 0000000..752d879 --- /dev/null +++ b/project_env.py @@ -0,0 +1,19 @@ +"""專案共用小工具:主控台輸出編碼處理。""" + +import sys + + +def ensure_utf8_console() -> None: + """將 stdout/stderr 切為 UTF-8,避免在 Big5/cp950 主控台輸出中文時崩潰。""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8") + except Exception: + pass + + +if __name__ == "__main__": + ensure_utf8_console() + print("console utf-8 ready") \ No newline at end of file diff --git a/scripts/build_frontend.ps1 b/scripts/build_frontend.ps1 new file mode 100644 index 0000000..e59c9d7 --- /dev/null +++ b/scripts/build_frontend.ps1 @@ -0,0 +1,22 @@ +# 前端建置輔助腳本(使用 Codex 桌面自帶的 node,不需 npm) +# 用法: .\scripts\build_frontend.ps1 # production build +# .\scripts\build_frontend.ps1 -Dev # dev server (vite) +# 注意: esbuild 會掃描目錄樹;在受限環境(沙盒)執行失敗時, +# 請改用一般終端機執行本腳本。 +param([switch]$Dev) +$ErrorActionPreference = 'Stop' +$node = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' +$root = Split-Path -Parent $PSScriptRoot +$client = Join-Path $root 'client' +if (-not (Test-Path $node)) { throw "找不到 node: $node" } +Push-Location $client +try { + if ($Dev) { + & $node node_modules\vite\bin\vite.js + } else { + & $node node_modules\vite\bin\vite.js build + } + exit $LASTEXITCODE +} finally { + Pop-Location +} \ No newline at end of file diff --git a/scripts/commit_current.ps1 b/scripts/commit_current.ps1 new file mode 100644 index 0000000..7eb87b2 --- /dev/null +++ b/scripts/commit_current.ps1 @@ -0,0 +1,28 @@ +# 一鍵:暫存本次所有修改並 commit(需在一般終端機、具 .git 寫入權限) +# 用法:powershell -ExecutionPolicy Bypass -File scripts\commit_current.ps1 +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') + +# 載入 runtime git(若尚未在 PATH) +$git = Get-Command git -ErrorAction SilentlyContinue +if (-not $git) { + $runtimeGit = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe' + if (Test-Path $runtimeGit) { $gitCmd = $runtimeGit } else { throw '找不到 git' } +} else { $gitCmd = $git.Source } + +Write-Host '== 確認待 commit 內容 ==' +& $gitCmd status --short + +Write-Host '' +Write-Host '== git add -A ==' +& $gitCmd add -A +if ($LASTEXITCODE -ne 0) { throw 'git add 失敗' } + +Write-Host '' +Write-Host '== git commit ==' +& $gitCmd commit -m "Fix info leak in web game + add handover notes" +if ($LASTEXITCODE -ne 0) { throw 'git commit 失敗' } + +Write-Host '' +Write-Host '== 完成 ==' +& $gitCmd log --oneline -1 diff --git a/scripts/env.ps1 b/scripts/env.ps1 new file mode 100644 index 0000000..a3b68a4 --- /dev/null +++ b/scripts/env.ps1 @@ -0,0 +1,54 @@ +# ============================================================ +# Liars-Bar-LLM 專案環境載入腳本(僅影響當前 session) +# 用途:把 Codex 桌面自帶的既有工具鏈掛進「本次 session」的 PATH。 +# 不做任何系統層級變更(不寫註冊表、不寫系統環境變數、不安裝任何東西)。 +# 用法:在專案根目錄執行 .\scripts\env.ps1 +# ============================================================ + +# Codex 桌面 runtime 根目錄(若 Codex 更新版本、路徑改變,可改這裡或改指向 hermes) +$runtimeRoot = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies' + +$toolDirs = @( + (Join-Path $runtimeRoot 'native\git\cmd'), # git + (Join-Path $runtimeRoot 'node\bin'), # node / npm + (Join-Path $runtimeRoot 'python'), # python + (Join-Path $runtimeRoot 'bin\fallback') # pnpm 等 +) + +$missing = @() +foreach ($dir in $toolDirs) { + if (Test-Path -LiteralPath $dir) { + if (($env:PATH -split ';') -notcontains $dir) { + $env:PATH = "$dir;$env:PATH" + } + } else { + $missing += $dir + } +} + +if ($missing.Count -gt 0) { + Write-Host "警告:以下工具目錄不存在(Codex 可能已更新路徑):" -ForegroundColor Yellow + foreach ($m in $missing) { Write-Host " $m" -ForegroundColor Yellow } + Write-Host "替代方案:改用 hermes 工具鏈(C:\Users\ckliu\AppData\Local\hermes\git 與 ...\node)。" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "環境載入完成(session 限定,未變更系統設定):" -ForegroundColor Green +foreach ($cmd in @('git','node','python','pnpm')) { + $bin = Get-Command $cmd -ErrorAction SilentlyContinue + if ($bin) { + $ver = try { & $cmd --version 2>&1 | Select-Object -First 1 } catch { '?' } + Write-Host (" {0,-8} {1}" -f $cmd, $ver) + } else { + Write-Host (" {0,-8} <找不到>" -f $cmd) -ForegroundColor Yellow + } +} + +# 專案 venv 提示 +$venvPython = Join-Path (Get-Location) '.venv\Scripts\python.exe' +if (Test-Path -LiteralPath $venvPython) { + $pyVer = & $venvPython --version 2>$null + Write-Host (" 專案 venv {0} ({1})" -f $venvPython, $pyVer) -ForegroundColor Green +} else { + Write-Host " 專案 venv 尚未建立(可用 .\scripts\env.ps1 掛載後執行 python -m venv .venv)" -ForegroundColor Yellow +} \ No newline at end of file diff --git a/scripts/inspect_remote.ps1 b/scripts/inspect_remote.ps1 new file mode 100644 index 0000000..0488fe1 --- /dev/null +++ b/scripts/inspect_remote.ps1 @@ -0,0 +1,32 @@ +# 唯讀診斷:fetch 遠端並比較本地/遠端差異(不做任何改寫) +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') + +$git = Get-Command git -ErrorAction SilentlyContinue +if (-not $git) { + $git = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe' +} else { $git = $git.Source } + +Write-Host '== [1] fetch 遠端(不 merge)==' +& $git fetch karylab feat/web-game +if ($LASTEXITCODE -ne 0) { throw 'fetch 失敗' } + +Write-Host '' +Write-Host '== [2] 本地奇怪的近 5 筆 ==' +& $git log --oneline -5 feat/web-game + +Write-Host '' +Write-Host '== [3] 遠端奇怪的近 5 筆(karylab/feat/web-game)==' +& $git log --oneline -5 karylab/feat/web-game + +Write-Host '' +Write-Host '== [4] 兩者共同祖先 ==' +& $git merge-base feat/web-game karylab/feat/web-game + +Write-Host '' +Write-Host '== [5] 遠端有、本地沒有(會被 pull 進來)==' +& $git log --oneline --left-right --cherry-pick feat/web-game...karylab/feat/web-game + +Write-Host '' +Write-Host '== [6] 檔案層級差異 ==' +& $git diff --stat feat/web-game karylab/feat/web-game diff --git a/scripts/push_current.ps1 b/scripts/push_current.ps1 new file mode 100644 index 0000000..2023316 --- /dev/null +++ b/scripts/push_current.ps1 @@ -0,0 +1,34 @@ +# 一鍵:commit 未提交變更 + push 到 karylab_agents fork(remote: karylab) +# 用法:powershell -ExecutionPolicy Bypass -File scripts\push_current.ps1 +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') + +$git = Get-Command git -ErrorAction SilentlyContinue +if (-not $git) { + $runtimeGit = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe' + if (Test-Path $runtimeGit) { $gitCmd = $runtimeGit } else { throw '找不到 git' } +} else { $gitCmd = $git.Source } + +Write-Host '== 目前狀態 ==' +& $gitCmd status --short + +Write-Host '' +Write-Host '== 若有變更則提交 ==' +$dirty = (& $gitCmd status --porcelain).Trim() -ne '' +if ($dirty) { + & $gitCmd add -A + if ($LASTEXITCODE -ne 0) { throw 'git add 失敗' } + & $gitCmd commit -m "Fix playerJoined roomId + add /api proxy to dist server" + if ($LASTEXITCODE -ne 0) { throw 'git commit 失敗' } +} else { + Write-Host '(無未提交變更)' +} + +Write-Host '' +Write-Host '== Push 到 fork(karylab -> karylab_agents)==' +& $gitCmd push karylab feat/web-game +if ($LASTEXITCODE -ne 0) { throw 'git push 失敗' } + +Write-Host '' +Write-Host '== 完成 ==' +& $gitCmd log --oneline -3 diff --git a/scripts/push_force_feat_web_game.ps1 b/scripts/push_force_feat_web_game.ps1 new file mode 100644 index 0000000..65aa857 --- /dev/null +++ b/scripts/push_force_feat_web_game.ps1 @@ -0,0 +1,34 @@ +# One-shot: commit pending changes, then force-with-lease push feat/web-game to fork (remote: karylab). +# --force-with-lease fails instead of overwriting if the remote moved since our fetch; never use plain -f. +# Usage: powershell -ExecutionPolicy Bypass -File scripts\push_force_feat_web_game.ps1 +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') + +$git = Get-Command git -ErrorAction SilentlyContinue +if (-not $git) { + $git = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe' +} else { $git = $git.Source } + +Write-Host '== Status before ==' +& $git status --short + +Write-Host '' +Write-Host '== Commit pending changes if any ==' +$porcelain = (& $git status --porcelain).Trim() +if ($porcelain -ne '') { + & $git add -A + if ($LASTEXITCODE -ne 0) { throw 'git add failed' } + & $git commit -m 'chore(scripts): ps1 UTF-8 BOM encoding + push helper scripts' + if ($LASTEXITCODE -ne 0) { throw 'git commit failed' } +} else { + Write-Host '(no pending changes)' +} + +Write-Host '' +Write-Host '== Force-with-lease push (fails instead of overwriting if remote moved) ==' +& $git push --force-with-lease karylab feat/web-game +if ($LASTEXITCODE -ne 0) { throw 'push failed (remote may have moved; do not use plain -f)' } + +Write-Host '' +Write-Host '== Done ==' +& $git log --oneline -1 \ No newline at end of file diff --git a/scripts/run_all_tests.ps1 b/scripts/run_all_tests.ps1 new file mode 100644 index 0000000..590af33 --- /dev/null +++ b/scripts/run_all_tests.ps1 @@ -0,0 +1,43 @@ +# 專案全量測試:Python + Node 引擎 + 後端 REST + 前端語法 +# 用法:.\scripts\run_all_tests.ps1 +$ErrorActionPreference = 'Continue' +$root = Split-Path -Parent $PSScriptRoot +$py = Join-Path $root '.venv\Scripts\python.exe' +$node = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' + +Set-Location $root + +Write-Host "`n===== [1/4] Python 語法檢查 =====" +& $py -m py_compile game.py player.py game_record.py llm_client.py multi_game_runner.py game_analyze.py player_matchup_analyze.py json_convert.py project_env.py +if ($LASTEXITCODE -eq 0) { Write-Host " 語法 OK" } else { Write-Host " 語法失敗" } + +Write-Host "`n===== [2/4] Python 引擎冒煙(mock LLM,不連網)=====" +& $py scripts\smoke_test_python.py + +Write-Host "`n===== [3/4] Node 引擎單元測試 =====" +& $node scripts\test_engine.js + +Write-Host "`n===== [3.5/4] Node 後端 REST 冒煙 =====" +& $node scripts\smoke_test_server.js + +Write-Host "`n===== [4/4] 前端 SFC 語法 =====" +Push-Location client +& $node -e " +const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc'); +const fs = require('fs'); +let failed = 0; +for (const f of ['src/components/GameTable.vue','src/components/Lobby.vue','src/App.vue']) { + const src = fs.readFileSync(f, 'utf8'); + const { descriptor, errors } = parse(src, { filename: f }); + let errs = errors.map(e=>e.message||e); + if (!errs.length) { try { compileScript(descriptor, { id: 'x1' }); } catch (e) { errs.push('script: '+e.message); } } + if (descriptor.template) { const t = compileTemplate({ source: descriptor.template.content, filename: f, id: 'x2' }); if (t.errors.length) errs.push('template: '+t.errors.map(x=>x.message||x).join('|')); } + if (errs.length) { console.log('FAIL', f, errs.join('\n ')); failed++; } else { console.log('OK ', f); } +} +process.exit(failed?1:0); +" +$code = $LASTEXITCODE +Pop-Location + +Write-Host "`n===== 完成(exit=$code)=====" +exit $code \ No newline at end of file diff --git a/scripts/run_e2e_local.js b/scripts/run_e2e_local.js new file mode 100644 index 0000000..35dc8a7 --- /dev/null +++ b/scripts/run_e2e_local.js @@ -0,0 +1,8 @@ +// 單進程端對端測試:載入 server(自動監聽 3000)→ 延遲 → 跑 test_socket_e2e.js +// 用法:node scripts/run_e2e_local.js(在專案根目錄執行) +require('../server/index.js'); +console.log('[runner] server 啟動中...'); +setTimeout(() => { + console.log('[runner] 開始端對端測試...'); + require('./test_socket_e2e.js'); +}, 1500); diff --git a/scripts/serve_dist.js b/scripts/serve_dist.js new file mode 100644 index 0000000..cb4f277 --- /dev/null +++ b/scripts/serve_dist.js @@ -0,0 +1,55 @@ +// 極簡靜態伺服器:服務 client/dist(前端 build 產物),並把 /api 代理到後端 :3000 +// 用法:node scripts/serve_dist.js [port] (預設 5173) +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const port = parseInt(process.argv[2] || '5173', 10); +const backendPort = parseInt(process.argv[3] || '3000', 10); +const distDir = path.resolve(__dirname, '../client/dist'); +const types = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.map': 'application/json' +}; + +if (!fs.existsSync(distDir)) { + console.error('找不到 client/dist,請先執行 scripts/build_frontend.ps1'); + process.exit(1); +} + +// /api -> 後端 proxy +function proxyApi(req, res) { + const proxyReq = http.request({ + host: 'localhost', + port: backendPort, + path: req.url, + method: req.method, + headers: Object.assign({}, req.headers, { host: 'localhost:' + backendPort }) + }, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + proxyReq.on('error', (e) => { res.writeHead(502); res.end('Bad Gateway: ' + e.message); }); + req.pipe(proxyReq); +} + +const server = http.createServer((req, res) => { + if (req.url.startsWith('/api')) { proxyApi(req, res); return; } + let urlPath = decodeURIComponent(req.url.split('?')[0]); + if (urlPath === '/') urlPath = '/index.html'; + const filePath = path.join(distDir, urlPath); + if (!filePath.startsWith(distDir)) { res.writeHead(403); res.end('Forbidden'); return; } + if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { res.writeHead(404); res.end('Not Found'); return; } + res.writeHead(200, { 'Content-Type': types[path.extname(filePath)] || 'application/octet-stream' }); + fs.createReadStream(filePath).pipe(res); +}); + +server.listen(port, () => { + console.log('靜態伺服器啟動於 http://localhost:' + port + '(服務 client/dist,/api 代理至 :' + backendPort + ')'); +}); diff --git a/scripts/smoke_test_python.py b/scripts/smoke_test_python.py new file mode 100644 index 0000000..3782484 --- /dev/null +++ b/scripts/smoke_test_python.py @@ -0,0 +1,96 @@ +"""Python 引擎冒煙測試(不連網、不呼叫真實 LLM)。 + +以「模擬 LLM」取代 API 呼叫,跑完整場騙子酒館遊戲,驗證 +game.py / player.py / game_record.py 主流程可正常完成並產生記錄。 +使用方式: + .venv\\Scripts\\python.exe scripts\\smoke_test_python.py +(若主控台遇到簡體中文編碼錯誤,請先設定 $env:PYTHONIOENCODING="utf-8") +""" +import os +import sys +import random + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +from game import Game + + +class MockChat: + """附著在某位玩家身上的假 LLM:依提示內容回傳格式正確的 JSON。 + 注意:chat() 統一回傳 (content, reasoning_content) 二元素 tuple。 + """ + + def __init__(self, player): + self.player = player + self.decision_count = 0 + + def chat(self, messages, model=None): + prompt = messages[0]["content"] if messages else "" + if "played_cards" in prompt: + return self._play_reply(), "" + if "was_challenged" in prompt: + return self._challenge_reply(), "" + return "mock opinion: \u8a72\u73a9\u5bb6\u76ee\u524d\u7121\u660e\u986f\u50be\u5411\u3002", "" + + def _play_reply(self): + """\u7d66\u4e86\u81ea\u5df1\u7684\u624b\u724c\uff1a\u540c\u9ede\u6578 >=2 \u51fa\u6700\u591a 3 \u5f35\uff0c\u5426\u5247\u51fa 1 \u5f35\u3002""" + hand = sorted(self.player.hand) + cards = [hand[0]] + for c in hand[1:3]: + if c == cards[0]: + cards.append(c) + cards_json = ",".join('"%s"' % c for c in cards) + return ('{"played_cards": [%s], "behavior": "\u7a69\u5065", ' + '"play_reason": "mock heuristic"}' % cards_json) + + def _challenge_reply(self): + """\u6bcf 4 \u6b21\u8cea\u7591\u6c7a\u7b56\u5c31\u8cea\u7591\u4e00\u6b21\uff0c\u8986\u84cb\u6210\u529f/\u5931\u6557\u5169\u689d\u8def\u5f91\u3002""" + self.decision_count += 1 + do_challenge = (self.decision_count % 4 == 0) + return ('{"was_challenged": %s, "challenge_reason": "mock trigger"}' % + ("true" if do_challenge else "false")) + + +def main(): + random.seed(20260803) + player_configs = [ + {"name": "DeepSeek", "model": "mock"}, + {"name": "GPT", "model": "mock"}, + {"name": "Claude", "model": "mock"}, + {"name": "Gemini", "model": "mock"}, + ] + + game = Game(player_configs) + for p in game.players: + p.llm_client = MockChat(p) + + max_plays = 400 + for step in range(max_plays): + if game.game_over: + break + game.play_round() + + if game.game_over: + print("[OK] \u904a\u6232\u7d50\u675f\uff0c\u4e3b\u6d41\u7a0b\u53ef\u6b63\u5e38\u5b8c\u6210\u3002") + else: + print(f"[FAIL] \u8dd1\u4e86 {max_plays} \u6b65\u4ecd\u672a\u7d50\u675f\uff08\u53ef\u80fd\u89c4\u5247\u6709\u6b7b\u7d50\uff09\u3002 rounds={game.round_count}") + return 1 + + rec = game.game_record + rounds = len(rec.rounds) + alive = [p.name for p in game.players if p.alive] + print(f"[OK] \u7372\u52dd\u8005={rec.winner} | \u5b58\u6d3b\u73a9\u5bb6={alive}") + print(f"[OK] \u8a18\u9304\u56de\u5408\u6578={rounds}") + + import glob + files = glob.glob(os.path.join(REPO_ROOT, "game_records", "*.json")) + print(f"[OK] game_records \u7522\u51fa {len(files)} \u500b JSON \u8a18\u9304\u6a94") + if files: + newest = max(files, key=os.path.getmtime) + print(f" \u6700\u65b0\u8a18\u9304: {os.path.basename(newest)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/scripts/smoke_test_server.js b/scripts/smoke_test_server.js new file mode 100644 index 0000000..0e3e0af --- /dev/null +++ b/scripts/smoke_test_server.js @@ -0,0 +1,33 @@ +// 後端冒煙測試:載入 index.js(自動監聽 3000)-> 測 REST 路由 -> 優雅關閉。 +// 使用方式: node scripts/smoke_test_server.js (需在專案根目錄執行) +const { app, io } = require("../server/index.js"); + +async function run() { + let exitCode = 0; + try { + const listRes = await fetch("http://localhost:3000/api/rooms"); + const listBody = await listRes.json(); + console.log("[OK] GET /api/rooms ->", listRes.status, JSON.stringify(listBody)); + + const createRes = await fetch("http://localhost:3000/api/rooms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hostId: "test-host-1" }), + }); + const room = await createRes.json(); + console.log("[OK] POST /api/rooms ->", createRes.status, + "roomId=", room.id, "players=", room.players.length, "status=", room.status); + } catch (e) { + console.error("[FAIL]", e.message); + exitCode = 1; + } + + io.close(() => { + console.log(exitCode === 0 ? "[OK] 後端可正常啟動與回應 REST API" : "[FAIL] 後端測試失敗"); + process.exit(exitCode); + }); + // 保險:2 秒後強制退出,避免卡住 + setTimeout(() => process.exit(exitCode), 2500); +} + +setTimeout(run, 1500); \ No newline at end of file diff --git a/scripts/smoke_test_socket.js b/scripts/smoke_test_socket.js new file mode 100644 index 0000000..4160599 --- /dev/null +++ b/scripts/smoke_test_socket.js @@ -0,0 +1,35 @@ +// Socket.IO 端對端冒煙測試:連上後端 -> createRoom -> 收到 roomCreated 即成功。 +// 使用:需先有伺服器在 http://localhost:3000 執行。 +const { io } = require("../client/node_modules/socket.io-client"); + +const socket = io("http://localhost:3000", { + transports: ["websocket"], + reconnection: false, + timeout: 6000, +}); + +let done = false; + +socket.on("connect", () => { + console.log("[OK] socket connected, id =", socket.id); + socket.emit("createRoom"); +}); + +socket.on("roomCreated", (room) => { + console.log("[OK] roomCreated -> roomId=", room.id, "status=", room.status, "players=", room.players.length); + done = true; + socket.close(); + process.exit(0); +}); + +socket.on("connect_error", (e) => { + console.error("[FAIL] connect_error:", e.message); + process.exit(1); +}); + +setTimeout(() => { + if (!done) { + console.error("[FAIL] 8 秒內未收到 roomCreated(逾時)"); + process.exit(1); + } +}, 8000); \ No newline at end of file diff --git a/scripts/test_engine.js b/scripts/test_engine.js new file mode 100644 index 0000000..9bbf56f --- /dev/null +++ b/scripts/test_engine.js @@ -0,0 +1,217 @@ +// 引擎單元測試(用 Node 內建 assert,無外部依賴) +// 使用:node scripts/test_engine.js +const assert = require('assert'); +const GameEngine = require('../server/game/engine.js'); + +const results = []; +function test(name, fn) { + try { fn(); results.push(['ok', name]); } + catch (e) { results.push(['fail', name, e.message]); } +} + +// 1) 發牌與牌組 +test('init 4 人:每人 5 張、目標牌正確、round=1', () => { + const e = new GameEngine(); + const st = e.init(['p1', 'p2', 'p3', 'p4']); + assert.strictEqual(st.players.length, 4); + for (const p of st.players) assert.strictEqual(p.handSize, 5); + assert.ok(['Q', 'K', 'A'].includes(st.targetCard)); + assert.strictEqual(st.roundNumber, 1); + assert.ok(st.currentPlayerId); +}); + +test('init 8 人:每人 5 張', () => { + const e = new GameEngine(); + const st = e.init(Array.from({ length: 8 }, (_, i) => 'p' + i)); + assert.strictEqual(st.players.length, 8); + for (const p of st.players) assert.strictEqual(p.handSize, 5); +}); + +test('牌組總張數 = 人數 x5(2/3/5/8)', () => { + for (const n of [2, 3, 5, 8]) { + const e = new GameEngine(); + const deck = e.createDeck(n); + assert.strictEqual(deck.length, n * 5, `n=${n}`); + } +}); + +// 2) 出牌驗證 +test('出牌:0 張 → 錯誤', () => { + const e = new GameEngine(); e.init(['a', 'b']); + assert.ok(e.playCard(e.currentPlayerId, []).error); +}); +test('出牌:4 張 → 錯誤', () => { + const e = new GameEngine(); e.init(['a', 'b']); + assert.ok(e.playCard(e.currentPlayerId, [0, 1, 2, 3]).error); +}); +test('出牌:索引超出範圍 → 錯誤', () => { + const e = new GameEngine(); e.init(['a', 'b']); + assert.ok(e.playCard(e.currentPlayerId, [99]).error); +}); +test('出牌:重複索引 → 錯誤', () => { + const e = new GameEngine(); e.init(['a', 'b']); + assert.ok(e.playCard(e.currentPlayerId, [0, 0]).error); +}); +test('出牌:非你的回合 → 錯誤', () => { + const e = new GameEngine(); e.init(['a', 'b']); + const other = Array.from(e.players.keys()).find((k) => k !== e.currentPlayerId); + assert.ok(e.playCard(other, [0]).error); +}); +test('出牌:合法出 1-3 張並移除、換下一位', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + const pid = e.currentPlayerId; + const before = e.getPlayerHand(pid).length; + const r = e.playCard(pid, [0]); + assert.strictEqual(r.error, undefined); + assert.strictEqual(e.getPlayerHand(pid).length, before - 1); + assert.strictEqual(r.nextPlayerId, e.currentPlayerId); +}); + +// 3) 質疑:整組判定 +test('質疑:吹牛組(含非目標牌)→ 質疑成功,出牌者開槍', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + e.targetCard = 'A'; + e.getPlayer('a').hand = ['A', 'K', 'JOKER']; + e.getPlayer('b').hand = ['A', 'A', 'A']; + e.getPlayer('c').hand = ['A', 'A']; + e.currentPlayerId = 'a'; + const r = e.playCard('a', [0, 1]); // A, K + assert.strictEqual(r.error, undefined); + const c = e.challenge('b'); + assert.strictEqual(c.isBluff, true); + assert.strictEqual(c.shooter, 'a'); +}); + +test('質疑:真牌組(全目標牌)→ 質疑失敗,質疑者開槍', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + e.targetCard = 'A'; + e.getPlayer('a').hand = ['A', 'Q', 'Q']; + e.getPlayer('b').hand = ['A', 'A', 'A']; + e.getPlayer('c').hand = ['A', 'A']; + e.currentPlayerId = 'a'; + e.playCard('a', [0]); // A + const c = e.challenge('b'); + assert.strictEqual(c.isBluff, false); + assert.strictEqual(c.shooter, 'b'); +}); + +test('質疑:Joker 視為萬能牌 → 質疑失敗', () => { + // 用 3 人局:a 出 JOKER 後仍有玩家( c )持牌,b 才能主動質疑 + const e = new GameEngine(); e.init(['a', 'b', 'c']); + e.targetCard = 'K'; + e.getPlayer('a').hand = ['JOKER', 'Q']; + e.getPlayer('b').hand = ['K', 'K']; + e.getPlayer('c').hand = ['Q', 'Q', 'Q']; + e.currentPlayerId = 'a'; + e.playCard('a', [0]); // JOKER(宣稱 K) + const c = e.challenge('b'); + assert.strictEqual(c.isBluff, false); // JOKER 是萬能牌 → 質疑失敗 +}); + +// 3.5) 資訊邊界:公開狀態不洩漏實際牌值 +test('資訊邊界:出牌後 gameState 只含張數、不含真實牌值', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + const pid = e.currentPlayerId; + e.playCard(pid, [0]); + const st = e.getGameState(); + for (const g of st.playedGroups) { + assert.strictEqual(typeof g.cardCount, 'number'); + assert.strictEqual(g.cards, undefined, 'playedGroups 不應洩漏 cards'); + } + assert.ok(!Array.isArray(st.playedCards) || st.playedCards.length === 0, 'playedCards 不應含牌值'); +}); +test('質疑時才揭露真實牌值(challengedGroup)', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + e.targetCard = 'A'; + e.currentPlayerId = 'a'; + e.playCard('a', [0]); + const lastGroup = e.playGroups[e.playGroups.length - 1]; + const before = [...lastGroup.cards]; // 引擎內部可讀 + const st = e.getGameState(); + // 公開狀態沒有牌值 + for (const g of st.playedGroups) assert.strictEqual(g.cards, undefined); + const r = e.challenge('b'); + assert.ok(Array.isArray(r.challengedGroup) && r.challengedGroup.length === before.length, + '質疑結果才揭露牌值'); +}); + +// 4) 系統質疑 +test('系統質疑:輪到某玩家且其他人都沒牌 → 自動打出並質疑', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + e.targetCard = 'A'; + e.getPlayer('a').hand = ['K', 'K']; + e.getPlayer('b').hand = ['A']; + e.getPlayer('c').hand = []; + e.currentPlayerId = 'b'; + const r = e.playCard('b', [0]); // b 出 A,手牌清空 + assert.ok(r.systemChallenge, '應觸發系統質疑'); + assert.strictEqual(r.systemChallenge.playerId, 'a'); + assert.strictEqual(r.systemChallenge.isBluff, true); // K 不是 A + assert.strictEqual(r.systemChallenge.shooter, 'a'); +}); + +test('系統質疑:若剩餘全為真牌 → 無人開槍並重開一手', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + e.targetCard = 'A'; + e.getPlayer('a').hand = ['A', 'A']; + e.getPlayer('b').hand = ['A']; + e.getPlayer('c').hand = []; + e.currentPlayerId = 'b'; + const r = e.playCard('b', [0]); + assert.ok(r.systemChallenge); + assert.strictEqual(r.systemChallenge.isBluff, false); + assert.strictEqual(r.systemChallenge.shooter, null); + // 重開一手後 a 仍有 5 張 + assert.strictEqual(e.getPlayerHand('a').length, 5); +}); + +// 5) 重開一手與死亡 +test('質疑後重開一手:存活者各 5 張、死掉者 0 張', () => { + const e = new GameEngine(); e.init(['a', 'b', 'c']); + e.targetCard = 'A'; + e.getPlayer('a').hand = ['A', 'K', 'JOKER']; + e.getPlayer('b').hand = ['A', 'A', 'A']; + e.getPlayer('c').hand = ['A', 'A']; + e.currentPlayerId = 'a'; + e.playCard('a', [0, 1]); // A, K 吹牛 + // 讓 a 必死 + e.getPlayer('a').revolver = { chambers: [true, false, false, false, false, false], currentChamber: 0 }; + const c = e.challenge('b'); + assert.strictEqual(c.shooter, 'a'); + assert.strictEqual(e.getPlayer('a').alive, false); + assert.strictEqual(e.getPlayerHand('a').length, 0); + assert.strictEqual(e.getPlayerHand('b').length, 5); + assert.ok(e.roundNumber >= 2); +}); + +// 6) 完整模擬:必定會結束 +test('完整隨機模擬可正常結束,存活者 <= 1', () => { + const e = new GameEngine(); + e.init(['p1', 'p2', 'p3', 'p4']); + let guard = 0; + while (!e.isGameOver && guard < 3000) { + guard++; + const pid = e.currentPlayerId; + if (!pid) break; + const hand = e.getPlayerHand(pid); + if (hand.length === 0) break; + const n = 1 + Math.floor(Math.random() * Math.min(3, hand.length)); + const idx = Array.from({ length: n }, (_, i) => i); + e.playCard(pid, idx); + if (Math.random() < 0.35 && e.playGroups.length > 0) { + e.challenge(e.currentPlayerId); + } + } + assert.ok(e.isGameOver || guard >= 3000 === false, '遊戲應結束(無死結)'); + assert.ok(e.getAlivePlayers().length <= 1, '最多一位存活'); + assert.ok(e.roundNumber > 0); +}); + +// 印出結果 +let failed = 0; +for (const [status, name, msg] of results) { + if (status === 'ok') console.log(' PASS ' + name); + else { failed++; console.log(' FAIL ' + name + ' -> ' + msg); } +} +console.log(`\n${results.length - failed}/${results.length} 通過`); +process.exit(failed ? 1 : 0); \ No newline at end of file diff --git a/scripts/test_socket_e2e.js b/scripts/test_socket_e2e.js new file mode 100644 index 0000000..53e9289 --- /dev/null +++ b/scripts/test_socket_e2e.js @@ -0,0 +1,109 @@ +// 多人端對端整合測試:連真實伺服器,驗證「多張出牌 → 下家挑戰 → 手牌更新」流程。 +// 使用:先啟動 server(node server/index.js),再跑 node scripts/test_socket_e2e.js +const { io } = require('../client/node_modules/socket.io-client'); + +const URL = 'http://localhost:3000'; +const clients = {}; + +function connect(name) { + return new Promise((resolve, reject) => { + const s = io(URL, { transports: ['websocket'], reconnection: false, timeout: 8000 }); + s._hand = null; + s._name = name; + s._lastError = null; + s.on('connect', () => { clients[s.id] = s; resolve(s); }); + s.on('connect_error', (e) => reject(new Error(name + ' connect_error: ' + e.message))); + s.on('yourHand', (h) => { s._hand = h || []; }); + s.on('error', (d) => { s._lastError = d && d.message; }); + }); +} + +function once(socket, ev, timeout = 8000) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`${socket._name} wait ${ev} timeout`)), timeout); + socket.once(ev, (d) => { clearTimeout(t); resolve(d); }); + }); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failures = 0; +function ok(cond, msg) { + if (cond) console.log(' PASS ' + msg); + else { failures++; console.log(' FAIL ' + msg); } +} + +(async () => { + console.log('[1] 三玩家連線+房間'); + const a = await connect('A'); + const b = await connect('B'); + const c = await connect('C'); + + const roomCreated = once(a, 'roomCreated'); + a.emit('createRoom'); + const room = await roomCreated; + const roomId = room.id; + ok(roomId, '建立房間 roomId=' + roomId); + + b.emit('joinRoom', { roomId }); + await once(a, 'playerJoined'); + c.emit('joinRoom', { roomId }); + await once(a, 'playerJoined'); + ok(true, 'B、C 加入'); + + console.log('[2] 開始遊戲 + 私密手牌'); + const started = [once(a, 'gameStarted'), once(b, 'gameStarted'), once(c, 'gameStarted')]; + a.emit('startGame', { roomId }); + const [gsA] = await Promise.all(started); + ok(gsA.currentPlayerId && ['Q','K','A'].includes(gsA.targetCard), 'gameStarted 狀態正確'); + ok(gsA.players.length === 3, '3 位玩家'); + await sleep(300); + ok(a._hand.length === 5 && b._hand.length === 5 && c._hand.length === 5, + '每位玩家收到 5 張私密手牌(yourHand)'); + + console.log('[3] 目前玩家多張出牌'); + const cur = clients[gsA.currentPlayerId]; + ok(!!cur, '定位目前玩家'); + const playedEvt = once(a, 'cardPlayed'); + cur.emit('playCard', { roomId, cardIndices: [0, 1] }); + const played = await playedEvt; + // 資訊邊界:cardPlayed 只公開張數,不洩漏實際牌值 + ok(played.playedGroup && played.playedGroup.cardCount === 2, '出牌 2 張並廣播(只含張數)'); + ok(!('cards' in (played.playedGroup || {})), 'cardPlayed 不洩漏實際牌值'); + const currentNow = played.gameState.currentPlayerId; + ok(currentNow !== cur.id, '換下一位'); + await sleep(300); + ok(cur._hand.length === 3, `出牌者剩 3 張 (yourHand 同步,實際=${cur._hand.length})`); + + console.log('[4] 非我的回合出牌 → 被拒絕'); + const notTurn = [a, b, c].find((s) => s.id !== cur.id && s.id !== currentNow); + ok(!!notTurn, '找到非回合玩家'); + notTurn._lastError = null; + notTurn.emit('playCard', { roomId, cardIndices: [0] }); + await sleep(300); + ok(!!notTurn._lastError, '非回合者被拒絕(' + (notTurn._lastError || 'none') + ')'); + + console.log('[5] 目前玩家(下家)質疑 → 收到結果與新狀態'); + const currentSocket = clients[currentNow]; + const challengedEvt = once(a, 'challenged'); + currentSocket.emit('challenge', { roomId }); + const ch = await challengedEvt; + ok(ch.result && typeof ch.result.isBluff === 'boolean', '質疑結果帶 isBluff=' + ch.result.isBluff); + ok(ch.result.shooter, '有開槍者(shooter=' + ch.result.shooter + ')'); + ok(ch.gameState.roundNumber >= 2, '質疑後進入下一局(round=' + ch.gameState.roundNumber + ')'); + await sleep(300); + ok(ch.gameState.players.every((p) => { + const sh = clients[p.id]; + return !sh || (sh._hand.length === p.handSize); + }), 'yourHand 與公開狀態一致(' + ch.gameState.players.map((p) => p.handSize).join(',') + ')'); + + const aliveCount = ch.gameState.players.filter((p) => p.alive).length; + console.log(`[6] 結果快照:第 ${ch.gameState.roundNumber} 局,存活 ${aliveCount}/3,isBluff=${ch.result.isBluff}`); + + [a, b, c].forEach((s) => s.close()); + await sleep(300); + console.log(failures === 0 ? '\n整合測試全部通過' : `\n有 ${failures} 項失敗`); + process.exit(failures ? 1 : 0); +})().catch((e) => { + console.error('[FATAL]', e.message); + process.exit(1); +}); \ No newline at end of file diff --git a/server/game/engine.js b/server/game/engine.js index 61d12fe..2d415b0 100644 --- a/server/game/engine.js +++ b/server/game/engine.js @@ -1,75 +1,81 @@ -// 遊戲規則常數 -const CARDS = { - Q: 6, - K: 6, - A: 6, - JOKER: 2 -}; +// 遊戲引擎核心 (Liar's Bar) +// 規則與 Python 版 (game.py) 對齊: +// - 每手每人 5 張,牌組依玩家人數等比擴充(Q:K:A 等量 + 約 10% Joker),支援 2-8 人 +// - 每次可出 1-3 張,宣稱皆為目標牌;Joker 為萬能牌 +// - 下一位玩家可選擇出牌或質疑「上一組」出的牌 +// - 質疑:整組牌全為目標牌/Joker 則質疑失敗(質疑者開槍),否則質疑成功(出牌者開槍) +// - 質疑後本手結束,存活玩家重新發牌並選新目標牌 +// - 特殊:輪到某玩家時若其他存活玩家已無牌,該玩家人餘牌視為自動打出並受系統質疑 +// - 僅存一位存活者時結束 const HAND_SIZE = 5; const REVOLVER_CHAMBERS = 6; -const BULLET_COUNT = 1; +const TARGETS = ['Q', 'K', 'A']; +const MAX_PLAY_PER_TURN = 3; class GameEngine { constructor() { - this.players = new Map(); + this.players = new Map(); // id -> { id, hand, revolver, alive } this.currentPlayerId = null; this.targetCard = null; - this.playedCards = []; - this.gameLog = []; + this.playGroups = []; // 本手連續出牌組 [{ playerId, cards }] this.roundNumber = 0; - this.deck = []; this.isGameOver = false; - this.playersWhoPlayedThisRound = new Set(); + this.winnerId = null; + this.lastShooterId = null; // 上一手開槍者(存活時作為下一手起始) } - // 初始化遊戲 + // ---------- 初始化 ---------- + init(playerIds) { this.players.clear(); this.currentPlayerId = null; this.targetCard = null; - this.playedCards = []; - this.gameLog = []; + this.playGroups = []; this.roundNumber = 0; this.isGameOver = false; - this.playersWhoPlayedThisRound.clear(); + this.winnerId = null; + this.lastShooterId = null; - // 建立牌組 - this.deck = this.createDeck(); - this.shuffleDeck(this.deck); - - // 發牌 for (const id of playerIds) { this.players.set(id, { id, - hand: this.deck.splice(0, HAND_SIZE), + hand: [], revolver: this.createRevolver(), - alive: true, - score: 0 + alive: true }); } - // 設定目標牌 + this.roundNumber = 1; + this.dealCards(this.getAlivePlayers().length); this.setTargetCard(); - - // 設定第一位玩家 - this.currentPlayerId = this.getNextAlivePlayer(); - + this.currentPlayerId = this.pickRandomAlivePlayer(); return this.getGameState(); } - // 建立牌組 - createDeck() { + // 依存活人數建立等比牌組(每人 5 張) + createDeck(playerCount) { + const total = playerCount * HAND_SIZE; + const jokerCount = Math.max(1, Math.round(total * 0.1)); + const normalTotal = total - jokerCount; + const base = Math.floor(normalTotal / 3); + let remainder = normalTotal % 3; + const counts = { Q: base, K: base, A: base }; + for (const key of TARGETS) { + if (remainder > 0) { counts[key] += 1; remainder -= 1; } + } const deck = []; - for (let i = 0; i < CARDS.Q; i++) deck.push('Q'); - for (let i = 0; i < CARDS.K; i++) deck.push('K'); - for (let i = 0; i < CARDS.A; i++) deck.push('A'); - for (let i = 0; i < CARDS.JOKER; i++) deck.push('JOKER'); + for (const key of Object.keys(counts)) { + for (let i = 0; i < counts[key]; i++) deck.push(key); + } + for (let i = 0; i < jokerCount; i++) deck.push('JOKER'); + this.shuffle(deck); return deck; } - // 洗牌 - shuffleDeck(deck) { + // ---------- 基本工具 ---------- + + shuffle(deck) { for (let i = deck.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [deck[i], deck[j]] = [deck[j], deck[i]]; @@ -77,211 +83,256 @@ class GameEngine { return deck; } - // 建立左輪手槍 createRevolver() { const chambers = Array(REVOLVER_CHAMBERS).fill(false); - const bulletIndex = Math.floor(Math.random() * REVOLVER_CHAMBERS); - chambers[bulletIndex] = true; - return { - chambers, - currentChamber: 0, - hasBullet: false - }; + chambers[Math.floor(Math.random() * REVOLVER_CHAMBERS)] = true; + return { chambers, currentChamber: 0 }; } - // 設定目標牌 setTargetCard() { - const targetCards = ['Q', 'K', 'A']; - this.targetCard = targetCards[Math.floor(Math.random() * targetCards.length)]; + this.targetCard = TARGETS[Math.floor(Math.random() * TARGETS.length)]; } - // 取得下一位存活玩家 - getNextAlivePlayer() { - const alivePlayers = Array.from(this.players.values()).filter(p => p.alive); - if (alivePlayers.length === 0) return null; + getAlivePlayers() { + return Array.from(this.players.values()).filter((p) => p.alive); + } - // 如果只剩一人,遊戲結束 - if (alivePlayers.length === 1) { - this.isGameOver = true; - return alivePlayers[0].id; + getPlayer(playerId) { + return this.players.get(playerId); + } + + pickRandomAlivePlayer() { + const alive = this.getAlivePlayers(); + return alive.length ? alive[Math.floor(Math.random() * alive.length)].id : null; + } + + // 目前玩家的下一位「有牌且存活」的玩家 + getNextAlivePlayerWithCards() { + const ids = Array.from(this.players.keys()); + if (ids.length === 0) return null; + const curIdx = ids.indexOf(this.currentPlayerId); + for (let i = 1; i <= ids.length; i++) { + const player = this.players.get(ids[(curIdx + i) % ids.length]); + if (player && player.alive && player.hand.length > 0) return player.id; } + // 所有人手上都無牌:回傳目前玩家(由呼叫端處理) + return this.currentPlayerId; + } - // 找當前玩家之後的第一位存活者 - const playerIds = Array.from(this.players.keys()); - const currentIndex = playerIds.indexOf(this.currentPlayerId); - - for (let i = 1; i <= playerIds.length; i++) { - const nextIndex = (currentIndex + i) % playerIds.length; - const nextPlayer = this.players.get(playerIds[nextIndex]); - if (nextPlayer && nextPlayer.alive) { - return nextPlayer.id; + isAllOthersEmpty(playerId) { + return this.getAlivePlayers() + .filter((p) => p.id !== playerId) + .every((p) => p.hand.length === 0); + } + + // ---------- 回合流程 ---------- + + // 出牌:cardIndices 為 1-3 個手牌索引(此輪宣稱皆為目標牌) + playCard(playerId, cardIndices) { + if (this.isGameOver) return { error: '遊戲已結束' }; + const player = this.getPlayer(playerId); + if (!player || !player.alive) return { error: '玩家已死亡' }; + if (playerId !== this.currentPlayerId) return { error: '還沒輪到你出牌' }; + + if (!Array.isArray(cardIndices) || cardIndices.length < 1 || cardIndices.length > MAX_PLAY_PER_TURN) { + return { error: `每次只能出 1-${MAX_PLAY_PER_TURN} 張` }; + } + const unique = new Set(cardIndices); + if (unique.size !== cardIndices.length) { + return { error: '不能重複選擇同一張牌' }; + } + for (const idx of cardIndices) { + if (!Number.isInteger(idx) || idx < 0 || idx >= player.hand.length) { + return { error: '無效的選牌索引' }; } } - return alivePlayers[0].id; + const played = cardIndices.map((idx) => player.hand[idx]); + // 由大到小移除,避免索引位移 + [...cardIndices].sort((a, b) => b - a).forEach((idx) => player.hand.splice(idx, 1)); + this.playGroups.push({ playerId: player.id, cards: played }); + + this.currentPlayerId = this.getNextAlivePlayerWithCards(); + + const baseResult = { + success: true, + playedGroup: { playerId: player.id, cards: played }, + nextPlayerId: this.currentPlayerId + }; + + // 特殊規則:輪到 nextPlayer 時其他存活玩家已無牌 → 系統質疑(自動打出剩餘手牌) + const allAliveEmpty = this.getAlivePlayers().every((p) => p.hand.length === 0); + const othersEmpty = !allAliveEmpty + && this.currentPlayerId !== null + && this.getPlayer(this.currentPlayerId).hand.length > 0 + && this.isAllOthersEmpty(this.currentPlayerId); + + if (othersEmpty) { + const sys = this.systemChallenge(this.currentPlayerId); + baseResult.systemChallenge = sys; + } else if (allAliveEmpty) { + // 所有人同時無牌(理論上不會發生)→ 視為無效質疑,重開一手 + baseResult.autoRoundReset = true; + this.startNewRound(); + } + + return baseResult; } - // 出牌 - playCard(playerId, cardIndex) { - if (this.isGameOver) { - return { error: '遊戲已結束' }; + // 系統質疑:自動打出某玩家剩餘手牌並質疑 + systemChallenge(playerId) { + const player = this.getPlayer(playerId); + const cards = [...player.hand]; + player.hand = []; + const valid = cards.length > 0 && cards.every((c) => c === this.targetCard || c === 'JOKER'); + const isBluff = !valid; + + let shooter = null; + let shot = null; + if (isBluff) { + // 剩餘手牌有假牌 → 出牌者開槍 + shooter = playerId; + shot = this.fireRevolver(playerId); + } else { + // 全是真牌 → 系統質疑失敗(與 Python 一致:無人開槍) + shot = { playerId, hasBullet: false, alive: player.alive }; } - const player = this.players.get(playerId); - if (!player || !player.alive) { - return { error: '玩家已死亡' }; - } - - // 檢查輪次 - if (playerId !== this.currentPlayerId) { - return { error: '不是你的回合' }; - } - - if (cardIndex < 0 || cardIndex >= player.hand.length) { - return { error: '無效的牌' }; - } - - const card = player.hand[cardIndex]; - player.hand.splice(cardIndex, 1); - this.playedCards.push({ playerId, card }); - this.playersWhoPlayedThisRound.add(playerId); - - // 移動到下一位玩家 - this.currentPlayerId = this.getNextAlivePlayer(); - - // 檢查是否所有存活玩家都出過牌 - const alivePlayers = Array.from(this.players.values()).filter(p => p.alive); - if (this.playersWhoPlayedThisRound.size >= alivePlayers.length) { - this.endRound(); - } + this.clearTable(); + this.startNewRound(); return { - success: true, - card, - remainingCards: player.hand.length, - nextPlayerId: this.currentPlayerId + systemChallenge: true, + playerId, + autoPlayedCards: cards, + isBluff, + shooter, + shot }; } - // 結束回合 - endRound() { - this.playedCards = []; - this.playersWhoPlayedThisRound.clear(); - this.roundNumber++; - this.setTargetCard(); - } - - // 質疑 + // 質疑上一組出的牌(由下一位玩家執行) challenge(challengerId) { - if (this.isGameOver) { - return { error: '遊戲已結束' }; - } + if (this.isGameOver) return { error: '遊戲已結束' }; + const challenger = this.getPlayer(challengerId); + if (!challenger || !challenger.alive) return { error: '挑戰者已死亡' }; + if (challengerId !== this.currentPlayerId) return { error: '還沒輪到你質疑' }; - const challenger = this.players.get(challengerId); - if (!challenger || !challenger.alive) { - return { error: '挑戰者已死亡' }; - } + const last = this.playGroups[this.playGroups.length - 1]; + if (!last) return { error: '還沒有出牌可以質疑' }; - // 檢查出牌者 - const lastPlay = this.playedCards[this.playedCards.length - 1]; - if (!lastPlay) { - return { error: '沒有出牌可質疑' }; - } + const valid = last.cards.length > 0 && last.cards.every((c) => c === this.targetCard || c === 'JOKER'); + const isBluff = !valid; - // 檢查是否下家 - if (challengerId !== this.currentPlayerId) { - return { error: '不是你的回合' }; - } - - const player = this.players.get(lastPlay.playerId); - - // 判斷質疑結果 - const isBluff = this.isBluff(lastPlay.card); const result = { challengerId, - playerId: lastPlay.playerId, + playerId: last.playerId, isBluff, - challengedCard: lastPlay.card + challengedGroup: last.cards }; if (isBluff) { - // 質疑正確,出牌者開槍 - result.shooter = lastPlay.playerId; - this.fireRevolver(lastPlay.playerId); + // 質疑成功 → 出牌者開槍 + result.shooter = last.playerId; + result.shot = this.fireRevolver(last.playerId); } else { - // 質疑錯誤,挑戰者開槍 + // 質疑失敗 → 質疑者開槍 result.shooter = challengerId; - this.fireRevolver(challengerId); + result.shot = this.fireRevolver(challengerId); } - // 清空已出牌 - this.playedCards = []; - this.playersWhoPlayedThisRound.clear(); - + this.clearTable(); + // 質疑後本手結束,重開一手(若遊戲還未結束) + this.startNewRound(); return result; } - // 判斷是否吹牛 - isBluff(card) { - // 如果出牌不是目標牌,就是吹牛 - return card !== this.targetCard; + // 重開一手:重新發牌、選目標牌、決定起始玩家 + startNewRound() { + const alive = this.getAlivePlayers(); + if (alive.length <= 1) { + this.isGameOver = true; + this.winnerId = alive.length === 1 ? alive[0].id : null; + return; + } + this.roundNumber += 1; + this.clearTable(); + this.setTargetCard(); + this.dealCards(alive.length); + + // 起始玩家:上一手開槍者(若存活),否則隨機 + if (this.lastShooterId && this.getPlayer(this.lastShooterId) && this.getPlayer(this.lastShooterId).alive) { + this.currentPlayerId = this.lastShooterId; + } else { + this.currentPlayerId = this.pickRandomAlivePlayer(); + } } - // 開槍 - fireRevolver(playerId) { - const player = this.players.get(playerId); - if (!player) return; + clearTable() { + this.playGroups = []; + } + dealCards(aliveCount) { + for (const p of this.players.values()) p.hand = []; + const deck = this.createDeck(aliveCount); + for (let round = 0; round < HAND_SIZE; round++) { + for (const id of this.players.keys()) { + const p = this.players.get(id); + if (p.alive && deck.length > 0) p.hand.push(deck.pop()); + } + } + } + + // ---------- 射擊 / 結束 ---------- + + fireRevolver(playerId) { + const player = this.getPlayer(playerId); + if (!player) return null; const revolver = player.revolver; const hasBullet = revolver.chambers[revolver.currentChamber]; - - // 移動到下一倉 revolver.currentChamber = (revolver.currentChamber + 1) % REVOLVER_CHAMBERS; if (hasBullet) { player.alive = false; - player.revolver = this.createRevolver(); // 重置手槍 + player.revolver = this.createRevolver(); // 死亡後重置左輪手槍 } - // 檢查遊戲是否結束 + this.lastShooterId = playerId; this.checkGameOver(); - return { - playerId, - hasBullet, - alive: player.alive, - isGameOver: this.isGameOver - }; + return { playerId, hasBullet, alive: player.alive, isGameOver: this.isGameOver }; } - // 檢查遊戲是否結束 checkGameOver() { - const alivePlayers = Array.from(this.players.values()).filter(p => p.alive); - if (alivePlayers.length <= 1) { + if (this.getAlivePlayers().length <= 1) { this.isGameOver = true; + const alive = this.getAlivePlayers(); + this.winnerId = alive.length === 1 ? alive[0].id : null; } } - // 移除玩家(離線處理) removePlayer(playerId) { - const player = this.players.get(playerId); + const player = this.getPlayer(playerId); if (player) { player.alive = false; + player.hand = []; this.checkGameOver(); } } - // 取得遊戲狀態 + // ---------- 對外狀態 ---------- + getGameState() { const state = { players: [], currentPlayerId: this.currentPlayerId, targetCard: this.targetCard, - playedCards: this.playedCards, + playedCards: [], + playedGroups: this.playGroups.map((g) => ({ playerId: g.playerId, cardCount: g.cards.length })), roundNumber: this.roundNumber, - isGameOver: this.isGameOver + isGameOver: this.isGameOver, + winnerId: this.winnerId }; - for (const [id, player] of this.players) { state.players.push({ id, @@ -290,16 +341,20 @@ class GameEngine { revolverChambers: player.revolver.currentChamber }); } - + // 資訊邊界:公開狀態只顯示「誰出了幾張」,不洩漏真實牌值; + // 真實牌值只在質疑(challenge / systemChallenge)時透過 challenged 事件揭露。 return state; } - // 取得玩家手牌(回傳副本,防止篡改) getPlayerHand(playerId) { - const player = this.players.get(playerId); + const player = this.getPlayer(playerId); if (!player) return []; - return [...player.hand]; // 回傳副本 + return [...player.hand]; + } + + isBluff(cards) { + return !(cards.length > 0 && cards.every((c) => c === this.targetCard || c === 'JOKER')); } } -module.exports = GameEngine; +module.exports = GameEngine; \ No newline at end of file diff --git a/server/game/room.js b/server/game/room.js index a4c2ec3..2405a78 100644 --- a/server/game/room.js +++ b/server/game/room.js @@ -16,7 +16,7 @@ class RoomManager { engine: null, gameState: null, status: 'waiting', // waiting, playing, ended - maxPlayers: 4, // 20 張牌 ÷ 5 張 = 最多 4 人 + maxPlayers: 8, // 引擎牌組依人數等比擴充,支援 2-8 人 minPlayers: 2 }; @@ -96,12 +96,22 @@ class RoomManager { for (const [roomId, room] of this.rooms) { if (room.players.includes(playerId)) { room.players = room.players.filter(id => id !== playerId); + + // 同步通知遊戲引擎(將被移除玩家標記為死亡) + if (room.engine) { + room.engine.removePlayer(playerId); + } // 如果房間人數不足,結束遊戲 if (room.players.length < room.minPlayers && room.status === 'playing') { room.status = 'ended'; } - + + // 空房清理:沒有玩家時刪除房間,避免一直累積 + if (room.players.length === 0) { + this.rooms.delete(roomId); + } + return { roomId, room }; } } diff --git a/server/index.js b/server/index.js index eb2fd72..1a54647 100644 --- a/server/index.js +++ b/server/index.js @@ -7,138 +7,138 @@ const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { - origin: "*", - methods: ["GET", "POST"] + origin: '*', + methods: ['GET', 'POST'] } }); const roomManager = new RoomManager(); -// 中間件 app.use(express.json()); -// 驗證房間權限 +// 把每個玩家的「私密手牌」推給他自己(不公開) +function broadcastHands(room) { + if (!room || !room.engine) return; + for (const playerId of room.players) { + io.to(playerId).emit('yourHand', room.engine.getPlayerHand(playerId)); + } +} + +// 驗證房間成員 function validateRoom(socket, roomId) { if (!socket.rooms.has(roomId)) { - socket.emit('error', { message: '無權限操作此房間' }); + socket.emit('error', { message: '尚未加入該房間' }); return false; } return true; } -// Socket.IO 連線 +// Socket.IO 事件 io.on('connection', (socket) => { console.log(`玩家連線: ${socket.id}`); // 建立房間 - socket.on('createRoom', (data) => { + socket.on('createRoom', () => { const room = roomManager.createRoom(socket.id); socket.join(room.id); socket.data = { roomId: room.id }; - io.to(room.id).emit('roomCreated', room); console.log(`房間建立: ${room.id}`); }); // 加入房間 socket.on('joinRoom', (data) => { - const { roomId } = data; + const { roomId } = data || {}; const result = roomManager.joinRoom(roomId, socket.id); - if (result.error) { socket.emit('error', { message: result.error }); return; } - socket.join(roomId); socket.data = { roomId }; - io.to(roomId).emit('playerJoined', result.room); - console.log(`玩家加入房間: ${roomId}`); + console.log(`玩家加入: ${roomId}`); }); // 開始遊戲 socket.on('startGame', (data) => { - const { roomId } = data; + const { roomId } = data || {}; if (!validateRoom(socket, roomId)) return; - const result = roomManager.startGame(roomId); - if (result.error) { socket.emit('error', { message: result.error }); return; } - io.to(roomId).emit('gameStarted', result.gameState); - console.log(`遊戲開始: ${roomId}`); + // 把每個人自己的手牌個別送達 + broadcastHands(roomManager.getRoom(roomId)); + console.log(`開始遊戲: ${roomId}`); }); - // 出牌 + // 出牌(可一次出 1-3 張,傳入 cardIndices 陣列) socket.on('playCard', (data) => { - const { roomId, cardIndex } = data; + const { roomId, cardIndices } = data || {}; if (!validateRoom(socket, roomId)) return; - + const room = roomManager.getRoom(roomId); - if (!room || !room.engine) { - socket.emit('error', { message: '遊戲未開始' }); + socket.emit('error', { message: '遊戲尚未開始' }); return; } - const result = room.engine.playCard(socket.id, cardIndex); - + const result = room.engine.playCard(socket.id, cardIndices); if (result.error) { socket.emit('error', { message: result.error }); return; } - // 廣播出牌結果 + // 資訊邊界:出牌時只公告「誰出了幾張」,不洩漏實際牌值(質疑時才揭露) io.to(roomId).emit('cardPlayed', { playerId: socket.id, - cardIndex, + playedGroup: { playerId: socket.id, cardCount: result.playedGroup.cards.length }, nextPlayerId: result.nextPlayerId, + systemChallenge: result.systemChallenge || null, gameState: room.engine.getGameState() }); + broadcastHands(room); }); - // 質疑 + // 質疑上一組出的牌 socket.on('challenge', (data) => { - const { roomId } = data; + const { roomId } = data || {}; if (!validateRoom(socket, roomId)) return; - + const room = roomManager.getRoom(roomId); - if (!room || !room.engine) { - socket.emit('error', { message: '遊戲未開始' }); + socket.emit('error', { message: '遊戲尚未開始' }); return; } const result = room.engine.challenge(socket.id); - if (result.error) { socket.emit('error', { message: result.error }); return; } - // 廣播質疑結果 io.to(roomId).emit('challenged', { challengerId: socket.id, result, gameState: room.engine.getGameState() }); + broadcastHands(room); }); - // 斷線處理 + // 斷線 socket.on('disconnect', () => { console.log(`玩家斷線: ${socket.id}`); - - // 清理房間 const removed = roomManager.removePlayer(socket.id); if (removed) { io.to(removed.roomId).emit('playerDisconnected', { playerId: socket.id, - room: removed.room + room: removed.room, + gameState: removed.room.engine ? removed.room.engine.getGameState() : null }); + broadcastHands(removed.room); } }); }); @@ -149,15 +149,28 @@ app.get('/api/rooms', (req, res) => { }); app.post('/api/rooms', (req, res) => { - const { hostId } = req.body; - const room = roomManager.createRoom(hostId); + const { hostId } = req.body || {}; + const room = roomManager.createRoom(hostId || 'unknown'); res.json(room); }); -// 啟動伺服器 +// 優雅關閉:收到中斷訊號時先關閉 Socket.IO 再退出 +function shutdown(signal) { + console.log(`收到 ${signal},準備關閉伺服器...`); + io.close(() => { + console.log('伺服器已關閉'); + process.exit(0); + }); + // 保險:2 秒內沒關乾淨就強制退出 + setTimeout(() => process.exit(0), 2000).unref(); +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`伺服器啟動在 http://localhost:${PORT}`); }); -module.exports = { app, io }; +module.exports = { app, io }; \ No newline at end of file