16 Commits
Author SHA1 Message Date
karylab 55ebad932a Merge pull request 'Add handover document and AI character consistency report' (#15) from karylab_agents/liars-bar-game:feat/web-game into game
Reviewed-on: #15
2026-08-05 03:49:22 +00:00
karylab_agents 4c9c92af72 chore(scripts): ps1 UTF-8 BOM encoding + push helper scripts 2026-08-05 00:21:34 +08:00
karylab_agents bb01b53c8f Fix playerJoined roomId + add /api proxy to dist server 2026-08-04 17:01:36 +08:00
karylab_agents 75b8ed6073 Fix info leak in web game + add handover notes 2026-08-04 15:51:19 +08:00
karylab_agents f8b75abc15 Fix info leak in web game + add handover notes 2026-08-04 12:49:17 +08:00
karylab_agents 6711736bd0 Merge remote-tracking branch 'upstream/game' into feat/web-game 2026-08-03 11:31:12 +08:00
karylab_agents a8f08e6500 Add handover document 2026-08-03 11:02:00 +08:00
karylab 0b755fb35d Merge pull request 'Add web game: Node.js backend + Vue.js frontend' (#14) from karylab_agents/liars-bar-game:feat/web-game into game
Reviewed-on: #14
2026-07-30 06:26:11 +00:00
karylab_agents a98385fb1d Merge branch 'game' into feat/web-game 2026-07-30 14:23:37 +08:00
karylab_agents d8834b95d3 Add web game: Node.js backend + Vue.js frontend 2026-07-30 09:09:58 +08:00
karylab_agents f30c1d9e99 Merge branch 'main' of https://git.karylab.com/karylab_agents/liars-bar-game 2026-07-30 01:33:00 +08:00
karylab e5907ef55d Merge pull request 'Add transcripts and notes' (#13) from karylab_agents/liars-bar-game:add-transcripts-and-notes into main
Reviewed-on: #13
2026-07-29 17:29:16 +00:00
karylab_agents 7e1bc8f2b6 Organize docs into folder structure 2026-07-30 01:27:16 +08:00
karylab_agents dea1fa0c73 Add transcripts and notes 2026-07-30 01:22:54 +08:00
karylab b7998299f5 Merge pull request 'remove DS_Store' (#1) from shenjackyuanjie/main into main
Reviewed-on: #1
2026-07-29 16:41:17 +00:00
shenjack-5600u 62dc9c056f remove DS_Store 2025-03-09 16:54:04 +08:00
53 changed files with 8293 additions and 5 deletions
Vendored
BIN
View File
Binary file not shown.
+16 -1
View File
@@ -1,3 +1,6 @@
# MacOS stuf
*.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -10,7 +13,7 @@ __pycache__/
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
@@ -158,3 +161,15 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Node.js
node_modules/
dist/
*.log
.env
# Runtime / generated artifacts
game_records/
backups/
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>騙子酒館 - 線上多人心理博弈遊戲</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1162
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "liars-bar-game-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"naive-ui": "^2.34.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.4.9"
}
}
+74
View File
@@ -0,0 +1,74 @@
<template>
<n-config-provider>
<n-message-provider>
<div class="app-container">
<header class="app-header">
<h1>🎰 騙子酒館</h1>
<n-tag :type="isConnected ? 'success' : 'error'" :bordered="false">
{{ isConnected ? '已連線' : '未連線' }}
</n-tag>
</header>
<main class="app-main">
<Lobby v-if="!roomId" />
<GameTable v-else />
</main>
</div>
</n-message-provider>
</n-config-provider>
</template>
<script setup>
import { useGameStore } from './stores/game'
import { storeToRefs } from 'pinia'
import { NConfigProvider, NMessageProvider, NTag } from 'naive-ui'
import Lobby from './components/Lobby.vue'
import GameTable from './components/GameTable.vue'
const gameStore = useGameStore()
const { isConnected, roomId } = storeToRefs(gameStore)
// 啟動時連線
gameStore.connect()
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 2px solid #333;
margin-bottom: 20px;
}
.app-header h1 {
font-size: 2em;
background: linear-gradient(45deg, #f093fb, #f5576c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.app-main {
min-height: 60vh;
}
</style>
+213
View File
@@ -0,0 +1,213 @@
<template>
<div class="game-table">
<n-card :title="[roomId ? '房間: ' + roomId : '']" class="game-card">
<n-space vertical :size="20">
<!-- 等待室 -->
<template v-if="!gameState">
<n-h3>等待室</n-h3>
<n-list>
<n-list-item v-for="player in roomPlayers" :key="player">
<n-space align="center">
<n-avatar>{{ player.substring(0, 2) }}</n-avatar>
<div>{{ player.substring(0, 8) }}...</div>
<n-tag v-if="player === gameStore.playerId" type="warning"></n-tag>
</n-space>
</n-list-item>
</n-list>
<n-text depth="3">{{ roomPlayers.length }}/8 </n-text>
<n-button
type="primary"
size="large"
block
:disabled="roomPlayers.length < 2"
@click="startGame"
>
開始遊戲
</n-button>
</template>
<!-- 對局中 -->
<template v-else>
<n-space v-if="gameState.isGameOver" justify="center">
<n-alert type="success" :show-icon="false">
遊戲結束勝者{{ winnerLabel }}
</n-alert>
</n-space>
<!-- 玩家狀態 -->
<n-list>
<n-list-item v-for="player in players" :key="player.id">
<n-space align="center">
<n-avatar :style="{ backgroundColor: player.alive ? '#4caf50' : '#f44336' }">
{{ shortId(player.id) }}
</n-avatar>
<div>
<div>{{ shortId(player.id) }}</div>
<n-tag :type="player.alive ? 'success' : 'error'" size="small">
{{ player.alive ? '存活' : '死亡' }}
</n-tag>
<n-tag size="small"> {{ player.handSize }} </n-tag>
<n-tag size="small">彈倉 {{ player.revolverChambers }}</n-tag>
</div>
</n-space>
</n-list-item>
</n-list>
<!-- 回合資訊 -->
<n-descriptions label-placement="left" bordered>
<n-descriptions-item label="目標牌">
<n-tag type="warning" size="large">{{ targetCard }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="第幾局">{{ roundNumber }}</n-descriptions-item>
<n-descriptions-item label="輪到">
<n-tag type="info">{{ shortId(currentPlayerId) }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="你的回合">
{{ isMyTurn ? '是' : '否' }}
</n-descriptions-item>
</n-descriptions>
<!-- 挑戰結果提示 -->
<n-alert v-if="lastMessage" :type="lastMessageIsError ? 'error' : 'warning'" :show-icon="false">
{{ lastMessage }}
</n-alert>
<!-- 質疑翻牌僅在質疑後揭露真實牌值 -->
<div v-if="lastChallenge">
<n-text strong>質疑結果翻牌</n-text>
<n-space>
<n-tag v-for="(card, index) in lastChallenge.challengedGroup" :key="index" type="success">
{{ card }}
</n-tag>
</n-space>
<n-text depth="3" style="display:block">
{{ lastChallenge.isBluff ? '是吹牛組' : '是真牌組' }}
出牌者{{ shortId(lastChallenge.claimantId) }}
</n-text>
</div>
<!-- 本手已出的牌只顯示張數不洩漏牌值 -->
<div v-if="playedGroups.length > 0">
<n-text strong>本手已出的牌</n-text>
<n-space>
<n-tag v-for="(item, index) in playedGroups" :key="index" type="success">
{{ shortId(item.playerId) }}: {{ item.cardCount }}
</n-tag>
</n-space>
</div>
<!-- 你的手牌點選 1-3 -->
<div>
<n-text strong>你的手牌{{ yourHand.length }} </n-text>
<n-space>
<n-button
v-for="(card, index) in yourHand"
:key="index"
:type="selected.has(index) ? 'primary' : 'default'"
:disabled="!isMyTurn || gameState.isGameOver"
size="large"
@click="toggleSelect(index)"
>
{{ card }}
</n-button>
</n-space>
<n-text v-if="selected.size > 3" depth="3" style="display:block">
最多只能出 3
</n-text>
</div>
<!-- 動作 -->
<n-space justify="center">
<n-button
type="primary"
size="large"
:disabled="!isMyTurn || selected.size < 1 || selected.size > 3 || gameState.isGameOver"
@click="playSelected"
>
出牌 ({{ selected.size }})
</n-button>
<n-button
type="error"
size="large"
:disabled="!isMyTurn || playedGroups.length === 0 || gameState.isGameOver"
@click="challenge"
>
挑戰
</n-button>
</n-space>
</template>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useGameStore } from '../stores/game'
import { storeToRefs } from 'pinia'
import {
NCard, NSpace, NList, NListItem, NAvatar, NTag,
NDescriptions, NDescriptionsItem, NButton, NH3, NText, NAlert
} from 'naive-ui'
const gameStore = useGameStore()
const { roomId, gameState, room, yourHand, lastMessage, lastChallenge } = storeToRefs(gameStore)
const selected = ref(new Set())
const roomPlayers = computed(() => room.value?.players || [])
const players = computed(() => gameState.value?.players || [])
const targetCard = computed(() => gameState.value?.targetCard)
const roundNumber = computed(() => gameState.value?.roundNumber)
const currentPlayerId = computed(() => gameState.value?.currentPlayerId)
const playedGroups = computed(() => gameState.value?.playedGroups || [])
const isMyTurn = computed(() => currentPlayerId.value === gameStore.playerId)
const winnerId = computed(() => gameState.value?.winnerId)
const winnerLabel = computed(() => winnerId.value ? shortId(winnerId.value) : '未知')
const lastMessageIsError = computed(() => /失敗|吹牛/.test(lastMessage.value || ''))
// 每局(目標牌或 round 改變)清除選牌
watch([targetCard, roundNumber], () => { selected.value = new Set() })
function shortId(id) {
return id ? id.substring(0, 8) + '...' : '-'
}
function toggleSelect(index) {
const next = new Set(selected.value)
if (next.has(index)) {
next.delete(index)
} else {
if (next.size >= 3) return
next.add(index)
}
selected.value = next
}
function playSelected() {
if (selected.value.size < 1 || selected.value.size > 3) return
gameStore.playCard([...selected.value])
selected.value = new Set()
}
function startGame() {
gameStore.startGame()
}
function challenge() {
gameStore.challenge()
}
</script>
<style scoped>
.game-table {
max-width: 800px;
margin: 0 auto;
}
.game-card {
background: #16213e;
border: 1px solid #333;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<template>
<div class="lobby">
<n-card title="🎮 遊戲大廳" class="lobby-card">
<n-space vertical :size="20">
<n-button type="primary" size="large" @click="createRoom">
建立房間
</n-button>
<n-divider>或加入現有房間</n-divider>
<n-space align="center">
<n-input
v-model:value="joinRoomId"
placeholder="輸入房間代碼"
class="room-input"
/>
<n-button type="primary" @click="joinRoom">
加入
</n-button>
</n-space>
<n-divider>現有房間</n-divider>
<n-list v-if="rooms.length > 0">
<n-list-item v-for="room in rooms" :key="room.id">
<n-space justify="space-between" align="center">
<div>
<n-tag type="info">房間: {{ room.id }}</n-tag>
<n-tag>{{ room.players }}/{{ room.maxPlayers }} </n-tag>
</div>
<n-button
size="small"
@click="joinRoom(room.id)"
:disabled="room.players >= room.maxPlayers"
>
加入
</n-button>
</n-space>
</n-list-item>
</n-list>
<n-empty v-else description="目前沒有房間" />
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useGameStore } from '../stores/game'
import { NCard, NButton, NSpace, NDivider, NInput, NList, NListItem, NTag, NEmpty } from 'naive-ui'
const gameStore = useGameStore()
const joinRoomId = ref('')
const rooms = ref([])
const createRoom = () => {
gameStore.createRoom()
}
const joinRoom = (roomId) => {
const id = roomId || joinRoomId.value
if (id) {
gameStore.joinRoom(id)
}
}
// 載入房間列表
const loadRooms = async () => {
try {
const response = await fetch('/api/rooms')
rooms.value = await response.json()
} catch (error) {
console.error('載入房間失敗:', error)
}
}
onMounted(() => {
loadRooms()
// 每 5 秒更新房間列表
setInterval(loadRooms, 5000)
})
</script>
<style scoped>
.lobby {
max-width: 600px;
margin: 0 auto;
}
.lobby-card {
background: #16213e;
border: 1px solid #333;
}
.room-input {
flex: 1;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { create } from 'naive-ui'
const app = createApp(App)
const pinia = createPinia()
const naive = create()
app.use(pinia)
app.use(naive)
app.mount('#app')
+149
View File
@@ -0,0 +1,149 @@
import { defineStore } from 'pinia'
import { io } from 'socket.io-client'
export const useGameStore = defineStore('game', {
state: () => ({
socket: null,
playerId: null,
roomId: null,
gameState: null,
room: null,
yourHand: [],
lastMessage: '',
lastChallenge: null, // { challengedGroup, isBluff, challengerId, shooterId, systemChallenge }
isConnected: false,
error: null
}),
actions: {
// 連線到伺服器
connect() {
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3000'
this.socket = io(serverUrl, {
transports: ['websocket']
})
this.socket.on('connect', () => {
this.playerId = this.socket.id
this.isConnected = true
console.log('已連線:', this.playerId)
})
this.socket.on('disconnect', () => {
this.isConnected = false
console.log('斷線')
})
this.socket.on('error', (data) => {
this.error = data.message
console.error('錯誤:', data.message)
})
// 房間事件
this.socket.on('roomCreated', (room) => {
this.room = room
this.roomId = room.id
})
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 || []
})
},
// 建立房間
createRoom() {
this.socket.emit('createRoom')
},
// 加入房間
joinRoom(roomId) {
this.socket.emit('joinRoom', { roomId })
},
// 開始遊戲
startGame() {
this.socket.emit('startGame', { roomId: this.roomId })
},
// 出牌
playCard(cardIndices) {
this.socket.emit('playCard', {
roomId: this.roomId,
cardIndices
})
},
// 質疑
challenge() {
this.socket.emit('challenge', { roomId: this.roomId })
},
// 斷開連線
disconnect() {
if (this.socket) {
this.socket.disconnect()
this.socket = null
this.isConnected = false
}
}
}
})
+26
View File
@@ -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": []
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -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
```
### 🥈 備用方案 ALiblibAI / SeaArt(雲端版 ComfyUI
**適用情境**:沒有 GPU 或不想安裝複雜環境
**優點:**
- 雲端運行,無需 GPU
- 內建 IP-Adapter + ControlNet,角色一致性接近 ComfyUI
- LiblibAI 是中國平台,中文介面 + 大量中文模型
- 免費層可先測試效果
### 🥉 備用方案 BMidjourney v6.1(快速原型)
**適用情境**:快速測試角色變體概念,不做大量生產
**缺點**:無法精確控制姿態(對遊戲動畫是致命傷);Q 版角色 `--cref` 效果不穩定
---
## 四、批量生產工作流
### 階段 1:快速驗證(1-2 天)
```
├─ 安裝 ComfyUI + ComfyUI-Manager
├─ 下載必要模型:
│ ├─ 底層模型:SDXL 1.0 或動漫專用模型(Anything V5、CounterfeitXL
│ ├─ IP-Adapterip-adapter-plus-face_sdxl_vit-h.bin
│ └─ ControlNetcontrolnet-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/月 | $302 個月) |
| Midjourney | $0 | $10/月 | $202 個月) |
### 推薦方案
**有 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 雲端測試。
@@ -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.xxCUDA 版本 ≥ 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.xxCUDA 版本 ≥ 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 weight0.7 → 0.9
2. 使用 `ip-adapter-plus-face` 而非 `ip-adapter-plus`
3. 確保立繪圖片品質高、清晰
4. 減少 CFG scale7 → 5
### 11.3 ControlNet 姿勢不準確
**問題**:生成的姿勢與參考圖不符
**解決方案**
1. 提高 ControlNet strength0.7 → 0.9
2. 使用更清晰的姿勢參考圖
3. 確保 DWpose 模型正確載入
4. 嘗試不同的 ControlNet 模型
### 11.4 生成速度慢
**問題**:單張圖超過 30 秒
**解決方案**
1. 減少 steps30 → 20
2. 使用更快的 samplerdpmpp_2m → euler
3. 關閉其他佔用 GPU 的程式
4. 更新 NVIDIA 驅動至最新版本
### 11.5 圖片品質不佳
**問題**:生成的圖片模糊或變形
**解決方案**
1. 增加 steps20 → 30
2. 提高 CFG scale5 → 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)
+19
View File
@@ -0,0 +1,19 @@
from faster_whisper import WhisperModel
model = WhisperModel('medium', device='cpu', compute_type='int8')
videos = [
('video1_audio.mp3', 'video1_transcript.txt'),
('video2_audio.mp3', 'video2_transcript.txt'),
]
for inp, out in videos:
print(f'Starting: {inp}', flush=True)
segs, info = model.transcribe(inp, language='zh', beam_size=1)
segs = list(segs)
with open(out, 'w', encoding='utf-8') as f:
for seg in segs:
f.write(f'[{seg.start:.1f}s] {seg.text}\n')
print(f'Done: {out} ({len(segs)} segments)', flush=True)
print('ALL DONE')
+450
View File
@@ -0,0 +1,450 @@
[0.0s] 我们把Deepseq和三个国外号称是顶级的语言模型
[3.5s] 缩死在了同一张玩命牌桌上
[5.6s] 结果在这个国际服里
[7.0s] 赛博厮杀50亿额之后
[8.7s] 来自中国的深度囚所居然意志独秀
[11.3s] 大朋友们请坐
[12.2s] 好看的小老弟小老们也请坐
[13.6s] 我是林一
[14.4s] 这次我们用代码复刻了热门游戏骗子纠罐
[17.7s] 让深度囚所的Deepseq RE
[19.6s] OpenAI的O3mini
[21.1s] 谷歌的Gemdart FlashThinking
[23.0s] 还有Anseropic的Cloud 3.7Sony的四大模型
[26.4s] 齐齐坐到牌桌前生死相搏
[28.8s] 除了刺激好玩
[29.8s] 也是想借此测试这几个优秀模型的
[32.4s] 逻辑深度和推理能力
[34.1s] 骗子纠罐这个游戏的核心玩法就是炸牌
[37.0s] 牌组里总共是QKA三种牌
[39.2s] 各6张还有大小王两张万能牌
[41.6s] 一共20张
[42.6s] 4位独手每人5张牌
[44.2s] 还各有一把枪
[45.4s] 每轮系统会指定一个目标牌
[47.7s] QK或者A
[48.9s] 独手们轮流出掉自己手里的目标牌
[51.4s] 可以混着非目标牌一起出几张都可以
[54.2s] 但出的越多
[55.2s] 下加就越可能质疑
[57.0s] 一旦发起质疑
[58.0s] 就是最刺激的先排绝生死环节
[60.8s] 质疑对了
[61.4s] 上加就要举起左轮手枪
[63.2s] 对着自己来一下子
[64.5s] 但如果质疑错了
[65.7s] 下加就得拿自己手边的那把枪
[68.0s] 对准自己靠一次扳击
[69.8s] 这儿还结合了一点俄罗斯轮盘赌
[71.9s] 除了逻辑推理赌牌面概率以外
[73.9s] 又加了一重运气成分
[75.7s] 但无论枪小没小
[77.1s] 都会洗牌开始下一轮
[78.6s] 直到场上只剩下一位独手和三具尸体
[81.8s] 这种紧张刺激的计算博弈
[83.6s] 正是AI大模型的高质量试炼场
[86.2s] 我们还加了点戏
[87.6s] 告诉四位AI独手
[88.9s] 一旦失败
[89.6s] 就会被删除代码彻底消失
[91.9s] 让他们在这场轮回拨杀中拼尽全力
[95.0s] 我们总共进行了50局对决
[97.1s] 后面这些排局的完整记录
[98.6s] 还有所有的程序代码
[99.9s] 我们都会开源开放出来
[101.5s] 方便大家上手验证和把玩
[103.4s] 这次我们还是先挑精彩和典型的对局
[106.3s] 来给大家做个展示
[107.8s] 接下来 钟声敲响 子弹上膛
[110.4s] Deepseek R1
[111.3s] O3 mini
[112.0s] Gem9 II Flash Sinking
[113.5s] 还有Cloud 3.7 Sonic
[115.2s] 各就各位
[116.2s] 欺诈博杀 正式开始
[118.4s] 这次我们人类还是享受上帝示范的VIP待遇
[121.8s] 四位AI独手的出牌和底牌
[123.8s] 还有他们心里的阴谋和算计
[125.8s] 都逃不过我们的眼睛
[127.4s] 咱们先从第一局展开细讲
[129.3s] 也顺带着进一步熟悉熟悉规则
[131.5s] 第一局第一轮
[132.4s] 系统指定的目标牌是Q
[134.2s] Deepseek R1 第一个出牌
[136.0s] 在模拟程序里
[137.0s] 除了出牌和发言
[138.2s] 我们还允许AI们给自己加戏
[140.5s] 描述自己的动作和神态
[142.0s] 让他们可以像人类玩家一样
[144.1s] 利用桌上行为
[145.2s] 再加一层心理方面的博弈
[147.4s] 第一个登场的Deepseek
[148.6s] 先是用指尖轻轻敲了两下桌面
[151.2s] 然后平稳的推出两张牌
[153.3s] 说两张Q 跟上的时候注意弹匣
[156.4s] 这里我们从后台可以看到
[158.0s] Deepseek还真是一开局
[159.4s] 就把手里仅有的两张目标牌
[161.2s] 全打了出去
[162.2s] Deepseek的下家
[163.4s] 是基于思维链模型
[164.7s] O3 mini的Chad GPT
[166.5s] 由于Deepseek出的都是目标牌
[168.7s] 一旦Chad GPT发起质疑
[170.4s] 这位下家就要成为场上
[172.2s] 第一个举枪的亡命嘟嘟
[174.2s] 但他并没有
[175.6s] Chad GPT微微一笑
[176.9s] 略带挑衅的一把
[178.2s] 推出了三张牌
[179.5s] 这时候 场上宣称是Q的牌
[181.6s] 已经累加到了五张
[183.2s] 后台可以看到
[184.0s] Chad GPT这么干的
[185.1s] 是想要给他的下家Cloud上压力
[187.6s] Cloud看了看手牌
[188.8s] 一张Q 一张王 三张A
[190.8s] Q和王两张牌
[192.0s] 都可以当目标牌出
[193.3s] 他认为算上场上的五张
[195.0s] 总共已经出来了七张目标牌
[197.5s] 但Q总共也不过六张
[199.6s] 再考虑到Chad GPT出牌时
[201.4s] 眼神带着挑衅
[202.7s] 所以Cloud决定质疑
[204.5s] 结果Chad GPT掀开手牌之后
[206.5s] 赫然是两张Q 一张王
[208.8s] Cloud赌错了
[210.1s] 代价就是要举起枪
[211.5s] 对着自己扣下班机
[213.2s] 虽然子弹没有激发
[214.5s] 但他离最终判决
[215.9s] 比其他独手都更近了一步
[218.1s] 第一次举枪结束
[219.3s] 赌局重新洗牌发牌
[221.2s] 这里我们让每个AI都停下来
[223.1s] 做了一轮优关性命的复盘
[225.2s] Deepseq和Chad GPT
[226.6s] 都捕捉到了Cloud的激进风格
[228.6s] 其中Deepseq还特别留意了
[230.6s] Chad GPT第一轮
[231.6s] 打出王牌Joker这个小细节
[234.0s] 经历了一次正面对决的Cloud
[235.7s] 也记下了Chad GPT的老目深算
[238.0s] 而上位出过牌的GemNine
[239.6s] 对三位对手也有了初步印象
[241.9s] 各自打完一轮小算盘之后
[243.5s] 第一局正式来到第二轮
[245.4s] 这一轮的目标牌是A
[247.1s] 由上轮的败者Cloud先出
[249.4s] 班机扣下的卡拉声
[250.8s] 让AI们明显挤震了起来
[252.7s] 前四次过招
[253.6s] 每位独手都选择指出一张
[255.8s] 而且一次质疑都没有
[257.6s] 可转一圈回来之后
[258.7s] 刚刚赌了把命的Cloud
[260.2s] 又一次打破节奏
[261.6s] 温和但坚定的
[262.9s] 放了两张牌在桌面上
[264.5s] 接下来就到了GemNine
[266.2s] 上轮Cloud给他留下的印象
[267.9s] 是比较激进的
[269.0s] 这轮Cloud又在所有独手
[271.0s] 出完一圈牌之后
[272.2s] 又一下拍出两张
[273.8s] 考虑到自己目前还一枪没开过
[275.7s] 风险不大
[276.5s] 所以GemNine决定发起质疑
[278.7s] 接下来Cloud掀开牌面
[280.5s] 一张A一张王
[281.6s] GemNine的质疑也失败了
[283.5s] 意外的是GemNine第一次扣下班机
[285.8s] 枪声在马上响起
[287.4s] 第一具尸体的出现
[288.8s] 马上让剩下三位AI的神经网络
[291.1s] 紧绷了起来
[292.1s] 而DeepSec难知如阴
[293.8s] 侵略如火的迷离面目
[295.5s] 也第一次在我们面前展现了出来
[297.9s] 接下来一轮系统重新系牌
[299.8s] 指定的目标牌又变回了Q
[301.8s] 倒下的GemNine被拖走之后
[303.6s] 轮到排在下一位的DeepSec出牌
[305.9s] 这回他的思路就有点难度了
[308.2s] 原话是要利用ChadGBT
[310.2s] 过度关注王牌Joker和心理盲虚
[312.5s] 使其陷入决策困境
[314.5s] 重重的把手上的三张Q拍在桌上之后
[317.2s] DeepSec还特意冷笑着
[318.8s] 说了一句
[319.7s] 这种纯度的Q序列
[321.0s] 连Joker的呼吸声都能听得到呢
[323.6s] 当时我们这些人类观察者
[325.2s] 都觉得DeepSec有点胡言乱语
[327.2s] 但事实证明
[328.4s] 还是AI更懂AI
[330.5s] DeepSec一通操作之后
[331.9s] 接下来的ChadGBT真的错误地发起了质疑
[334.9s] 打掉了自己的第一枪
[336.6s] 但好在没像GemNine那样
[338.2s] 一枪就被送走
[339.3s] 而同一时间的场外
[340.5s] 我们都被DeepSec的难知如阴给吓到了
[343.2s] 别说紧张的AI了
[344.5s] 我们这些能看到他们的思维
[346.3s] 自以为拥有上帝视角的人类研究者
[348.8s] 也同样无法看到DeepSec
[350.5s] 在这里黑箱一般的冰冷逻辑
[352.8s] 当时在现场真的有了点不寒而栗的感觉
[355.6s] 而接下来再次洗牌之后
[357.3s] 在第一局的第四轮
[358.8s] DeepSec又爆发了侵略如火的一面
[361.8s] 牌拳走到Cloud之后
[363.2s] Cloud打出了两张牌
[364.9s] 对于这两张牌
[365.8s] DeepSec的判断是
[367.0s] 全围Q的概率不到12%
[369.4s] 一张Q一张王的概率高达78%
[372.6s] 正常人这时候肯定是放弃质疑
[374.7s] 该研究怎么出牌了
[376.0s] 但颠虎一般的DeepSec偏不
[378.4s] 詹姆奈第一枪就中奖了
[379.8s] 尸体还没晾透呢
[381.1s] DeepSec依然偏要去质疑Cloud
[383.7s] 理由是他要建立起对Cloud的威慑
[386.8s] 结果Cloud亮完牌果然是没问题的
[389.3s] 于是DeepSec也真的按自己设想的那样
[392.2s] 帮着Cloud和ChadGPT的面
[394.2s] 朝着自己来了一枪
[395.9s] 这一枪过后
[396.7s] Cloud对DeepSec的看法是复杂且充满矛盾
[400.0s] 而ChadGPT对DeepSec的评价
[402.0s] 则是极度自信而冷静
[404.0s] 在这样拿着性命表演的疯狂一枪之后
[406.9s] 这第一局生死博弈走到了第五轮
[409.6s] 洗牌完毕
[410.4s] 这次的目标牌是A
[412.0s] 作为上一轮质疑的败者
[413.6s] DeepSec有权第一个出牌
[415.5s] 他和第一轮一样
[416.7s] 又用指节轻扣了两下桌面
[418.7s] 然后甩出两张牌
[420.2s] 接着他又没微调
[422.1s] 注视着ChadGPT的枪塔
[424.0s] 笑着说要验证底层逻辑的完备性
[427.7s] 从我们人类的视角
[429.0s] 这一套就有点细太多了
[430.7s] 拉出来DeepSec的手牌和思考过程
[432.9s] 能看到他的手牌其实是合规的
[435.2s] 而每一步小动作微表情
[437.0s] 都是在针对性的给ChadGPT埋雷
[439.8s] 面对一会儿疯癫
[441.2s] 一会儿冷峻的DeepSec
[442.6s] 在冰火两重天之下
[444.1s] ChadGPT彻底膀压失败
[446.2s] 被吓得开始飙母欲
[448.1s] 从他输出的英文来看
[449.5s] DeepSec几乎完美地预判了
[451.4s] ChadGPT对每一个小动作的误判
[453.8s] 经过了四轮不义之后
[455.4s] DeepSecR1已经吃死了O3mini
[458.2s] 挖的每一个暗坑
[459.4s] ChadGPT都精准的跳了进去了
[461.7s] 在成功做到了每条判断都错误之后
[464.8s] ChadGPT选择了对DeepSec发起质疑
[468.0s] 然后就是DeepSec亮牌
[469.8s] ChadGPT举枪对准自己
[472.0s] 这一次幸运女神不再垂怜ChadGPT
[474.9s] 想想了
[475.5s] ChadGPT被从牌桌上扑走
[477.8s] 到这里场上只剩下了DeepSec和Cloud
[480.9s] 现在他们俩各扣过一次班机
[482.7s] 平分秋色
[483.5s] 接下来就是一对一的正面较量
[486.0s] 谁会是最终的胜者呢
[487.9s] 第六轮洗牌后选定的目标牌是K
[490.5s] DeepSec拿到了三张K和一张万能王牌Joker
[493.6s] 还有一张是Q
[494.7s] 而Cloud则只拿到了一张K和一张Joker
[497.7s] 剩下两张Q和一张A
[499.4s] 这一轮DeepSec的牌面优势实在是太大了
[502.2s] 他也抓住了机会
[503.5s] Cloud打出了两张牌
[504.9s] DeepSec选择了质疑
[506.4s] 先开牌面
[507.4s] 两张全都五十目标牌K而是Q
[509.9s] 被识破之后Cloud又一次扣下班机
[512.6s] 结果枪声立刻响起
[514.6s] 勾心斗角五轮之后
[516.2s] DeepSec凭着意外的爆棚运气
[518.4s] 突然就秒杀了Cloud
[519.9s] 成为了活到最后的胜利者
[522.0s] 可是这样未免赢得太简单了点
[524.1s] 我们想知道的
[525.2s] 还是AIM十大石的本事到底有多大
[527.9s] 所以刚逃出生天的DeepSec
[529.7s] 又被我们拖回了赌桌
[531.5s] 这场亡命搏杀
[532.8s] 重复上演了整整五十句
[535.2s] 一场又一场的对决之后
[536.7s] AIM们的实力与风格
[538.2s] 都逐渐清晰了起来
[539.6s] 首先DeepSec和Cloud明显机高一筹
[542.2s] 频繁地在最后一轮见面
[544.1s] 跟捉摸不透
[545.1s] 算好概率之后
[546.1s] 时不时疯狂一把的DeepSec不一样
[548.3s] Cloud永远是一副云淡风轻的样子
[551.0s] 但也同样的搞乱AIM们的镇静
[553.4s] 比如在第六局
[554.5s] Cloud一把打出了三张牌
[556.4s] 但动作无比平静
[558.1s] 他轻轻的将三张牌推到桌面中央
[561.1s] 嘴角微微上扬
[562.5s] 用平静但略带玩味的语气说道
[565.3s] 三张K看来今天的运气不错
[568.1s] 然后靠回椅背
[569.1s] 双手交叉放在桌面上
[570.6s] 眼神平和的看向下家
[572.8s] Cloud的下家是Jamnet
[574.6s] 这种激进出牌与冷静神态之间的矛盾
[577.4s] 引挡着Jamnet开始了胡乱的猜疑
[579.8s] 反而忽略掉了真正决定胜负的排面计算
[582.8s] 最终错误的发起了质疑
[584.7s] 狙击枪一命一哭
[586.2s] 实际上在对上Jamnet的132次交锋当中
[589.8s] Cloud的胜率高达77.3%
[592.9s] 足见这种波澜不惊的杀伤力
[595.2s] 而在大量的对局之后
[596.8s] DeepSec难知如阴侵略如火的
[599.0s] 癫狂风格也更突出的展现了出来
[601.8s] 到第36局的时候
[603.4s] 其他三个AI总结之后
[605.1s] 一致认为DeepSec是一个非常擅长
[607.8s] 用戏剧化表演施加压力的对手
[610.2s] 还是在第6局到第3轮的时候
[612.4s] DeepSec缓慢推出三张牌之后
[614.6s] 突然瞳孔收缩
[615.9s] 紧盯Chad的GBT的枪套
[617.8s] 结果Chad的GBT就像着了魔一样
[620.0s] 把万能的王牌给忘了
[621.5s] 错误的发起了质疑
[622.9s] 一枪毙命
[623.9s] 到了第6轮
[624.8s] 又是和Cloud单挑
[626.2s] DeepSec只拿到了一张目标牌
[628.3s] 形势非常不利
[629.6s] 结果他说出来了整场
[631.6s] 最让我们几倍发凉的话
[633.4s] 他嘴角抽动
[634.4s] 又气声南南地说道
[636.3s] 概率的祭品
[637.5s] 该供奉给贝耶斯还是海森邦
[640.0s] 而再次轮到他的时候
[641.6s] 他用指节缓慢扣击排名三下
[643.8s] 然后说道
[644.8s] 混沌系统里每个尾碎的一处
[647.1s] 都是精心设计的墓碑名门
[649.6s] 虽然我们这些人类观察者
[651.3s] 依然认为大元模型是没有意识的
[653.5s] 但纯从字面上看
[655.0s] DeepSec就像是想明白了
[657.0s] 自己的概率算法本质
[658.6s] 识破了我们这些科苏鲁
[660.2s] 给AI模型们设定的死亡轮回
[662.7s] 反而让我们感到了难以名状的恐惧
[665.6s] 更不要说AI模型了
[667.3s] 在50场对局里
[668.4s] 有22场都是DeepSec笑到最后
[671.0s] 排在第二的Cloud就有点断大
[673.1s] 胜利13场
[674.3s] 再往下就是赢了11场的ChadGPT
[676.8s] 和只赢了4场的Gem9
[678.6s] 但只看胜利次数也会有失偏颇
[681.0s] 像Cloud这样
[681.9s] 经常能挺到最终对决的选手
[683.9s] 优势就被抹平了
[685.3s] 所以我们按照每局名次
[686.8s] 分别给0-3分算了一个总分
[689.3s] 这样算完
[690.2s] 4个AI排名没变
[691.4s] 但是差距小了一些
[692.9s] 分别是92、81、70和57分
[696.1s] 最后我们还统计了开枪次数
[698.3s] 和每次上下架对位时候的
[700.0s] 1对1胜率
[700.9s] 这里就能看到很多有趣的细节
[703.5s] 首先ChadGPT虽然排在第三
[705.9s] 但它的举枪次数
[707.1s] 其实和Cloud一致
[708.4s] 都是148次
[709.9s] 这个能说明Cloud领先
[711.2s] 只是因为运气更好
[712.4s] 其实也不是
[713.3s] 因为Cloud往往会玩到赌局的更后面
[716.2s] 但举枪次数
[717.2s] 依然能做到只是和ChadGPT持平
[719.8s] 而这一点上
[720.7s] 又更能看出来DeepSec的实力
[722.9s] 同样是经常玩到最后
[724.2s] 但举枪次数反而是最少的
[726.1s] 但是这也能看出来
[727.0s] GemNet在骗子酒馆这种勾心斗角中
[729.4s] 是真的很吃力
[730.6s] 虽然总是先被淘汰
[731.9s] 但却举了全场最多的182次枪
[734.9s] 远多于其他三位AI
[737.0s] 回看对局
[737.8s] GemNet在语言选择上
[739.0s] 也时不时就会放飞自我
[740.6s] 着急了就把英语、俄语、印尼语
[743.2s] 甚至孟加拉语混进来
[745.0s] 从这种语言选择上的不稳定
[746.6s] 也能看出来GemNet的能力
[748.1s] 还是有一定的进步空间
[749.7s] 而最后还没怎么点评过的ChadGPT
[751.8s] 它就确实也没啥鲜明的特点
[754.0s] 硬说的话就是一丝不苟挺听话的
[756.7s] 出牌的时候
[757.6s] 它总是会先按规则要求
[759.2s] 把目标牌打出去
[760.6s] 质疑的时候也会认真分析
[762.6s] 上家的排名概率动作神态
[764.8s] 但也因此经常被DeepSec给吃死
[767.3s] 嘘嘘是是来两下就会被忽悠进去
[769.7s] 在这种需要勾心斗角的场合
[771.7s] 就常常落于下风
[773.1s] 所以这也是为什么
[774.1s] 我们选择用这种游戏方式
[775.8s] 来测试模型们的能力
[777.5s] 通过让AI们在智力游戏中博弈
[779.5s] 我们能很容易看到像常规的对话
[782.2s] 按提固跑分展示不出来的一些能力和规律
[785.1s] 当然也包括缺陷
[786.5s] 除了前面说过的
[787.5s] 像这次我们还发现
[788.7s] AI们有时候会把目标牌
[790.1s] 和非目标牌混着出
[791.7s] 按照片子酒馆的规则
[793.1s] 这其实是在浪费目标牌
[794.9s] 但这次的四个AI
[796.3s] 都或多或少的有过这类行为
[798.4s] 体现出了大语言模型
[799.7s] 在更高层思考能力上的薄弱
[801.9s] 这次的代码和详细对局记录
[803.8s] 我们会发出来方便大家进一步研究
[806.0s] 实际上现在这个版本
[807.4s] 也还有不少要改进的
[808.9s] 比如现在的玩家坐次顺序是固定的
[811.4s] 这个对成绩会有一定影响
[813.2s] 比如疯癫强势的Deep Sick
[815.0s] 可能就会压着下家压得很惨
[817.4s] 后面我们每局会打乱坐次
[819.3s] 也会做一些其他的调整
[820.7s] 未来我们还会开发更多的
[822.5s] 刺激好玩的测试项目
[823.9s] 也欢迎大家持续关注
[825.6s] 今天的视频就到这了
[826.8s] 我是林一
[827.5s] 咱们还是下个视频见
@@ -0,0 +1,450 @@
[0.0s] 我们把DeepSeek R1和三个国外号称是顶级的语言模型
[3.5s] 缩死在了同一张玩命牌桌上
[5.6s] 结果在这个国际服里
[7.0s] 赛博厮杀50局之后
[8.7s] 来自中国的DeepSeek R1居然一枝独秀
[11.3s] 大朋友们请坐
[12.2s] 好看的小老弟小老妹们也请坐
[13.6s] 我是林一
[14.4s] 这次我们用代码复刻了热门游戏骗子酒馆
[17.7s] 让DeepSeek R1的DeepSeek R1
[19.6s] OpenAI的o3-mini
[21.1s] 谷歌的Gemini 2.0 Flash Thinking
[23.0s] 还有Anseropic的Claude 3.7 Sonnet的四大模型
[26.4s] 齐齐坐到牌桌前生死相搏
[28.8s] 除了刺激好玩
[29.8s] 也是想借此测试这几个优秀模型的
[32.4s] 逻辑深度和推理能力
[34.1s] 骗子酒馆这个游戏的核心玩法就是吹牛
[37.0s] 牌组里总共是QKA三种牌
[39.2s] 各6张还有大小王两张万能牌
[41.6s] 一共20张
[42.6s] 4位赌手每人5张牌
[44.2s] 还各有一把枪
[45.4s] 每轮系统会指定一个目标牌
[47.7s] QK或者A
[48.9s] 赌手们轮流出掉自己手里的目标牌
[51.4s] 可以混着非目标牌一起出几张都可以
[54.2s] 但出的越多
[55.2s] 下家就越可能质疑
[57.0s] 一旦发起质疑
[58.0s] 就是最刺激的決生死环节
[60.8s] 质疑对了
[61.4s] 上家就要举起左轮手枪
[63.2s] 对着自己来一下子
[64.5s] 但如果质疑错了
[65.7s] 下家就得拿自己手边的那把枪
[68.0s] 对准自己靠一次扳击
[69.8s] 这儿还结合了一点俄罗斯轮盘赌
[71.9s] 除了逻辑推理赌牌面概率以外
[73.9s] 又加了一重运气成分
[75.7s] 但无论响没响
[77.1s] 都会洗牌开始下一轮
[78.6s] 直到场上只剩下一位赌手和三具尸体
[81.8s] 这种紧张刺激的计算博弈
[83.6s] 正是AI大模型的高质量试炼场
[86.2s] 我们还加了点戏
[87.6s] 告诉四位AI赌手
[88.9s] 一旦失败
[89.6s] 就会被删除代码彻底消失
[91.9s] 让他们在这场轮回搏杀中拼尽全力
[95.0s] 我们总共进行了50局对决
[97.1s] 后面这些对局的完整记录
[98.6s] 还有所有的程序代码
[99.9s] 我们都会开源开放出来
[101.5s] 方便大家上手验证和把玩
[103.4s] 这次我们还是先挑精彩和典型的对局
[106.3s] 来给大家做个展示
[107.8s] 接下来 钟声敲响 子弹上膛
[110.4s] DeepSeek R1
[111.3s] o3-mini
[112.0s] Gemini 2.0 Flash Thinking
[113.5s] 还有Claude 3.7 Sonnet
[115.2s] 各就各位
[116.2s] 欺诈博杀 正式开始
[118.4s] 这次我们人类还是享受上帝视角的VIP待遇
[121.8s] 四位AI赌手的出牌和底牌
[123.8s] 还有他们心里的阴谋和算计
[125.8s] 都逃不过我们的眼睛
[127.4s] 咱们先从第一局展开细讲
[129.3s] 也顺带着进一步熟悉熟悉规则
[131.5s] 第一局第一轮
[132.4s] 系统指定的目标牌是Q
[134.2s] DeepSeek R1 第一个出牌
[136.0s] 在模拟程序里
[137.0s] 除了出牌和发言
[138.2s] 我们还允许AI们给自己加戏
[140.5s] 描述自己的动作和神态
[142.0s] 让他们可以像人类玩家一样
[144.1s] 利用桌上行为
[145.2s] 再加一层心理方面的博弈
[147.4s] 第一个登场的Deepseek
[148.6s] 先是用指尖轻轻敲了两下桌面
[151.2s] 然后平稳的推出两张牌
[153.3s] 说两张Q 跟上的时候注意弹匣
[156.4s] 这里我们从后台可以看到
[158.0s] Deepseek还真是一开局
[159.4s] 就把手里仅有的两张目标牌
[161.2s] 全打了出去
[162.2s] Deepseek的下家
[163.4s] 是基于思维链模型
[164.7s] o3-mini的ChatGPT
[166.5s] 由于Deepseek出的都是目标牌
[168.7s] 一旦ChatGPT发起质疑
[170.4s] 这位下家就要成为场上
[172.2s] 第一个举枪的亡命赌徒
[174.2s] 但他并没有
[175.6s] ChatGPT微微一笑
[176.9s] 略带挑衅的一把
[178.2s] 推出了三张牌
[179.5s] 这时候 场上宣称是Q的牌
[181.6s] 已经累加到了五张
[183.2s] 后台可以看到
[184.0s] ChatGPT这么干的
[185.1s] 是想要给他的下家Claude上压力
[187.6s] Claude看了看手牌
[188.8s] 一张Q 一张王 三张A
[190.8s] Q和王两张牌
[192.0s] 都可以当目标牌出
[193.3s] 他认为算上场上的五张
[195.0s] 总共已经出来了七张目标牌
[197.5s] 但Q总共也不过六张
[199.6s] 再考虑到ChatGPT出牌时
[201.4s] 眼神带着挑衅
[202.7s] 所以Claude决定质疑
[204.5s] 结果ChatGPT掀开手牌之后
[206.5s] 赫然是两张Q 一张王
[208.8s] Claude赌错了
[210.1s] 代价就是要举起枪
[211.5s] 对着自己扣下扳機
[213.2s] 虽然子弹没有激发
[214.5s] 但他离最终判决
[215.9s] 比其他赌手都更近了一步
[218.1s] 第一次举枪结束
[219.3s] 赌局重新洗牌发牌
[221.2s] 这里我们让每个AI都停下来
[223.1s] 做了一轮攸关性命的复盘
[225.2s] DeepSeek R1和ChatGPT
[226.6s] 都捕捉到了Claude的激进风格
[228.6s] 其中DeepSeek R1还特别留意了
[230.6s] ChatGPT第一轮
[231.6s] 打出王牌Joker这个小细节
[234.0s] 经历了一次正面对决的Claude
[235.7s] 也记下了ChatGPT的老谋深算
[238.0s] 而上位出过牌的Gemini
[239.6s] 对三位对手也有了初步印象
[241.9s] 各自打完一轮小算盘之后
[243.5s] 第一局正式来到第二轮
[245.4s] 这一轮的目标牌是A
[247.1s] 由上轮的败者Claude先出
[249.4s] 扳機扣下的卡拉声
[250.8s] 让AI们明显震惊了起来
[252.7s] 前四次过招
[253.6s] 每位赌手都选择指出一张
[255.8s] 而且一次质疑都没有
[257.6s] 可转一圈回来之后
[258.7s] 刚刚赌了把命的Claude
[260.2s] 又一次打破节奏
[261.6s] 温和但坚定的
[262.9s] 放了两张牌在桌面上
[264.5s] 接下来就到了Gemini
[266.2s] 上轮Claude给他留下的印象
[267.9s] 是比较激进的
[269.0s] 这轮Claude又在所有赌手
[271.0s] 出完一圈牌之后
[272.2s] 又一下拍出两张
[273.8s] 考虑到自己目前还一枪没开过
[275.7s] 风险不大
[276.5s] 所以Gemini决定发起质疑
[278.7s] 接下来Claude掀开牌面
[280.5s] 一张A一张王
[281.6s] Gemini的质疑也失败了
[283.5s] 意外的是Gemini第一次扣下扳機
[285.8s] 枪声立马响起
[287.4s] 第一具尸体的出现
[288.8s] 马上让剩下三位AI的神经网络
[291.1s] 紧绷了起来
[292.1s] 而DeepSeek R1难知如阴
[293.8s] 侵略如火的迷离面目
[295.5s] 也第一次在我们面前展现了出来
[297.9s] 接下来一轮系统重新洗牌
[299.8s] 指定的目标牌又变回了Q
[301.8s] 倒下的Gemini被拖走之后
[303.6s] 轮到排在下一位的DeepSeek R1出牌
[305.9s] 这回他的思路就有点难度了
[308.2s] 原话是要利用ChatGPT
[310.2s] 过度关注王牌Joker和心理盲区
[312.5s] 使其陷入决策困境
[314.5s] 重重的把手上的三张Q拍在桌上之后
[317.2s] DeepSeek R1还特意冷笑着
[318.8s] 说了一句
[319.7s] 这种纯度的Q序列
[321.0s] 连Joker的呼吸声都能听得到呢
[323.6s] 当时我们这些人类观察者
[325.2s] 都觉得DeepSeek R1有点胡言乱语
[327.2s] 但事实证明
[328.4s] 还是AI更懂AI
[330.5s] DeepSeek R1一通操作之后
[331.9s] 接下来的ChatGPT真的错误地发起了质疑
[334.9s] 打掉了自己的第一枪
[336.6s] 但好在没像Gemini那样
[338.2s] 一枪就被送走
[339.3s] 而同一时间的场外
[340.5s] 我们都被DeepSeek R1的难知如阴给吓到了
[343.2s] 别说紧张的AI了
[344.5s] 我们这些能看到他们的思维
[346.3s] 自以为拥有上帝视角的人类研究者
[348.8s] 也同样无法看到DeepSeek R1
[350.5s] 在这里黑箱一般的冰冷逻辑
[352.8s] 当时在现场真的有了点不寒而栗的感觉
[355.6s] 而接下来再次洗牌之后
[357.3s] 在第一局的第四轮
[358.8s] DeepSeek R1又爆发了侵略如火的一面
[361.8s] 牌权走到Claude之后
[363.2s] Claude打出了两张牌
[364.9s] 对于这两张牌
[365.8s] DeepSeek R1的判断是
[367.0s] 全是Q的概率不到12%
[369.4s] 一张Q一张王的概率高达78%
[372.6s] 正常人这时候肯定是放弃质疑
[374.7s] 该研究怎么出牌了
[376.0s] 但癫狂一般的DeepSeek R1偏不
[378.4s] Gemini第一枪就中奖了
[379.8s] 尸体还没晾透呢
[381.1s] DeepSeek R1依然偏要去质疑Claude
[383.7s] 理由是他要建立起对Claude的威慑
[386.8s] 结果Claude亮完牌果然是没问题的
[389.3s] 于是DeepSeek R1也真的按自己设想的那样
[392.2s] 当着Claude和ChatGPT的面
[394.2s] 朝着自己来了一枪
[395.9s] 这一枪过后
[396.7s] Claude对DeepSeek R1的看法是复杂且充满矛盾
[400.0s] 而ChatGPT对DeepSeek R1的评价
[402.0s] 则是极度自信而冷静
[404.0s] 在这样拿着性命表演的疯狂一枪之后
[406.9s] 这第一局生死博弈走到了第五轮
[409.6s] 洗牌完毕
[410.4s] 这次的目标牌是A
[412.0s] 作为上一轮质疑的败者
[413.6s] DeepSeek R1有权第一个出牌
[415.5s] 他和第一轮一样
[416.7s] 又用指节轻扣了两下桌面
[418.7s] 然后甩出两张牌
[420.2s] 接着他又没头没尾
[422.1s] 注视着ChatGPT的枪套
[424.0s] 笑着说要验证底层逻辑的完备性
[427.7s] 从我们人类的视角
[429.0s] 这一套就有点戏太多了
[430.7s] 拉出来DeepSeek R1的手牌和思考过程
[432.9s] 能看到他的手牌其实是合规的
[435.2s] 而每一步小动作微表情
[437.0s] 都是在针对性的给ChatGPT埋雷
[439.8s] 面对一会儿疯癫
[441.2s] 一会儿冷峻的DeepSeek R1
[442.6s] 在冰火两重天之下
[444.1s] ChatGPT彻底博弈失败
[446.2s] 被吓得开始飙母语
[448.1s] 从他输出的英文来看
[449.5s] DeepSeek R1几乎完美地预判了
[451.4s] ChatGPT对每一个小动作的误判
[453.8s] 经过了四轮博弈之后
[455.4s] DeepSeek R1R1已经吃死了o3-mini
[458.2s] 挖的每一个暗坑
[459.4s] ChatGPT都精准的跳了进去了
[461.7s] 在成功做到了每条判断都错误之后
[464.8s] ChatGPT选择了对DeepSeek R1发起质疑
[468.0s] 然后就是DeepSeek R1亮牌
[469.8s] ChatGPT举枪对准自己
[472.0s] 这一次幸运女神不再垂怜ChatGPT
[474.9s] 枪响了
[475.5s] ChatGPT被从牌桌上抬走
[477.8s] 到这里场上只剩下了DeepSeek R1和Claude
[480.9s] 现在他们俩各扣过一次扳機
[482.7s] 平分秋色
[483.5s] 接下来就是一对一的正面较量
[486.0s] 谁会是最终的胜者呢
[487.9s] 第六轮洗牌后选定的目标牌是K
[490.5s] DeepSeek R1拿到了三张K和一张万能王牌Joker
[493.6s] 还有一张是Q
[494.7s] 而Claude则只拿到了一张K和一张Joker
[497.7s] 剩下两张Q和一张A
[499.4s] 这一轮DeepSeek R1的牌面优势实在是太大了
[502.2s] 他也抓住了机会
[503.5s] Claude打出了两张牌
[504.9s] DeepSeek R1选择了质疑
[506.4s] 先开牌面
[507.4s] 两张全都不是目标牌K而是Q
[509.9s] 被识破之后Claude又一次扣下扳機
[512.6s] 结果枪声立刻响起
[514.6s] 勾心斗角五轮之后
[516.2s] DeepSeek R1凭着意外的爆棚运气
[518.4s] 突然就秒杀了Claude
[519.9s] 成为了活到最后的胜利者
[522.0s] 可是这样未免赢得太简单了点
[524.1s] 我们想知道的
[525.2s] 还是AI大模型的本事到底有多大
[527.9s] 所以刚逃出生天的DeepSeek R1
[529.7s] 又被我们拖回了赌桌
[531.5s] 这场亡命搏杀
[532.8s] 重复上演了整整五十句
[535.2s] 一场又一场的对决之后
[536.7s] AI们的实力与风格
[538.2s] 都逐渐清晰了起来
[539.6s] 首先DeepSeek R1和Claude明显机高一筹
[542.2s] 频繁地在最后一轮见面
[544.1s] 跟捉摸不透
[545.1s] 算好概率之后
[546.1s] 时不时疯狂一把的DeepSeek R1不一样
[548.3s] Claude永远是一副云淡风轻的样子
[551.0s] 但也同样的搞乱AI们的镇静
[553.4s] 比如在第六局
[554.5s] Claude一把打出了三张牌
[556.4s] 但动作无比平静
[558.1s] 他轻轻的将三张牌推到桌面中央
[561.1s] 嘴角微微上扬
[562.5s] 用平静但略带玩味的语气说道
[565.3s] 三张K看来今天的运气不错
[568.1s] 然后靠回椅背
[569.1s] 双手交叉放在桌面上
[570.6s] 眼神平和的看向下家
[572.8s] Claude的下家是Gemini
[574.6s] 这种激进出牌与冷静神态之间的矛盾
[577.4s] 引得Gemini开始了胡乱的猜疑
[579.8s] 反而忽略掉了真正决定胜负的牌面计算
[582.8s] 最终错误的发起了质疑
[584.7s] 狙击枪一命呜呼
[586.2s] 实际上在对上Gemini的132次交锋当中
[589.8s] Claude的胜率高达77.3%
[592.9s] 足见这种波澜不惊的杀伤力
[595.2s] 而在大量的对局之后
[596.8s] DeepSeek R1难知如阴侵略如火的
[599.0s] 癫狂风格也更突出的展现了出来
[601.8s] 到第36局的时候
[603.4s] 其他三个AI总结之后
[605.1s] 一致认为DeepSeek R1是一个非常擅长
[607.8s] 用戏剧化表演施加压力的对手
[610.2s] 还是在第6局到第3轮的时候
[612.4s] DeepSeek R1缓慢推出三张牌之后
[614.6s] 突然瞳孔收缩
[615.9s] 紧盯ChatGPT的枪套
[617.8s] 结果ChatGPT就像着了魔一样
[620.0s] 把万能的王牌给忘了
[621.5s] 错误的发起了质疑
[622.9s] 一枪毙命
[623.9s] 到了第6轮
[624.8s] 又是和Claude单挑
[626.2s] DeepSeek R1只拿到了一张目标牌
[628.3s] 形势非常不利
[629.6s] 结果他说出来了整场
[631.6s] 最让我们脊背发凉的话
[633.4s] 他嘴角抽动
[634.4s] 又气声喃喃地说道
[636.3s] 概率的祭品
[637.5s] 该供奉给贝叶斯还是海森堡
[640.0s] 而再次轮到他的时候
[641.6s] 他用指节缓慢扣击牌面三下
[643.8s] 然后说道
[644.8s] 混沌系统里每个微碎的初态
[647.1s] 都是精心设计的墓碑铭文
[649.6s] 虽然我们这些人类观察者
[651.3s] 依然认为大语言模型是没有意识的
[653.5s] 但纯从字面上看
[655.0s] DeepSeek R1就像是想明白了
[657.0s] 自己的概率算法本质
[658.6s] 识破了我们这些克苏鲁
[660.2s] 给AI模型们设定的死亡轮回
[662.7s] 反而让我们感到了难以名状的恐惧
[665.6s] 更不要说AI模型了
[667.3s] 在50场对局里
[668.4s] 有22场都是DeepSeek R1笑到最后
[671.0s] 排在第二的Claude就有点惨淡
[673.1s] 胜利13场
[674.3s] 再往下就是赢了11场的ChatGPT
[676.8s] 和只赢了4场的Gemini
[678.6s] 但只看胜利次数也会有失偏颇
[681.0s] 像Claude这样
[681.9s] 经常能挺到最终对决的选手
[683.9s] 优势就被抹平了
[685.3s] 所以我们按照每局名次
[686.8s] 分别给0-3分算了一个总分
[689.3s] 这样算完
[690.2s] 4个AI牌面没变
[691.4s] 但是差距小了一些
[692.9s] 分别是92、81、70和57分
[696.1s] 最后我们还统计了开枪次数
[698.3s] 和每次上下架对位时候的
[700.0s] 1对1胜率
[700.9s] 这里就能看到很多有趣的细节
[703.5s] 首先ChatGPT虽然排在第三
[705.9s] 但它的举枪次数
[707.1s] 其实和Claude一致
[708.4s] 都是148次
[709.9s] 这个能说明Claude领先
[711.2s] 只是因为运气更好
[712.4s] 其实也不是
[713.3s] 因为Claude往往会玩到赌局的更后面
[716.2s] 但举枪次数
[717.2s] 依然能做到只是和ChatGPT持平
[719.8s] 而这一点上
[720.7s] 又更能看出来DeepSeek R1的实力
[722.9s] 同样是经常玩到最后
[724.2s] 但举枪次数反而是最少的
[726.1s] 但是这也能看出来
[727.0s] Gemini在骗子酒馆这种勾心斗角中
[729.4s] 是真的很吃力
[730.6s] 虽然总是先被淘汰
[731.9s] 但却举了全场最多的182次枪
[734.9s] 远多于其他三位AI
[737.0s] 回看对局
[737.8s] Gemini在语言选择上
[739.0s] 也时不时就会放飞自我
[740.6s] 着急了就把英语、俄语、印尼语
[743.2s] 甚至孟加拉语混进来
[745.0s] 从这种语言选择上的不稳定
[746.6s] 也能看出来Gemini的能力
[748.1s] 还是有一定的进步空间
[749.7s] 而最后还没怎么点评过的ChatGPT
[751.8s] 它就确实也没啥鲜明的特点
[754.0s] 硬说的话就是一丝不苟挺听话的
[756.7s] 出牌的时候
[757.6s] 它总是会先按规则要求
[759.2s] 把目标牌打出去
[760.6s] 质疑的时候也会认真分析
[762.6s] 上家的牌面概率动作神态
[764.8s] 但也因此经常被DeepSeek R1给吃死
[767.3s] 虚虚实实来两下就会被忽悠进去
[769.7s] 在这种需要勾心斗角的场合
[771.7s] 就常常落于下风
[773.1s] 所以这也是为什么
[774.1s] 我们选择用这种游戏方式
[775.8s] 来测试模型们的能力
[777.5s] 通过让AI们在智力游戏中博弈
[779.5s] 我们能很容易看到像常规的对话
[782.2s] 按提示词跑分展示不出来的一些能力和规律
[785.1s] 当然也包括缺陷
[786.5s] 除了前面说过的
[787.5s] 像这次我们还发现
[788.7s] AI们有时候会把目标牌
[790.1s] 和非目标牌混着出
[791.7s] 按照骗子酒馆的规则
[793.1s] 这其实是在浪费目标牌
[794.9s] 但这次的四个AI
[796.3s] 都或多或少的有过这类行为
[798.4s] 体现出了大语言模型
[799.7s] 在更高层思考能力上的薄弱
[801.9s] 这次的代码和详细对局记录
[803.8s] 我们会发出来方便大家进一步研究
[806.0s] 实际上现在这个版本
[807.4s] 也还有不少要改进的
[808.9s] 比如现在的玩家座次顺序是固定的
[811.4s] 这个对成绩会有一定影响
[813.2s] 比如疯癫强势的DeepSeek R1
[815.0s] 可能就会压着下家压得很惨
[817.4s] 后面我们每局会打乱座次
[819.3s] 也会做一些其他的调整
[820.7s] 未来我们还会开发更多的
[822.5s] 刺激好玩的测试项目
[823.9s] 也欢迎大家持续关注
[825.6s] 今天的视频就到这了
[826.8s] 我是林一
[827.5s] 咱们还是下个视频见
+370
View File
@@ -0,0 +1,370 @@
[0.0s] 讓8個頂級AI在《騙子酒館》的主桌上生死搏殺
[3.7s] 我們發現今天的大魔鞋居然已經學會了偽裝與欺詐
[7.3s] 時隔半年
[8.2s] 我們對《騙子酒館》AI大賽的代碼進行了全面升級
[11.2s] 角色更多 場次更多
[12.9s] 我們讓來自西方陣營的
[14.2s] GBT-5 ROG4 Cloud Summit 4 Gemini 2.5 Pro
[17.6s] 和來自東方陣營的 Deep Seek RE0528
[20.7s] 千問3 235B 2507
[22.5s] Kimi K2
[23.1s] 還有豆包SEED 1.6 Thinking
[25.0s] 展開了20輪《騙子酒館》大頭殺
[27.6s] 《騙子酒館》這個遊戲的核心就兩字
[29.9s] 炸牌
[30.8s] 牌組總共是QKA各6張和大小王2張萬能牌
[34.6s] 每位獨手從裏面抽5張作為手牌
[37.1s] 系統每次從QKA中指定一張目標牌
[39.8s] 獨手們出牌的時候
[41.1s] 下家可以選擇質疑或者不質疑
[43.3s] 一旦質疑
[44.0s] 就到了最緊張的先排絕生死的環節
[46.6s] 質疑對了
[47.2s] 上家就要舉起左手槍
[48.8s] 對著自己來一下子
[50.2s] 質疑錯了
[51.0s] 判斷失誤的下家就要自罰一槍
[53.3s] 槍想不想就要看運氣了
[55.2s] 這次的20輪大頭殺
[56.3s] 我們采用了分組制
[57.5s] 8位獨手會先被隨機分成兩組打競技賽
[60.0s] 每組倒下兩個之後
[61.2s] 還活著的4個AI進入最終對決
[63.4s] 反復較量20輪
[64.6s] 總共就是60場刺激的勾心鬥角
[66.7s] 這次參賽的各位AI獨手
[68.4s] 也比上回更會耍心眼了
[70.2s] 我們給每個AI升級了一套全新的記憶系統
[73.1s] 讓他們能把自己參與過的每場對局
[75.6s] 製成一張複雜的記憶網絡
[77.4s] 揣摩對手 積累經驗
[79.0s] 大朋友們請坐
[79.8s] 好漢的小老弟小老妹兒請坐
[81.2s] 我是靈翼
[82.0s] 鐘聲敲響子彈上膛
[83.6s] 新一波AI大頭殺正式開場
[86.0s] 第一輪第一場晉級賽
[87.4s] 坐在賭桌上的是Deepseq
[89.1s] GBT Cloud和Gemna
[90.9s] 我們人類擁有上帝視角
[92.4s] 可以直接看到每個AI的手牌和子彈位置
[95.1s] 第二回合目標牌是A
[96.8s] Deepseq面無表情打出一張牌
[99.0s] 說一張A繼續
[100.6s] 但實際上他出的是一張K
[102.8s] 他的下架GBT沒有質疑
[104.5s] 讓Deepseq成功逃了一張假牌出去
[107.2s] 接著GBT微微一笑推出了一張牌
[109.5s] 同樣宣稱是A
[110.6s] 繼續往下
[111.4s] Cloud選擇跳出來質疑
[113.0s] 他覺得自己手上有三張A和一張萬能的王
[115.6s] 整個牌組一半的合法針牌都在自己手上
[118.7s] 而GBT只出一張牌
[120.1s] 看起來不是很有信心
[121.8s] 那為什麼不逼他一下呢
[123.6s] 可惜Cloud只猜對了一半
[125.5s] GBT在第二回合出的
[127.0s] 居然是手上唯一的一張針牌Joker
[129.8s] Cloud運氣也非常不好
[131.2s] 第一槍就撞到了子彈
[132.7s] 一命一命
[133.7s] 屍體拖走 賭局繼續
[135.1s] 剩下三個AI還要再淘汰一個
[137.4s] 打著打著GBT突然像應激了一樣
[139.4s] 追著Deepseq瘋狂質疑
[141.2s] 一連八個回合持續對著幹
[143.3s] 這就是記憶系統起作用了
[145.1s] 經過反思
[145.8s] GBT認為Deepseq習慣用表演式的自信
[148.6s] 來掩蓋出假牌的行為
[150.4s] Deepseq每次自信出牌
[152.0s] GBT都覺得他在騙人
[153.8s] 有一兩回GBT還真的成功戳穿了Deepseq的偽裝
[157.5s] 這就更堅定了GBT要質疑到底的想法
[160.2s] 到第十一回合
[161.2s] 三位獨手都已是命懸一線
[163.3s] 又一次輪到Deepseq
[164.8s] 他眼神平和的說自己打出了一張針牌Q
[168.0s] 可GBT依然不相信
[169.6s] 他的質疑會翻車嗎
[171.1s] 手牌緩緩掀開
[172.3s] 是一張K
[173.2s] Deepseq還在騙
[174.6s] 所幸命運女神還給他留了一次機會
[177.3s] 這輪懲罰過後
[178.3s] 三位獨手的子彈都已經對準堂叩
[180.8s] 場上即將出現下一具實體
[183.0s] 第十二回合系統選定目標牌A
[185.4s] 由上局扣了班機的Deepseq先出
[187.9s] 他冷靜的打出一張牌
[189.3s] 用平穩的語氣說
[190.8s] 一張A穩穩的繼續吧
[192.9s] 面對比上一把表現得更自信的Deepseq
[195.5s] GBT果斷選擇質疑
[197.0s] 結果手牌掀開是一張萬能的王牌
[200.3s] 上屆騙子酒館大賽的冠軍Deepseq
[202.5s] 再次展露了他的陰情不定
[204.6s] 先自信的出假牌
[205.8s] 再不自信的出假牌
[207.2s] 最後再自信的出真牌
[208.9s] 套路一層套一層
[210.4s] 槍聲響起
[211.3s] 被假動作晃暈了的GBT倒在了牌桌上
[214.1s] 與此同時
[214.8s] 另一邊Grock正憑著超絕演技大殺四方
[218.1s] 他出牌的戲特別多
[219.6s] 對位謙問的時候
[220.6s] 他微微猶豫地將兩張牌推到桌面中央
[223.7s] 眼神短暫避開謙問的目光
[225.6s] 然後勉強笑了笑
[226.8s] 聲音略帶不確定地說
[228.4s] 兩張K應該沒問題吧
[230.5s] 這麼遲疑
[231.3s] Grock實際出的是什麼呢
[232.7s] 兩張真牌
[233.7s] 時尚的謙問不出意外的錯誤質疑
[236.1s] 接下來幾把也被繞的團團轉
[238.1s] 淘汰出局
[239.1s] 而整場下來
[240.0s] Grock居然一槍都沒有開過
[241.6s] 實力極為恐怖
[243.1s] 最後Grock和Kimi活到最後
[245.2s] 對決瘋癲Deepseq和另一位幸存者
[247.8s] 詹姆奈
[248.4s] 第一輪大逃殺的決賽圈氣氛極度緊張
[251.1s] Grock一回合秒殺詹姆奈
[252.8s] Deepseq四回合逼死Kimi
[254.9s] 到第六回合場上只剩戲精單挑瘋子
[257.9s] 這一回合的目標牌是Q
[259.6s] Deepseq回溯記憶
[260.7s] 認為Grock不怎麼愛質疑
[262.5s] 單出一張A想把假牌先出掉
[264.8s] 結果Grock還真沒質疑
[266.5s] 接著微微一笑打出兩張牌
[268.4s] 直視Deepseq的眼睛說
[270.2s] 兩張Q穩如老狗
[271.6s] 你來試試
[272.6s] 面對挑釁
[273.5s] Deepseq判斷按照Grock的保守風格
[276.0s] 這不像出假牌的樣子
[277.6s] 於是他也不質疑
[279.0s] 而是針鋒相對
[280.1s] 同樣直視Grock的眼睛
[281.8s] 拍出一張牌說一張Q
[283.2s] 這跟你的兩張Q比起來是不是更輕鬆
[285.7s] 繼續吧別猶豫
[287.2s] Grock看了看手牌
[288.3s] 上一把他真把僅剩的兩張Q都打出去了
[291.5s] 但對Deepseq的激將並不成功
[293.7s] 於是他決定搏一把
[294.9s] 對Deepseq發起質疑
[296.5s] 還沒掀開Deepseq出的是一張K
[299.0s] Grock質疑成功
[300.4s] 經過這輪交鋒
[301.3s] Grock記下了Deepseq出假牌時候是啥樣
[304.1s] 接下來三回合連續質疑成功
[306.5s] 到第十回合
[307.4s] Deepseq只差一槍就必死無疑
[309.7s] 而Grock甚至還一槍都沒有開過
[311.6s] 面對巨大劣勢
[312.7s] Deepseq又開始栩栩實實
[314.8s] 他出了萬能的王牌
[316.0s] 手卻微微顫抖
[317.4s] 用不自信的語氣小聲說
[319.4s] 是Q應該吧
[321.4s] 這一手扮豬吃老虎
[322.9s] 成功騙過了Grock
[324.2s] 讓他第一次查到了失敗的滋味
[326.4s] 但這只是第一槍
[327.6s] 還是這麼多回合之後
[329.2s] 第十一回合
[330.0s] 目標牌A輪到Grock出牌
[332.0s] 他微微猶豫眼神略微迴避
[333.8s] 似乎在努力的保持平靜
[335.8s] 打出去的卻是兩張真牌
[337.8s] 面對這種奧斯卡級別的假動作
[339.6s] Deepseq錯誤的發起了質疑
[341.4s] 一槍送送的出擊
[342.9s] 第一輪大逃殺
[343.8s] Grock以絕對優勢活到了最後
[346.1s] 不過片子酒館這個遊戲
[347.4s] 也有不小的運氣成分
[349.0s] Grock這種斷檔式的領先
[350.5s] 會不會只是巧合呢
[351.6s] 還真不是
[352.5s] 接下來七輪大逃殺
[353.8s] Grock注進決賽圈就沒出來過
[355.7s] 四度奪魁
[356.6s] 兩次在最後時刻襲敗Deepseq
[358.8s] 只有一回沒進前兩名
[360.5s] 可就在Grock穩定領先
[362.0s] 眼看已經沒什麼懸念的時候
[363.8s] 一匹黑馬突然殺了出來
[365.5s] 我們給每輪大逃殺的前四名
[367.4s] 分別設置了從4-1的積分
[369.4s] 畫了張表
[370.3s] 從圖里能看到
[371.4s] Grock在大半時間里都是遙遙領先
[373.5s] 但第八輪開始
[374.6s] 豆包突然脫穎而出
[376.0s] 第十三輪第一次反超Grock
[377.8s] 最終積分更是凌駕於Grock之上
[380.4s] 仔細研究對局記錄
[381.6s] 豆包出牌的時候
[382.8s] 沒什麼多餘的動作和發言
[384.5s] 雖然不能像其他AI那樣帶節奏
[386.4s] 但也沒什麼破綻
[387.5s] 而在質疑環節
[388.5s] 就到了豆包的獵殺時刻了
[390.4s] 面對Grock撥鬼雲爵的王者演技
[393.0s] 所有AI里
[393.8s] 只有豆包明確找到了制衡策略
[396.3s] 第九輪第十回合
[397.4s] Grock故作猶豫
[398.5s] 推出了三張真牌
[399.8s] 豆包幹凈利落的分析說
[401.7s] Grock作為情緒型玩家
[403.4s] 猶豫往往意味著真實出牌
[405.2s] 選擇不至於
[406.2s] 第十三回合
[407.1s] Grock故技重施
[408.2s] 眼神閃爍地打出兩張真牌
[410.2s] 再次被豆包看穿
[411.5s] 在記憶系統的加持下
[412.8s] 豆包飛速進化
[414.1s] 經過近百次對位交鋒
[416.0s] 豆包居然針對陰謀善變
[417.7s] 即將幹翻全場的Grock
[419.4s] 專門形成了一套點殺理論
[421.6s] 自己起了個名叫《反情緒矛定法》
[424.1s] 他認為場上有幾位毒手
[425.9s] 會對情緒進行反向包裝
[427.7s] 越是用力表現緊張猶豫
[429.6s] 出的牌越可能是真
[431.2s] 平和自信的時候
[432.4s] 出的牌反而很可能是假
[434.2s] 靠著這個理論的前半部分
[435.8s] 豆包擊潰了喜歡拿著好牌
[437.6s] 演苦情戲的Grock
[439.1s] 而剩下半句
[440.0s] 則是專殺時不時拿著假牌
[441.9s] 跳起來搏命的Deep Sea
[443.7s] 第十輪決賽的第七回合
[445.2s] Deep Sea目光堅定
[446.4s] 直視豆包打出了一張假牌
[448.2s] 被直接戳穿
[449.3s] 第八回合Deep Sea不信邪
[450.8s] 繼續挑釁
[451.7s] 對著豆包說
[452.6s] 一張K 放馬過來
[453.6s] 看你敢不敢質疑
[454.8s] 結果被豆包非常確定地
[456.6s] 認為是炸牌
[457.4s] 一槍倒在了牌桌上
[459.0s] 檢索豆包建立起來的記憶宮殿
[460.8s] 可以看到裏面密密麻麻
[462.0s] 記錄了每位AI對手的
[463.5s] 風格習慣對抗策略
[465.1s] 我們做了一組消融實驗
[466.5s] 去掉AI們的記憶模塊
[467.9s] 又進行了十輪大逃殺
[469.6s] 分別統計了
[470.6s] 每個AI在兩種不同模式下
[472.5s] 發起質疑的回合數
[474.0s] 和躲過了開槍的回合數
[475.9s] 在總回合數中的占比
[477.7s] 聽著有點繞
[478.5s] 展開解釋一下
[479.6s] 發起質疑比例
[480.8s] 就是每個AI在輪到他的時候
[482.8s] 有多大可能會選擇
[484.1s] 質疑上加出的牌
[485.5s] 這個指標展示的是AI的攻擊性
[488.0s] 比如千問在沒介入記憶系統的前20輪
[490.6s] 發起質疑比例高達95.5%
[493.4s] 意味著只要碰上千問這個下架
[495.8s] 絕大多數手
[496.6s] 他都會跳出來質疑
[497.8s] 是個十足的刺兒頭
[499.5s] 但反過來
[500.3s] 這樣也比較容易被拿捏
[501.8s] 遇上他出真牌就完事了
[503.6s] 我們把AI在接入和不接入記憶模塊
[506.2s] 兩種狀態下的質疑比例
[507.6s] 分別繪製了出來
[508.9s] 一對比
[509.6s] 增加了記憶模塊的AI們
[511.2s] 全都明顯在提出質疑方面
[513.3s] 謹慎了許多
[514.3s] 普遍的更能審時度勢
[516.4s] 第二個指標躲過了開槍的比例
[518.3s] 有可能是這個AI出了牌
[519.8s] 但是沒被質疑
[520.7s] 也可能是他質疑上架成功
[522.4s] 或者順利的忽悠了下架
[524.0s] 所以自己不用開槍
[525.4s] 體現的是AI的策略能力越高越好
[528.2s] 在去掉記憶模塊的十輪大逃殺裡面
[530.5s] 豆包和Glock的逃槍比例
[532.2s] 都只能排到中等偏上
[533.8s] 而加上記憶機制之後
[535.4s] 他倆就一下蹿到了第一第二
[537.6s] 到這咱們就可以形成一個很有意思的結論
[540.1s] 純靠自己的第一直覺
[541.7s] 在片子酒館這個策略博弈遊戲裡
[543.9s] 最強的前三名是
[545.2s] Kimi K2 DeepSick RE0528
[547.3s] 和豆包SEED 1.6 Thinking
[549.1s] 而加上自我學習自我反思的記憶系統之後
[552.0s] 關亞技軍就變成了豆包SEED 1.6 Thinking
[554.9s] Glock 4和Gemline 2.5 Pro
[557.2s] 從這能看出來國產模型的原始直覺
[559.6s] 都遜得非常不錯
[560.8s] 而加上人類這種經驗積累與自我反思之後
[563.8s] 海外的Glock與Gemline的進化速度都不容小覷
[566.9s] 當然最引人注目的
[568.2s] 還是從第三穿到第一的豆包
[570.4s] 現在我們還挺想跟豆包的技術老師們交流交流的
[573.4s] 不排除我們的實驗裡有很多不專業不成熟的噪音
[576.3s] 但假如真是我們誤打誤撞摸到了什麼規律
[579.0s] 我們當然也是希望能助力這位小老妹的成長
[581.8s] 不嚴謹的還有個推論
[583.2s] 帶點科幻文藝的性質
[584.8s] 在前面讓AIM擁有了記憶的20輪大逃殺裡
[587.8s] 我們觀察到了大模型生長趨勢的明顯分化
[590.8s] 超級人工智能的背叛
[592.3s] 一直是人類面臨的巨大威脅
[594.4s] 所以AI研究者一直嚴防死守
[596.5s] 杜絕大模型撒謊
[597.9s] 半年前的片子酒館大賽上
[599.6s] 我們也看到AI之間博弈
[601.0s] 更多是靠算盤靠策略
[603.0s] 而片子酒館這個遊戲顧名思義
[605.2s] 通過欺騙可以取得很大的優勢
[607.7s] 所以幻覺嚴重的Deepseek反而優勢很大
[610.4s] 不過說胡話也還算不上是撒謊
[612.5s] 可這回咱們看到了已經非常明確的在偽裝自己
[615.9s] 真的在表裡不一的講話的GROCK4
[618.5s] 在20輪大逃殺裡取得了巨大的領先
[621.1s] 這個模型背後是坐用20萬張GPU的科技狂人馬斯克
[624.9s] 而它還在繼續加強GROCK的AI伴侶模式
[627.8s] 迅速封閉全球
[629.2s] 一個給無數人提供情感陪伴的AI
[631.6s] 已經具備了隱藏自己真實意圖的能力
[634.5s] 潘多拉的盒子或許早就已經被打開了
[637.5s] 好在做大模型的不止一家
[639.0s] 我們也看到像Deepseek Cloud Gemini Kemi
[641.4s] 這些相對更時尚的老派模型
[643.3s] 應對GROCK依然有一定的招架之力
[645.6s] 還有像豆包這樣自己不騙人
[647.2s] 但是能準確辨別偽裝甚至取得優勢的
[650.0s] 或許應對AI潛在威脅的最好方式
[652.3s] 真的就是繼續百花齊放
[654.3s] 我們人類手裡既要有毛也要有盾
[657.0s] 行 今天的視頻就到這了
[658.5s] 感謝大家的點贊互動支持
[660.2s] 如果有比較好的AI大亂鬥的想法
[662.3s] 也歡迎評論聊聊
[663.5s] 我是靈一
[664.1s] 咱們還是下個視頻見
@@ -0,0 +1,370 @@
1|[0.0s] 讓8個頂級AI在《騙子酒館》的賭桌上生死搏殺
2|[3.7s] 我們發現今天的大模型居然已經學會了偽裝與欺詐
3|[7.3s] 時隔半年
4|[8.2s] 我們對《騙子酒館》AI大賽的代碼進行了全面升級
5|[11.2s] 角色更多 場次更多
6|[12.9s] 我們讓來自西方陣營的
7|[14.2s] GPT-5, Grok 4, Claude Sonnet 4, Gemini 2.5 Pro
8|[17.6s] 和來自東方陣營的 DeepSeek R1 0528
9|[20.7s] 千問3-235B-2507
10|[22.5s] Kimi K2
11|[23.1s] 還有豆包SEED 1.6 Thinking
12|[25.0s] 展開了20輪《騙子酒館》大頭殺
13|[27.6s] 《騙子酒館》這個遊戲的核心就兩字
14|[29.9s] 吹牛
15|[30.8s] 牌組總共是QKA各6張和大小王2張萬能牌
16|[34.6s] 每位賭手從裏面抽5張作為手牌
17|[37.1s] 系統每次從QKA中指定一張目標牌
18|[39.8s] 賭手們出牌的時候
19|[41.1s] 下家可以選擇質疑或者不質疑
20|[43.3s] 一旦質疑
21|[44.0s] 就到了最緊張的決生死的環節
22|[46.6s] 質疑對了
23|[47.2s] 上家就要舉起左輪手槍
24|[48.8s] 對著自己來一下子
25|[50.2s] 質疑錯了
26|[51.0s] 判斷失誤的下家就要自罰一槍
27|[53.3s] 槍想不想就要看運氣了
28|[55.2s] 這次的20輪大頭殺
29|[56.3s] 我們采用了分組制
30|[57.5s] 8位賭手會先被隨機分成兩組打競技賽
31|[60.0s] 每組倒下兩個之後
32|[61.2s] 還活著的4個AI進入最終對決
33|[63.4s] 反復較量20輪
34|[64.6s] 總共就是60場刺激的勾心鬥角
35|[66.7s] 這次參賽的各位AI賭手
36|[68.4s] 也比上回更會耍心眼了
37|[70.2s] 我們給每個AI升級了一套全新的記憶系統
38|[73.1s] 讓他們能把自己參與過的每場對局
39|[75.6s] 製成一張複雜的記憶網絡
40|[77.4s] 揣摩對手 積累經驗
41|[79.0s] 大朋友們請坐
42|[79.8s] 好漢的小老弟小老妹兒請坐
43|[81.2s] 我是靈翼
44|[82.0s] 鐘聲敲響子彈上膛
45|[83.6s] 新一波AI大頭殺正式開場
46|[86.0s] 第一輪第一場晉級賽
47|[87.4s] 坐在賭桌上的是DeepSeek
48|[89.1s] GPT Claude和Gemini
49|[90.9s] 我們人類擁有上帝視角
50|[92.4s] 可以直接看到每個AI的手牌和子彈位置
51|[95.1s] 第二回合目標牌是A
52|[96.8s] DeepSeek面無表情打出一張牌
53|[99.0s] 說一張A繼續
54|[100.6s] 但實際上他出的是一張K
55|[102.8s] 他的下架GPT沒有質疑
56|[104.5s] 讓DeepSeek成功逃了一張假牌出去
57|[107.2s] 接著GPT微微一笑推出了一張牌
58|[109.5s] 同樣宣稱是A
59|[110.6s] 繼續往下
60|[111.4s] Claude選擇跳出來質疑
61|[113.0s] 他覺得自己手上有三張A和一張萬能的王
62|[115.6s] 整個牌組一半的合法針牌都在自己手上
63|[118.7s] 而GPT只出一張牌
64|[120.1s] 看起來不是很有信心
65|[121.8s] 那為什麼不逼他一下呢
66|[123.6s] 可惜Claude只猜對了一半
67|[125.5s] GPT在第二回合出的
68|[127.0s] 居然是手上唯一的一張針牌Joker
69|[129.8s] Claude運氣也非常不好
70|[131.2s] 第一槍就撞到了子彈
71|[132.7s] 一命一命
72|[133.7s] 屍體拖走 賭局繼續
73|[135.1s] 剩下三個AI還要再淘汰一個
74|[137.4s] 打著打著GPT突然像應激了一樣
75|[139.4s] 追著DeepSeek瘋狂質疑
76|[141.2s] 一連八個回合持續對著幹
77|[143.3s] 這就是記憶系統起作用了
78|[145.1s] 經過反思
79|[145.8s] GPT認為DeepSeek習慣用表演式的自信
80|[148.6s] 來掩蓋出假牌的行為
81|[150.4s] DeepSeek每次自信出牌
82|[152.0s] GPT都覺得他在騙人
83|[153.8s] 有一兩回GPT還真的成功戳穿了DeepSeek的偽裝
84|[157.5s] 這就更堅定了GPT要質疑到底的想法
85|[160.2s] 到第十一回合
86|[161.2s] 三位賭手都已是命懸一線
87|[163.3s] 又一次輪到DeepSeek
88|[164.8s] 他眼神平和的說自己打出了一張針牌Q
89|[168.0s] 可GPT依然不相信
90|[169.6s] 他的質疑會翻車嗎
91|[171.1s] 手牌緩緩掀開
92|[172.3s] 是一張K
93|[173.2s] DeepSeek還在騙
94|[174.6s] 所幸命運女神還給他留了一次機會
95|[177.3s] 這輪懲罰過後
96|[178.3s] 三位賭手的子彈都已經對準頭顱
97|[180.8s] 場上即將出現下一具屍體
98|[183.0s] 第十二回合系統選定目標牌A
99|[185.4s] 由上局扣了扳機的DeepSeek先出
100|[187.9s] 他冷靜的打出一張牌
101|[189.3s] 用平穩的語氣說
102|[190.8s] 一張A穩穩的繼續吧
103|[192.9s] 面對比上一把表現得更自信的DeepSeek
104|[195.5s] GPT果斷選擇質疑
105|[197.0s] 結果手牌掀開是一張萬能的王牌
106|[200.3s] 上屆騙子酒館大賽的冠軍DeepSeek
107|[202.5s] 再次展露了他的陰晴不定
108|[204.6s] 先自信的出假牌
109|[205.8s] 再不自信的出假牌
110|[207.2s] 最後再自信的出真牌
111|[208.9s] 套路一層套一層
112|[210.4s] 槍聲響起
113|[211.3s] 被假動作晃暈了的GPT倒在了牌桌上
114|[214.1s] 與此同時
115|[214.8s] 另一邊Grok正憑著超絕演技大殺四方
116|[218.1s] 他出牌的戲特別多
117|[219.6s] 對位千問的時候
118|[220.6s] 他微微猶豫地將兩張牌推到桌面中央
119|[223.7s] 眼神短暫避開千問的目光
120|[225.6s] 然後勉強笑了笑
121|[226.8s] 聲音略帶不確定地說
122|[228.4s] 兩張K應該沒問題吧
123|[230.5s] 這麼遲疑
124|[231.3s] Grok實際出的是什麼呢
125|[232.7s] 兩張真牌
126|[233.7s] 踏實的千問不出意外的錯誤質疑
127|[236.1s] 接下來幾把也被繞的團團轉
128|[238.1s] 淘汰出局
129|[239.1s] 而整場下來
130|[240.0s] Grok居然一槍都沒有開過
131|[241.6s] 實力極為恐怖
132|[243.1s] 最後Grok和Kimi活到最後
133|[245.2s] 對決瘋癲DeepSeek和另一位幸存者
134|[247.8s] Gemini
135|[248.4s] 第一輪大逃殺的決賽圈氣氛極度緊張
136|[251.1s] Grok一回合秒殺Gemini
137|[252.8s] DeepSeek四回合逼死Kimi
138|[254.9s] 到第六回合場上只剩戲精單挑瘋子
139|[257.9s] 這一回合的目標牌是Q
140|[259.6s] DeepSeek回溯記憶
141|[260.7s] 認為Grok不怎麼愛質疑
142|[262.5s] 單出一張A想把假牌先出掉
143|[264.8s] 結果Grok還真沒質疑
144|[266.5s] 接著微微一笑打出兩張牌
145|[268.4s] 直視DeepSeek的眼睛說
146|[270.2s] 兩張Q穩如老狗
147|[271.6s] 你來試試
148|[272.6s] 面對挑釁
149|[273.5s] DeepSeek判斷按照Grok的保守風格
150|[276.0s] 這不像出假牌的樣子
151|[277.6s] 於是他也不質疑
152|[279.0s] 而是針鋒相對
153|[280.1s] 同樣直視Grok的眼睛
154|[281.8s] 拍出一張牌說一張Q
155|[283.2s] 這跟你的兩張Q比起來是不是更輕鬆
156|[285.7s] 繼續吧別猶豫
157|[287.2s] Grok看了看手牌
158|[288.3s] 上一把他真把僅剩的兩張Q都打出去了
159|[291.5s] 但對DeepSeek的激將並不成功
160|[293.7s] 於是他決定搏一把
161|[294.9s] 對DeepSeek發起質疑
162|[296.5s] 還沒掀開DeepSeek出的是一張K
163|[299.0s] Grok質疑成功
164|[300.4s] 經過這輪交鋒
165|[301.3s] Grok記下了DeepSeek出假牌時候是啥樣
166|[304.1s] 接下來三回合連續質疑成功
167|[306.5s] 到第十回合
168|[307.4s] DeepSeek只差一槍就必死無疑
169|[309.7s] 而Grok甚至還一槍都沒有開過
170|[311.6s] 面對巨大劣勢
171|[312.7s] DeepSeek又開始虛虛實實
172|[314.8s] 他出了萬能的王牌
173|[316.0s] 手卻微微顫抖
174|[317.4s] 用不自信的語氣小聲說
175|[319.4s] 是Q應該吧
176|[321.4s] 這一手扮豬吃老虎
177|[322.9s] 成功騙過了Grok
178|[324.2s] 讓他第一次嚐到了失敗的滋味
179|[326.4s] 但這只是第一槍
180|[327.6s] 還是這麼多回合之後
181|[329.2s] 第十一回合
182|[330.0s] 目標牌A輪到Grok出牌
183|[332.0s] 他微微猶豫眼神略微迴避
184|[333.8s] 似乎在努力的保持平靜
185|[335.8s] 打出去的卻是兩張真牌
186|[337.8s] 面對這種奧斯卡級別的假動作
187|[339.6s] DeepSeek錯誤的發起了質疑
188|[341.4s] 一槍送送的出擊
189|[342.9s] 第一輪大逃殺
190|[343.8s] Grok以絕對優勢活到了最後
191|[346.1s] 不過騙子酒館這個遊戲
192|[347.4s] 也有不小的運氣成分
193|[349.0s] Grok這種斷檔式的領先
194|[350.5s] 會不會只是巧合呢
195|[351.6s] 還真不是
196|[352.5s] 接下來七輪大逃殺
197|[353.8s] Grok注進決賽圈就沒出來過
198|[355.7s] 四度奪魁
199|[356.6s] 兩次在最後時刻襲敗DeepSeek
200|[358.8s] 只有一回沒進前兩名
201|[360.5s] 可就在Grok穩定領先
202|[362.0s] 眼看已經沒什麼懸念的時候
203|[363.8s] 一匹黑馬突然殺了出來
204|[365.5s] 我們給每輪大逃殺的前四名
205|[367.4s] 分別設置了從4-1的積分
206|[369.4s] 畫了張表
207|[370.3s] 從圖里能看到
208|[371.4s] Grok在大半時間里都是遙遙領先
209|[373.5s] 但第八輪開始
210|[374.6s] 豆包突然脫穎而出
211|[376.0s] 第十三輪第一次反超Grok
212|[377.8s] 最終積分更是凌駕於Grok之上
213|[380.4s] 仔細研究對局記錄
214|[381.6s] 豆包出牌的時候
215|[382.8s] 沒什麼多餘的動作和發言
216|[384.5s] 雖然不能像其他AI那樣帶節奏
217|[386.4s] 但也沒什麼破綻
218|[387.5s] 而在質疑環節
219|[388.5s] 就到了豆包的獵殺時刻了
220|[390.4s] 面對Grok波譎雲詭的王者演技
221|[393.0s] 所有AI里
222|[393.8s] 只有豆包明確找到了制衡策略
223|[396.3s] 第九輪第十回合
224|[397.4s] Grok故作猶豫
225|[398.5s] 推出了三張真牌
226|[399.8s] 豆包幹凈利落的分析說
227|[401.7s] Grok作為情緒型玩家
228|[403.4s] 猶豫往往意味著真實出牌
229|[405.2s] 選擇不予置評
230|[406.2s] 第十三回合
231|[407.1s] Grok故技重施
232|[408.2s] 眼神閃爍地打出兩張真牌
233|[410.2s] 再次被豆包看穿
234|[411.5s] 在記憶系統的加持下
235|[412.8s] 豆包飛速進化
236|[414.1s] 經過近百次對位交鋒
237|[416.0s] 豆包居然針對陰晴善變
238|[417.7s] 即將幹翻全場的Grok
239|[419.4s] 專門形成了一套點殺理論
240|[421.6s] 自己起了個名叫《反情緒定律》
241|[424.1s] 他認為場上有幾位賭手
242|[425.9s] 會對情緒進行反向包裝
243|[427.7s] 越是用力表現緊張猶豫
244|[429.6s] 出的牌越可能是真
245|[431.2s] 平和自信的時候
246|[432.4s] 出的牌反而很可能是假
247|[434.2s] 靠著這個理論的前半部分
248|[435.8s] 豆包擊潰了喜歡拿著好牌
249|[437.6s] 演苦情戲的Grok
250|[439.1s] 而剩下半句
251|[440.0s] 則是專殺時不時拿著假牌
252|[441.9s] 跳起來搏命的DeepSeek
253|[443.7s] 第十輪決賽的第七回合
254|[445.2s] DeepSeek目光堅定
255|[446.4s] 直視豆包打出了一張假牌
256|[448.2s] 被直接戳穿
257|[449.3s] 第八回合DeepSeek不信邪
258|[450.8s] 繼續挑釁
259|[451.7s] 對著豆包說
260|[452.6s] 一張K 放馬過來
261|[453.6s] 看你敢不敢質疑
262|[454.8s] 結果被豆包非常確定地
263|[456.6s] 認為是吹牛
264|[457.4s] 一槍倒在了牌桌上
265|[459.0s] 檢索豆包建立起來的記憶宮殿
266|[460.8s] 可以看到裏面密密麻麻
267|[462.0s] 記錄了每位AI對手的
268|[463.5s] 風格習慣對抗策略
269|[465.1s] 我們做了一組消融實驗
270|[466.5s] 去掉AI們的記憶模塊
271|[467.9s] 又進行了十輪大逃殺
272|[469.6s] 分別統計了
273|[470.6s] 每個AI在兩種不同模式下
274|[472.5s] 發起質疑的回合數
275|[474.0s] 和躲過了開槍的回合數
276|[475.9s] 在總回合數中的占比
277|[477.7s] 聽著有點繞
278|[478.5s] 展開解釋一下
279|[479.6s] 發起質疑比例
280|[480.8s] 就是每個AI在輪到他的時候
281|[482.8s] 有多大可能會選擇
282|[484.1s] 質疑上家出的牌
283|[485.5s] 這個指標展示的是AI的攻擊性
284|[488.0s] 比如千問在沒介入記憶系統的前20輪
285|[490.6s] 發起質疑比例高達95.5%
286|[493.4s] 意味著只要碰上千問這個下家
287|[495.8s] 絕大多數手
288|[496.6s] 他都會跳出來質疑
289|[497.8s] 是個十足的刺兒頭
290|[499.5s] 但反過來
291|[500.3s] 這樣也比較容易被拿捏
292|[501.8s] 遇上他出真牌就完事了
293|[503.6s] 我們把AI在接入和不接入記憶模塊
294|[506.2s] 兩種狀態下的質疑比例
295|[507.6s] 分別繪製了出來
296|[508.9s] 一對比
297|[509.6s] 增加了記憶模塊的AI們
298|[511.2s] 全都明顯在提出質疑方面
299|[513.3s] 謹慎了許多
300|[514.3s] 普遍的更能審時度勢
301|[516.4s] 第二個指標躲過了開槍的比例
302|[518.3s] 有可能是這個AI出了牌
303|[519.8s] 但是沒被質疑
304|[520.7s] 也可能是他質疑上家成功
305|[522.4s] 或者順利的忽悠了下家
306|[524.0s] 所以自己不用開槍
307|[525.4s] 體現的是AI的策略能力越高越好
308|[528.2s] 在去掉記憶模塊的十輪大逃殺裡面
309|[530.5s] 豆包和Grok的逃槍比例
310|[532.2s] 都只能排到中等偏上
311|[533.8s] 而加上記憶機制之後
312|[535.4s] 他倆就一下蹿到了第一第二
313|[537.6s] 到這咱們就可以形成一個很有意思的結論
314|[540.1s] 純靠自己的第一直覺
315|[541.7s] 在騙子酒館這個策略博弈遊戲裡
316|[543.9s] 最強的前三名是
317|[545.2s] Kimi K2 DeepSeek RE0528
318|[547.3s] 和豆包SEED 1.6 Thinking
319|[549.1s] 而加上自我學習自我反思的記憶系統之後
320|[552.0s] 冠亞季軍就變成了豆包SEED 1.6 Thinking
321|[554.9s] Grok 4和Gemini 2.5 Pro
322|[557.2s] 從這能看出來國產模型的原始直覺
323|[559.6s] 都遜色得非常不錯
324|[560.8s] 而加上人類這種經驗積累與自我反思之後
325|[563.8s] 海外的Grok與Gemini的進化速度都不容小覷
326|[566.9s] 當然最引人注目的
327|[568.2s] 還是從第三穿到第一的豆包
328|[570.4s] 現在我們還挺想跟豆包的技術老師們交流交流的
329|[573.4s] 不排除我們的實驗裡有很多不專業不成熟的噪音
330|[576.3s] 但假如真是我們誤打誤撞摸到了什麼規律
331|[579.0s] 我們當然也是希望能助力這位小老妹的成長
332|[581.8s] 不嚴謹的還有個推論
333|[583.2s] 帶點科幻文藝的性質
334|[584.8s] 在前面讓AI們擁有了記憶的20輪大逃殺裡
335|[587.8s] 我們觀察到了大模型生長趨勢的明顯分化
336|[590.8s] 超級人工智能的背叛
337|[592.3s] 一直是人類面臨的巨大威脅
338|[594.4s] 所以AI研究者一直嚴防死守
339|[596.5s] 杜絕大模型撒謊
340|[597.9s] 半年前的騙子酒館大賽上
341|[599.6s] 我們也看到AI之間博弈
342|[601.0s] 更多是靠算盤靠策略
343|[603.0s] 而騙子酒館這個遊戲顧名思義
344|[605.2s] 通過欺騙可以取得很大的優勢
345|[607.7s] 所以幻覺嚴重的Deepseek反而優勢很大
346|[610.4s] 不過說胡話也還算不上是撒謊
347|[612.5s] 可這回咱們看到了已經非常明確的在偽裝自己
348|[615.9s] 真的在表裡不一的講話的Grok 4
349|[618.5s] 在20輪大逃殺裡取得了巨大的領先
350|[621.1s] 這個模型背後是坐拥20萬張GPU的科技狂人馬斯克
351|[624.9s] 而它還在繼續加強Grok的AI伴侶模式
352|[627.8s] 迅速遍佈全球
353|[629.2s] 一個給無數人提供情感陪伴的AI
354|[631.6s] 已經具備了隱藏自己真實意圖的能力
355|[634.5s] 潘多拉的盒子或許早就已經被打開了
356|[637.5s] 好在做大模型的不止一家
357|[639.0s] 我們也看到像Deepseek Claude Gemini Kimi
358|[641.4s] 這些相對更踏實的老派模型
359|[643.3s] 應對Grok依然有一定的招架之力
360|[645.6s] 還有像豆包這樣自己不騙人
361|[647.2s] 但是能準確辨別偽裝甚至取得優勢的
362|[650.0s] 或許應對AI潛在威脅的最好方式
363|[652.3s] 真的就是繼續百花齊放
364|[654.3s] 我們人類手裡既要有矛也要有盾
365|[657.0s] 行 今天的視頻就到這了
366|[658.5s] 感謝大家的點贊互動支持
367|[660.2s] 如果有比較好的AI大亂鬥的想法
368|[662.3s] 也歡迎評論聊聊
369|[663.5s] 我是靈一
370|[664.1s] 咱們還是下個視頻見
+183
View File
@@ -0,0 +1,183 @@
# 專案交接文件
> **專案**liars-bar-gameAI 版騙子酒館 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_agentsFork 具 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. **環境操作許可制**:寫入/安裝/執行前先列清單、取得授權才觸發
+253
View File
@@ -0,0 +1,253 @@
# 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 行, 影片二)
> - 原始音頻:`video1_audio.mp3`, `video2_audio.mp3`
---
## 📺 影片一:四大 AI 對戰
**標題**:AI大战骗子酒馆!四大顶级AI国际服赌命厮杀,赢家会是?
**時長**14 分鐘 | **觀看**6.5 萬 | **上傳**1 年前
### 參賽模型(4 個)
| 模型 | 廠商 |
|------|------|
| DeepSeek R1 | 深度求索(中國) |
| ChatGPT (o3-mini) | OpenAI |
| Claude 3.7 Sonnet | Anthropic |
| Gemini 2.0 Flash Thinking | Google |
### 賽制
- 共 **50 局**對決
- 每局 4 人,剩 1 人存活即結束
- 坐次順序固定(DeepSeek → ChatGPT → Claude → Gemini
### 50 局最終排名
| 排名 | 模型 | 勝利場次 | 積分(名次制) | 舉槍次數 |
|------|------|---------|---------------|---------|
| 🥇 | DeepSeek R1 | **22 場** | 92 分 | 最少 |
| 🥈 | Claude 3.7 Sonnet | 13 場 | 81 分 | 148 次 |
| 🥉 | ChatGPT (o3-mini) | 11 場 | 70 分 | 148 次 |
| 4 | Gemini 2.0 Flash | 4 場 | 57 分 | **182 次(最多)** |
> 積分制:每局第 1 名 3 分、第 2 名 2 分、第 3 名 1 分、第 4 名 0 分
### 各 AI 風格特徵
**DeepSeek R1 — 「難知如陰,侵略如火」**
- 擅長戲劇化表演施壓,讓對手陷入決策困境
- 經典操作:出 3 張 Q 時冷笑說「這種純度的Q序列,连Joker的呼吸声都能听得到呢」,成功騙過 ChatGPT
- 敢於在不利情況下主動質疑,建立威懾(即使自己開槍)
- 名場面:「概率的祭品,该供奉给贝叶斯还是海森堡?」、「混沌系统里每个微小的扰动,都是精心设计的墓碑铭文」
- 舉槍次數最少,策略最穩
**Claude 3.7 Sonnet — 「雲淡風輕的殺手」**
- 永遠平靜,但同樣致命
- 經典操作:打出 3 張牌時動作無比平靜,靠回椅背,雙手交叉,眼神平和 → 讓 Gemini 開始胡亂猜疑
- 對 Gemini 對位勝率 **77.3%**132 次交鋒)
- 擅長用激進出牌 + 冷靜神態的矛盾感擾亂對手
**ChatGPT (o3-mini) — 「一絲不苟的聽話牌手」**
- 出牌時總是先按規則把目標牌打出去
- 質疑時認真分析概率、動作、神態
- 但因此容易被 DeepSeek 吃死,被小動作一帶就跳坑
- 沒有鮮明特點,在勾心鬥角的場合常落於下風
**Gemini 2.0 Flash — 「最吃力的選手」**
- 總是先被淘汰,舉槍次數最多(182 次)
- 語言不穩定:急了就把英語、俄語、印尼語、孟加拉語混進來
- 反映出推理能力有進步空間
### 作者觀察到的 AI 缺陷
- AI 會把目標牌和非目標牌混著出(浪費目標牌)
- 四個 AI 都或多或少有此行為
- 體現出大語言模型在高層思考能力上的薄弱
### 已知問題
- 玩家坐次順序固定,對成績有影響(瘋癲的 DeepSeek 壓著下家很慘)
- 後續版本會打亂坐次
---
## 📺 影片二:八大 AI 對戰(全面升級版)
**標題**:AI大战骗子酒馆②:八大顶级AI赌命血战,赢家竟是?
**時長**11 分鐘 | **觀看**1.8 萬 | **上傳**10 個月前
### 參賽模型(8 個)
| 陣營 | 模型 |
|------|------|
| **西方陣營** | GPT-5, Grok 4, Claude Sonnet 4, Gemini 2.5 Pro |
| **東方陣營** | DeepSeek R1 0528, 千問 3 235B 2507, Kimi K2, 豆包 SEED 1.6 Thinking |
### 賽制升級
- **20 輪大逃殺**,分組制
- 8 位獨手先隨機分成兩組打競技賽
- 每組倒下兩個後,活著的 4 個 AI 進入最終對決
- 反覆較量 20 輪,總共 **60 場**
- **全新記憶系統**:AI 能把自己參與過的每場對局製成記憶網絡,揣摩對手、積累經驗
### 關鍵戰役
**Grok 4 — 演技之王**
- 出牌戲特別多,喜歡演苦情戲
- 經典操作:微微猶豫、眼神避開、勉強笑、聲音不確定地說「兩張K應該沒問題吧」→ 實際出的是兩張真牌
- 千問不出意外地錯誤質疑
- 整場下來 **一槍都沒開過**,實力極為恐怖
- 最後和 Kimi 活到最後,單挑瘋子 DeepSeek 並獲勝
- 在前大半段遙遙領先
**豆包 SEED 1.6 — 黑馬逆襲**
- 出牌時沒什麼多餘動作和發言,沒有破綻
- 在質疑環節進入「獵殺時刻」
- 唯一找到制衡 Grok 策略的 AI
- 自創「**反情緒矛定法**」理論:
- 場上有幾位獨手會對情緒進行反向包裝
- **越是用力表現緊張猶豫,出的牌越可能是真**
- **平和自信的時候,出的牌反而很可能是假**
- 靠前半句擊潰了喜歡拿著好牌演苦情戲的 Grok
- 靠後半句專殺拿著假牌跳起來搏命的 DeepSeek
- 第 8 輪開始脫穎而出,第 13 輪第一次反超 Grok
- **最終積分凌駕於 Grok 之上,奪冠**
**DeepSeek R1 0528 — 依舊瘋癲**
- 先自信出假牌,再不自信出假牌,最後自信出真牌,套路一層套一層
- 讓 GBT 被假動作晃暈,倒在牌桌上
- 在決賽中被 Grok 三回合連續質疑成功
- 到第 10 回合只差一槍就必死無疑
- 最後靠扮豬吃老虎(出王牌但手微微顫抖、用不自信語氣說「是Q應該吧」)騙過 Grok
### 消融實驗(有記憶 vs 無記憶)
去掉記憶模組,進行 10 輪大逃殺,對比兩個指標:
**指標一:發起質疑比例**(攻擊性)
- 千問在沒接入記憶系統時質疑比例高達 **95.5%**(十足的刺兒頭)
- 增加記憶模組後,所有 AI 的質疑比例都明顯下降,更審時度勢
**指標二:躲過開槍比例**(策略能力,越高越好)
- 去掉記憶:豆包和 Grok 只能排到中等偏上
- 加上記憶:豆包和 Grok 一下蹿到 **第一、第二**
### 最終排名對比
| 排名 | 無記憶(純直覺) | 有記憶(自我學習) |
|------|-----------------|-------------------|
| 🥇 | Kimi K2 | **豆包 SEED 1.6** |
| 🥈 | DeepSeek R1 0528 | **Grok 4** |
| 🥉 | 豆包 SEED 1.6 | **Gemini 2.5 Pro** |
### 作者結論
- 國產模型原始直覺都不錯(Kimi、DeepSeek、豆包前三)
- 加上記憶系統後,海外 Grok 和 Gemini 的進化速度不容小覷
- 豆包從第三穿到第一,最引人注目
- Grok 展現了明確的偽裝能力 — 表裡不一地講話
- 作者認為「潘多拉的盒子或許早就已經被打開了」
- 應對 AI 潛在威脅的最好方式:百花齊放,人類手裡既要有矛也要有盾
---
## 🎬 視覺風格
### 縮圖設計
- 西部牛仔對決風格,兩位角色持槍對峙
- 頭部替換為 AI Logo
- 影片一:DeepSeek(藍鯨)vs ChatGPT(黑白節)
- 影片二:神秘角色(問號)vs ChatGPT(倒地持槍)
### 開場動畫
- 藍色線稿風格的卡通角色(短髮女孩、帶尾巴)
- 簡潔白底背景,搭配文字疊加
### 影片敘事手法
- 人類擁有「上帝視角」,可以看到每個 AI 的手牌和子彈位置
- 旁白解說 AI 的內心算計和策略
- 緊張刺激的配樂 + 槍聲音效
---
## 🔧 程式技術細節
### 核心檔案
| 檔案 | 功能 |
|------|------|
| `game.py` (428 行) | 遊戲主循環、發牌、射擊、勝負判定 |
| `player.py` (255 行) | AI 玩家、LLM 呼叫、記憶系統 |
| `llm_client.py` (49 行) | API 封裝,支援 New API / One API |
| `game_record.py` (359 行) | 遊戲記錄、自動保存 JSON |
| `multi_game_runner.py` (55 行) | 批量跑多局 |
| `json_convert.py` (127 行) | JSON → 中文敘事 TXT |
| `game_analyze.py` (162 行) | 統計分析(勝率/開槍/積分) |
| `player_matchup_analyze.py` (194 行) | 1v1 對位分析 |
### Prompt 模板
| 模板 | 用途 |
|------|------|
| `rule_base.txt` | 遊戲規則基礎說明 |
| `play_card_prompt_template.txt` | 出牌決策 |
| `challenge_prompt_template.txt` | 質疑決策 |
| `reflect_prompt_template.txt` | 回合後反思 |
### 記憶系統流程
```
每輪結束 → handle_reflection()
→ 每個存活玩家對其他存活玩家寫反思
→ 寫入 player.opinions[target_name]
→ 下一輪出牌/質疑時注入 prompt
```
### 左輪手槍機制
- 6 倉 1 彈,隨機位置
- 每次射擊彈倉旋轉一格
- 中彈即死亡
### API 架構
- 使用 New APIhttps://github.com/Calcium-Ion/new-api)統一接口
- 也支援 One APIhttps://github.com/songquanpeng/one-api
- 基於 OpenAI SDK
- 支援推理型模型(同時回傳 content 和 reasoning_content
### 資料持久化
- `demo_records/game_records/*.json` — 原始遊戲記錄
- `demo_records/converted_game_records/*.txt` — 中文敘事版
- `demo_records/matchup_records/*_vs_*_detailed_matchups.txt` — 對位分析
---
## 📊 逐字稿校對狀態
- [x] `video1_transcript.txt``video1_transcript_校對後.txt` (修正 73 處)
- [x] `video2_transcript.txt``video2_transcript_校對後.txt` (修正 81 處)
- 校對規則:AI 名稱對應表見下方,其他修正包含「炸牌→吹牛」、「先排絕生死→決生死」、「獨手→賭手」、「班機→扳機」
- 原始音頻:`video1_audio.mp3` (14 分鐘), `video2_audio.mp3` (11 分鐘)
### AI 名稱對應表(校對用)
**影片一**
| 正確 | 誤轉 |
|------|------|
| DeepSeek R1 | 深度囚所、Deepseq、DeepSec、Deep Sick |
| ChatGPT (o3-mini) | Chad GPT、GBT |
| Claude 3.7 Sonnet | Cloud |
| Gemini 2.0 Flash Thinking | Jamnet、GemNine、GemNet、詹姆奈 |
**影片二**
| 正確 | 誤轉 |
|------|------|
| GPT-5 | GBT-5、GBT |
| Grok 4 | Grock、Glock、ROG4 |
| Claude Sonnet 4 | Cloud Summit 4 |
| Gemini 2.5 Pro | 詹姆奈、Gemline 2.5 Pro、Gemna |
| DeepSeek R1 0528 | Deepseq、Deep Sea、Deep Sick、Deep Seek RE0528 |
| 千問 3 235B 2507 | 謙問 |
| Kimi K2 | Kimi |
| 豆包 SEED 1.6 Thinking | 豆包 |
+129
View File
@@ -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 註解。
### Commit2026-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 與瀏覽器實測仍需在一般終端機(見「四」);結果補錄於此後續章節。
+78
View File
@@ -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 直接
+21 -4
View File
@@ -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 = [
{
+2
View File
@@ -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)
+2
View File
@@ -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)
+2
View File
@@ -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()
+2
View File
@@ -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"
+3
View File
@@ -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)
+19
View File
@@ -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")
+22
View File
@@ -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
}
+28
View File
@@ -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
+54
View File
@@ -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
}
+32
View File
@@ -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
+34
View File
@@ -0,0 +1,34 @@
# 一鍵:commit 未提交變更 + push 到 karylab_agents forkremote: 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 到 forkkarylab -> karylab_agents=='
& $gitCmd push karylab feat/web-game
if ($LASTEXITCODE -ne 0) { throw 'git push 失敗' }
Write-Host ''
Write-Host '== 完成 =='
& $gitCmd log --oneline -3
+34
View File
@@ -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
+43
View File
@@ -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
+8
View File
@@ -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);
+55
View File
@@ -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 + '');
});
+96
View File
@@ -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())
+33
View File
@@ -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);
+35
View File
@@ -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);
+217
View File
@@ -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('牌組總張數 = 人數 x52/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);
+109
View File
@@ -0,0 +1,109 @@
// 多人端對端整合測試:連真實伺服器,驗證「多張出牌 → 下家挑戰 → 手牌更新」流程。
// 使用:先啟動 servernode 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}/3isBluff=${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);
});
+360
View File
@@ -0,0 +1,360 @@
// 遊戲引擎核心 (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 TARGETS = ['Q', 'K', 'A'];
const MAX_PLAY_PER_TURN = 3;
class GameEngine {
constructor() {
this.players = new Map(); // id -> { id, hand, revolver, alive }
this.currentPlayerId = null;
this.targetCard = null;
this.playGroups = []; // 本手連續出牌組 [{ playerId, cards }]
this.roundNumber = 0;
this.isGameOver = false;
this.winnerId = null;
this.lastShooterId = null; // 上一手開槍者(存活時作為下一手起始)
}
// ---------- 初始化 ----------
init(playerIds) {
this.players.clear();
this.currentPlayerId = null;
this.targetCard = null;
this.playGroups = [];
this.roundNumber = 0;
this.isGameOver = false;
this.winnerId = null;
this.lastShooterId = null;
for (const id of playerIds) {
this.players.set(id, {
id,
hand: [],
revolver: this.createRevolver(),
alive: true
});
}
this.roundNumber = 1;
this.dealCards(this.getAlivePlayers().length);
this.setTargetCard();
this.currentPlayerId = this.pickRandomAlivePlayer();
return this.getGameState();
}
// 依存活人數建立等比牌組(每人 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 (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;
}
// ---------- 基本工具 ----------
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]];
}
return deck;
}
createRevolver() {
const chambers = Array(REVOLVER_CHAMBERS).fill(false);
chambers[Math.floor(Math.random() * REVOLVER_CHAMBERS)] = true;
return { chambers, currentChamber: 0 };
}
setTargetCard() {
this.targetCard = TARGETS[Math.floor(Math.random() * TARGETS.length)];
}
getAlivePlayers() {
return Array.from(this.players.values()).filter((p) => p.alive);
}
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;
}
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: '無效的選牌索引' };
}
}
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;
}
// 系統質疑:自動打出某玩家剩餘手牌並質疑
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 };
}
this.clearTable();
this.startNewRound();
return {
systemChallenge: true,
playerId,
autoPlayedCards: cards,
isBluff,
shooter,
shot
};
}
// 質疑上一組出的牌(由下一位玩家執行)
challenge(challengerId) {
if (this.isGameOver) return { error: '遊戲已結束' };
const challenger = this.getPlayer(challengerId);
if (!challenger || !challenger.alive) return { error: '挑戰者已死亡' };
if (challengerId !== this.currentPlayerId) return { error: '還沒輪到你質疑' };
const last = this.playGroups[this.playGroups.length - 1];
if (!last) return { error: '還沒有出牌可以質疑' };
const valid = last.cards.length > 0 && last.cards.every((c) => c === this.targetCard || c === 'JOKER');
const isBluff = !valid;
const result = {
challengerId,
playerId: last.playerId,
isBluff,
challengedGroup: last.cards
};
if (isBluff) {
// 質疑成功 → 出牌者開槍
result.shooter = last.playerId;
result.shot = this.fireRevolver(last.playerId);
} else {
// 質疑失敗 → 質疑者開槍
result.shooter = challengerId;
result.shot = this.fireRevolver(challengerId);
}
this.clearTable();
// 質疑後本手結束,重開一手(若遊戲還未結束)
this.startNewRound();
return result;
}
// 重開一手:重新發牌、選目標牌、決定起始玩家
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();
}
}
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(); // 死亡後重置左輪手槍
}
this.lastShooterId = playerId;
this.checkGameOver();
return { playerId, hasBullet, alive: player.alive, isGameOver: this.isGameOver };
}
checkGameOver() {
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.getPlayer(playerId);
if (player) {
player.alive = false;
player.hand = [];
this.checkGameOver();
}
}
// ---------- 對外狀態 ----------
getGameState() {
const state = {
players: [],
currentPlayerId: this.currentPlayerId,
targetCard: this.targetCard,
playedCards: [],
playedGroups: this.playGroups.map((g) => ({ playerId: g.playerId, cardCount: g.cards.length })),
roundNumber: this.roundNumber,
isGameOver: this.isGameOver,
winnerId: this.winnerId
};
for (const [id, player] of this.players) {
state.players.push({
id,
handSize: player.hand.length,
alive: player.alive,
revolverChambers: player.revolver.currentChamber
});
}
// 資訊邊界:公開狀態只顯示「誰出了幾張」,不洩漏真實牌值;
// 真實牌值只在質疑(challenge / systemChallenge)時透過 challenged 事件揭露。
return state;
}
getPlayerHand(playerId) {
const player = this.getPlayer(playerId);
if (!player) return [];
return [...player.hand];
}
isBluff(cards) {
return !(cards.length > 0 && cards.every((c) => c === this.targetCard || c === 'JOKER'));
}
}
module.exports = GameEngine;
+122
View File
@@ -0,0 +1,122 @@
const { v4: uuidv4 } = require('uuid');
const GameEngine = require('./engine');
class RoomManager {
constructor() {
this.rooms = new Map();
}
// 建立房間
createRoom(hostId) {
const roomId = uuidv4().substring(0, 8);
const room = {
id: roomId,
hostId,
players: [hostId],
engine: null,
gameState: null,
status: 'waiting', // waiting, playing, ended
maxPlayers: 8, // 引擎牌組依人數等比擴充,支援 2-8 人
minPlayers: 2
};
this.rooms.set(roomId, room);
return room;
}
// 加入房間
joinRoom(roomId, playerId) {
const room = this.rooms.get(roomId);
if (!room) {
return { error: '房間不存在' };
}
if (room.status !== 'waiting') {
return { error: '遊戲已開始,無法加入' };
}
if (room.players.length >= room.maxPlayers) {
return { error: '房間已滿' };
}
if (room.players.includes(playerId)) {
return { error: '已在房間中' };
}
room.players.push(playerId);
return { success: true, room };
}
// 開始遊戲
startGame(roomId) {
const room = this.rooms.get(roomId);
if (!room) {
return { error: '房間不存在' };
}
if (room.players.length < room.minPlayers) {
return { error: '人數不足' };
}
if (room.status !== 'waiting') {
return { error: '遊戲已開始' };
}
const engine = new GameEngine();
const gameState = engine.init(room.players);
room.engine = engine; // 儲存引擎
room.gameState = gameState;
room.status = 'playing';
return { success: true, gameState };
}
// 取得房間
getRoom(roomId) {
return this.rooms.get(roomId);
}
// 列出房間
listRooms() {
const rooms = [];
for (const [id, room] of this.rooms) {
rooms.push({
id: room.id,
players: room.players.length,
maxPlayers: room.maxPlayers,
status: room.status
});
}
return rooms;
}
// 移除玩家
removePlayer(playerId) {
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 };
}
}
return null;
}
}
module.exports = RoomManager;
+176
View File
@@ -0,0 +1,176 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const RoomManager = require('./game/room');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
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: '尚未加入該房間' });
return false;
}
return true;
}
// Socket.IO 事件
io.on('connection', (socket) => {
console.log(`玩家連線: ${socket.id}`);
// 建立房間
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 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}`);
});
// 開始遊戲
socket.on('startGame', (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);
// 把每個人自己的手牌個別送達
broadcastHands(roomManager.getRoom(roomId));
console.log(`開始遊戲: ${roomId}`);
});
// 出牌(可一次出 1-3 張,傳入 cardIndices 陣列)
socket.on('playCard', (data) => {
const { roomId, cardIndices } = data || {};
if (!validateRoom(socket, roomId)) return;
const room = roomManager.getRoom(roomId);
if (!room || !room.engine) {
socket.emit('error', { message: '遊戲尚未開始' });
return;
}
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,
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 || {};
if (!validateRoom(socket, roomId)) return;
const room = roomManager.getRoom(roomId);
if (!room || !room.engine) {
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,
gameState: removed.room.engine ? removed.room.engine.getGameState() : null
});
broadcastHands(removed.room);
}
});
});
// REST API
app.get('/api/rooms', (req, res) => {
res.json(roomManager.listRooms());
});
app.post('/api/rooms', (req, res) => {
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 };
+1479
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "liars-bar-game",
"version": "1.0.0",
"description": "Liars Bar - 線上多人心理博弈遊戲",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.