767 lines
28 KiB
Python
767 lines
28 KiB
Python
"""
|
|
斗地主 AI 适配器实现
|
|
支持规则AI和DeepSeek大模型AI
|
|
"""
|
|
import os
|
|
import json
|
|
import asyncio
|
|
import aiohttp
|
|
from typing import Dict, List, Any, Optional
|
|
from collections import Counter
|
|
|
|
import sys
|
|
import os as os_module
|
|
sys.path.insert(0, os_module.path.dirname(os_module.path.dirname(os_module.path.abspath(__file__))))
|
|
|
|
from engine.ai_adapter import AIAdapter, RuleBasedAdapter, LLMAdapter
|
|
from doudizhu.game_logic import (
|
|
Card, identify_card_type, can_beat, evaluate_hand_strength,
|
|
find_cards_by_ids, Deck, CardType
|
|
)
|
|
|
|
# 加载斗地主模型配置文件(支持 DeepSeek 等外部模型)
|
|
def _load_doudizhu_config():
|
|
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'model_config.json')
|
|
if os.path.exists(config_path):
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
DOUDIZHU_CONFIG = _load_doudizhu_config()
|
|
|
|
|
|
class DoudizhuRuleAI(RuleBasedAdapter):
|
|
"""
|
|
斗地主基于规则的AI
|
|
从现有JS代码迁移
|
|
"""
|
|
|
|
def __init__(self, config: Optional[Dict] = None):
|
|
super().__init__(config or {})
|
|
self.ai_type = 'doudizhu_rule'
|
|
|
|
def get_name(self) -> str:
|
|
return "斗地主规则AI"
|
|
|
|
def want_to_be_lord(self, cards: List[Card]) -> bool:
|
|
"""决定是否叫地主"""
|
|
score = evaluate_hand_strength(cards)
|
|
return score >= 8 # 分数>=8才当地主
|
|
|
|
def find_all_valid_plays(self, cards: List[Card], desktop_type: Optional[Dict]) -> List[Dict]:
|
|
"""找出所有可出的牌型"""
|
|
valid_plays = []
|
|
n = len(cards)
|
|
|
|
# 辅助函数:生成所有组合
|
|
def get_combinations(arr, k):
|
|
if k == 0:
|
|
return [[]]
|
|
if len(arr) < k:
|
|
return []
|
|
result = []
|
|
for i in range(len(arr) - k + 1):
|
|
sub_combos = get_combinations(arr[i + 1:], k - 1)
|
|
for sub in sub_combos:
|
|
result.append([arr[i]] + sub)
|
|
return result
|
|
|
|
# 尝试不同数量的牌(限制在合理范围内)
|
|
for k in range(1, min(n + 1, 9)): # 最多8张
|
|
combos = get_combinations(cards, k)
|
|
for combo in combos:
|
|
card_type = identify_card_type(combo)
|
|
if card_type and can_beat(desktop_type, card_type):
|
|
valid_plays.append({
|
|
'cards': combo,
|
|
'type': card_type,
|
|
'card_ids': [c.id for c in combo]
|
|
})
|
|
|
|
# 炸弹单独处理(可以管任何非炸弹)
|
|
value_count = {}
|
|
for c in cards:
|
|
value_count[c.value] = value_count.get(c.value, 0) + 1
|
|
|
|
for value, count in value_count.items():
|
|
if count == 4:
|
|
bomb_cards = [c for c in cards if c.value == value]
|
|
bomb_type = identify_card_type(bomb_cards)
|
|
if bomb_type and can_beat(desktop_type, bomb_type):
|
|
# 去重
|
|
bomb_ids = set(c.id for c in bomb_cards)
|
|
is_duplicate = any(
|
|
set(c.id for c in play['cards']) == bomb_ids
|
|
for play in valid_plays
|
|
)
|
|
if not is_duplicate:
|
|
valid_plays.append({
|
|
'cards': bomb_cards,
|
|
'type': bomb_type,
|
|
'card_ids': [c.id for c in bomb_cards]
|
|
})
|
|
|
|
# 王炸
|
|
jokers = [c for c in cards if c.is_joker]
|
|
if len(jokers) == 2:
|
|
joker_type = identify_card_type(jokers)
|
|
if joker_type and can_beat(desktop_type, joker_type):
|
|
valid_plays.append({
|
|
'cards': jokers,
|
|
'type': joker_type,
|
|
'card_ids': [c.id for c in jokers]
|
|
})
|
|
|
|
return valid_plays
|
|
|
|
# ───────────────────────── 辅助函数 ─────────────────────────
|
|
|
|
def _estimate_min_rounds(self, cards: List[Card]) -> int:
|
|
"""估算最少需要几轮出完(简化版)"""
|
|
if not cards:
|
|
return 0
|
|
n = len(cards)
|
|
value_count = Counter(c.value for c in cards)
|
|
|
|
rounds = 0
|
|
# 王炸
|
|
if sum(1 for c in cards if c.is_joker) == 2:
|
|
rounds += 1
|
|
n -= 2
|
|
|
|
# 炸弹
|
|
for v, c in list(value_count.items()):
|
|
if c == 4:
|
|
rounds += 1
|
|
n -= 4
|
|
value_count[v] = 0
|
|
|
|
# 飞机(连续三张)
|
|
triples = sorted([v for v, c in value_count.items() if c >= 3 and v <= 14])
|
|
if len(triples) >= 2:
|
|
streak = 1
|
|
for i in range(1, len(triples)):
|
|
if triples[i] == triples[i - 1] + 1:
|
|
streak += 1
|
|
else:
|
|
break
|
|
if streak >= 2:
|
|
rounds += 1
|
|
n -= streak * 3
|
|
for v in triples[:streak]:
|
|
value_count[v] -= 3
|
|
|
|
# 连对
|
|
pairs = sorted([v for v, c in value_count.items() if c >= 2 and v <= 14])
|
|
if len(pairs) >= 3:
|
|
streak = 1
|
|
for i in range(1, len(pairs)):
|
|
if pairs[i] == pairs[i - 1] + 1:
|
|
streak += 1
|
|
else:
|
|
break
|
|
if streak >= 3:
|
|
rounds += 1
|
|
n -= streak * 2
|
|
for v in pairs[:streak]:
|
|
value_count[v] -= 2
|
|
|
|
# 顺子
|
|
unique = sorted([v for v, c in value_count.items() if c > 0 and v <= 14])
|
|
if len(unique) >= 5:
|
|
streak = 1
|
|
max_streak = 1
|
|
for i in range(1, len(unique)):
|
|
if unique[i] == unique[i - 1] + 1:
|
|
streak += 1
|
|
max_streak = max(max_streak, streak)
|
|
else:
|
|
streak = 1
|
|
if max_streak >= 5:
|
|
rounds += 1
|
|
n -= max_streak
|
|
|
|
# 剩余牌按最紧凑估算
|
|
if n > 0:
|
|
rounds += max(1, (n + 1) // 2)
|
|
return rounds
|
|
|
|
def _calculate_break_cost(self, cards: List[Card], play_cards: List[Card]) -> float:
|
|
"""计算拆牌代价:出了这些牌后,是否拆了原本的组合"""
|
|
play_values = {c.value for c in play_cards}
|
|
all_values = [c.value for c in cards]
|
|
cost = 0.0
|
|
|
|
# 检查是否拆了顺子
|
|
sorted_unique = sorted(set(v for v in all_values if v <= 14))
|
|
if len(sorted_unique) >= 5:
|
|
# 找最长连续序列
|
|
streaks = []
|
|
current = [sorted_unique[0]]
|
|
for v in sorted_unique[1:]:
|
|
if v == current[-1] + 1:
|
|
current.append(v)
|
|
else:
|
|
if len(current) >= 5:
|
|
streaks.append(list(current))
|
|
current = [v]
|
|
if len(current) >= 5:
|
|
streaks.append(current)
|
|
|
|
for streak in streaks:
|
|
removed = play_values & set(streak)
|
|
if removed and len(removed) < len(streak):
|
|
cost += len(removed) * 2.0
|
|
|
|
# 检查是否拆了连对
|
|
value_count = Counter(all_values)
|
|
pair_values = sorted([v for v, c in value_count.items() if c >= 2 and v <= 14])
|
|
if len(pair_values) >= 3:
|
|
current = [pair_values[0]]
|
|
for v in pair_values[1:]:
|
|
if v == current[-1] + 1:
|
|
current.append(v)
|
|
else:
|
|
if len(current) >= 3:
|
|
removed = play_values & set(current)
|
|
if removed and len(removed) < len(current):
|
|
cost += len(removed) * 3.0
|
|
current = [v]
|
|
if len(current) >= 3:
|
|
removed = play_values & set(current)
|
|
if removed and len(removed) < len(current):
|
|
cost += len(removed) * 3.0
|
|
|
|
# 检查是否拆了飞机(连续三张)
|
|
triple_values = sorted([v for v, c in value_count.items() if c >= 3 and v <= 14])
|
|
if len(triple_values) >= 2:
|
|
current = [triple_values[0]]
|
|
for v in triple_values[1:]:
|
|
if v == current[-1] + 1:
|
|
current.append(v)
|
|
else:
|
|
if len(current) >= 2:
|
|
removed = play_values & set(current)
|
|
if removed and len(removed) < len(current):
|
|
cost += len(removed) * 4.0
|
|
current = [v]
|
|
if len(current) >= 2:
|
|
removed = play_values & set(current)
|
|
if removed and len(removed) < len(current):
|
|
cost += len(removed) * 4.0
|
|
|
|
return cost
|
|
|
|
def _evaluate_remaining_shape(self, cards: List[Card]) -> float:
|
|
"""评估剩余手牌的牌型质量(越高越好)"""
|
|
if not cards:
|
|
return 100.0
|
|
quality = 0.0
|
|
value_count = Counter(c.value for c in cards)
|
|
|
|
# 炸弹/王炸
|
|
for c in value_count.values():
|
|
if c == 4:
|
|
quality += 10.0
|
|
jokers = [c for c in cards if c.is_joker]
|
|
if len(jokers) == 2:
|
|
quality += 20.0
|
|
|
|
# 2 和王
|
|
for c in cards:
|
|
if c.value == 15:
|
|
quality += 3.0
|
|
elif c.value >= 16:
|
|
quality += 5.0
|
|
|
|
# 顺子潜力
|
|
unique = sorted(set(v for v in value_count.keys() if v <= 14))
|
|
streaks = 0
|
|
cur = 0
|
|
for i, v in enumerate(unique):
|
|
if i == 0 or v == unique[i - 1] + 1:
|
|
cur += 1
|
|
else:
|
|
if cur >= 3:
|
|
streaks += cur
|
|
cur = 1
|
|
if cur >= 3:
|
|
streaks += cur
|
|
quality += streaks * 0.8
|
|
|
|
# 对子/三张
|
|
pairs = sum(1 for c in value_count.values() if c >= 2)
|
|
triples = sum(1 for c in value_count.values() if c >= 3)
|
|
quality += pairs * 1.5 + triples * 3.0
|
|
|
|
return quality
|
|
|
|
def _evaluate_play(self, play: Dict, cards: List[Card],
|
|
is_lord: bool, teammate_cards: int,
|
|
desktop_type: Optional[Dict]) -> float:
|
|
"""评估一个出牌方案的得分,越高越好"""
|
|
play_card_ids = {c.id for c in play['cards']}
|
|
remaining = [c for c in cards if c.id not in play_card_ids]
|
|
remaining_count = len(remaining)
|
|
play_count = len(play['cards'])
|
|
play_type = play['type']['type']
|
|
play_value = play['type']['value']
|
|
|
|
score = 0.0
|
|
|
|
# 1. 能出完 → 最高优先级
|
|
if remaining_count == 0:
|
|
return 20000.0
|
|
|
|
# 2. 出得多比出得少好(减少轮数)
|
|
score += play_count * 5.0
|
|
|
|
# 3. 剩余轮数估算:轮数越少越好
|
|
my_rounds = self._estimate_min_rounds(remaining)
|
|
score -= my_rounds * 30.0
|
|
|
|
# 4. 牌值越小越好(保留大牌)
|
|
score -= play_value * 0.8
|
|
|
|
# 5. 拆牌代价
|
|
break_cost = self._calculate_break_cost(cards, play['cards'])
|
|
score -= break_cost * 8.0
|
|
|
|
# 6. 剩余牌质量
|
|
remaining_quality = self._evaluate_remaining_shape(remaining)
|
|
score += remaining_quality * 1.5
|
|
|
|
# 7. 炸弹特殊处理
|
|
if play_type in ('bomb', 'joker_bomb'):
|
|
if remaining_count <= 2:
|
|
score += 800 # 快出完了,果断炸
|
|
elif teammate_cards <= 2 and not is_lord:
|
|
score += 800 # 队友快走完了,炸地主
|
|
elif not is_lord and not desktop_type:
|
|
score -= 300 # 农民且没人出牌,不要主动炸
|
|
else:
|
|
score -= 100 # 一般情况,不优先炸
|
|
|
|
# 8. 角色策略
|
|
if is_lord:
|
|
# 地主:优先出对方难接的长牌型
|
|
if play_type == 'straight' and play_count >= 5:
|
|
score += play_count * 3.0
|
|
elif play_type == 'double_straight':
|
|
score += play_count * 2.5
|
|
elif play_type == 'triple_straight':
|
|
score += play_count * 3.5
|
|
elif play_type in ('triple_with_single', 'triple_with_pair'):
|
|
score += 5.0
|
|
else:
|
|
# 农民:队友牌少时让牌,桌面有牌时要帮管
|
|
if teammate_cards <= 3:
|
|
if not desktop_type:
|
|
score -= 300 # 不要主动抢节奏
|
|
elif desktop_type:
|
|
score += 150 # 队友快走完了,积极管牌
|
|
|
|
# 9. 管牌时:尽量用刚好能管的最小组合,优先同类型
|
|
if desktop_type:
|
|
desktop_type_str = desktop_type['type']
|
|
if play_type == desktop_type_str:
|
|
score += 25 # 同类型优先,不浪费牌型
|
|
elif play_type in ('bomb', 'joker_bomb'):
|
|
# 用炸弹管,前面已处理
|
|
pass
|
|
else:
|
|
score -= 10 # 跨类型管牌(如用对子管单张),轻微惩罚
|
|
|
|
return score
|
|
|
|
def choose_best_play(self, valid_plays: List[Dict], cards: List[Card],
|
|
is_lord: bool, teammate_cards: int,
|
|
desktop_type: Optional[Dict] = None) -> Optional[Dict]:
|
|
"""选择最佳出牌(多维度评分)"""
|
|
if not valid_plays:
|
|
return None
|
|
|
|
# 炸弹/王炸单独收集,最后决策
|
|
bombs = [p for p in valid_plays if p['type']['type'] in ('bomb', 'joker_bomb')]
|
|
non_bombs = [p for p in valid_plays if p['type']['type'] not in ('bomb', 'joker_bomb')]
|
|
|
|
best = None
|
|
best_score = -float('inf')
|
|
|
|
# 先评估非炸弹
|
|
for play in non_bombs:
|
|
score = self._evaluate_play(play, cards, is_lord, teammate_cards, desktop_type)
|
|
if score > best_score:
|
|
best_score = score
|
|
best = play
|
|
|
|
# 炸弹决策:只在必要时才考虑
|
|
if bombs:
|
|
# 判断是否该出炸弹
|
|
should_bomb = False
|
|
remaining_count = len(cards)
|
|
|
|
if remaining_count <= 2:
|
|
should_bomb = True
|
|
elif teammate_cards <= 2 and not is_lord:
|
|
should_bomb = True
|
|
elif desktop_type:
|
|
# 桌面牌很大,没有非炸弹能管,必须炸
|
|
if not best or best_score < 0:
|
|
should_bomb = True
|
|
|
|
if should_bomb:
|
|
# 选最小的炸弹
|
|
bombs.sort(key=lambda p: p['type']['value'])
|
|
bomb = bombs[0]
|
|
bomb_score = self._evaluate_play(bomb, cards, is_lord, teammate_cards, desktop_type)
|
|
if bomb_score > best_score:
|
|
best = bomb
|
|
|
|
return best
|
|
|
|
async def make_decision_calling(self, cards: List[Card]) -> Dict[str, Any]:
|
|
"""叫地主决策"""
|
|
want = self.want_to_be_lord(cards)
|
|
return {
|
|
'action': 'call' if want else 'pass',
|
|
'data': {'want': want},
|
|
'reasoning': f'手牌强度评分: {evaluate_hand_strength(cards)}',
|
|
'confidence': 0.8 if want else 0.9
|
|
}
|
|
|
|
async def make_decision_playing(self, cards: List[Card],
|
|
desktop_type: Optional[Dict],
|
|
is_lord: bool,
|
|
teammate_cards: int) -> Dict[str, Any]:
|
|
"""出牌决策(优化版)"""
|
|
valid_plays = self.find_all_valid_plays(cards, desktop_type)
|
|
|
|
if not valid_plays:
|
|
return {
|
|
'action': 'pass',
|
|
'data': {},
|
|
'reasoning': '没有能管上的牌',
|
|
'confidence': 1.0
|
|
}
|
|
|
|
# 农民让牌逻辑:队友快走完了且自己不能一轮出完,不要主动出牌
|
|
if not is_lord and not desktop_type and teammate_cards <= 3:
|
|
my_rounds = self._estimate_min_rounds(cards)
|
|
if my_rounds > 1:
|
|
return {
|
|
'action': 'pass',
|
|
'data': {},
|
|
'reasoning': f'队友剩{teammate_cards}张牌,让队友先走',
|
|
'confidence': 0.9
|
|
}
|
|
|
|
best_play = self.choose_best_play(valid_plays, cards, is_lord, teammate_cards, desktop_type)
|
|
|
|
if best_play:
|
|
play_name = best_play['type']['name']
|
|
remaining = len(cards) - len(best_play['cards'])
|
|
reasoning = f'出 {play_name},剩余{remaining}张牌'
|
|
|
|
# 根据决策补充理由
|
|
if best_play['type']['type'] in ('bomb', 'joker_bomb'):
|
|
reasoning = f'炸弹!{reasoning}'
|
|
elif not is_lord and teammate_cards <= 3:
|
|
reasoning = f'帮队友管牌:{reasoning}'
|
|
elif is_lord and best_play['type']['type'] in ('straight', 'double_straight', 'triple_straight'):
|
|
reasoning = f'地主出长牌型逼拆:{reasoning}'
|
|
|
|
return {
|
|
'action': 'play',
|
|
'data': {'card_ids': best_play['card_ids']},
|
|
'reasoning': reasoning,
|
|
'confidence': 0.88
|
|
}
|
|
|
|
return {
|
|
'action': 'pass',
|
|
'data': {},
|
|
'reasoning': '选择不出',
|
|
'confidence': 0.7
|
|
}
|
|
|
|
async def make_decision(self, game_state: Dict[str, Any],
|
|
available_actions: List[str],
|
|
player_context: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""AI 做出决策"""
|
|
start_time = asyncio.get_event_loop().time()
|
|
|
|
# 解析上下文
|
|
phase = game_state.get('phase') or game_state.get('data', {}).get('phase')
|
|
cards_data = player_context.get('cards', [])
|
|
cards = [Card(**c) for c in cards_data]
|
|
|
|
is_lord = player_context.get('is_lord', False)
|
|
teammate_cards = player_context.get('teammate_cards', 17)
|
|
|
|
# desktop_cards 可能在 game_state['doudizhu'] 或 game_state['data'] 中
|
|
desktop_type = None
|
|
if 'doudizhu' in game_state:
|
|
desktop_type = game_state['doudizhu'].get('desktop_cards')
|
|
elif 'data' in game_state:
|
|
desktop_type = game_state['data'].get('desktop_cards')
|
|
|
|
# 决策
|
|
if 'call' in available_actions or ('calling' in str(phase) and 'pass' in available_actions):
|
|
result = await self.make_decision_calling(cards)
|
|
else:
|
|
result = await self.make_decision_playing(cards, desktop_type, is_lord, teammate_cards)
|
|
|
|
# 记录调用时间
|
|
latency = (asyncio.get_event_loop().time() - start_time) * 1000
|
|
self.record_call(latency)
|
|
|
|
return result
|
|
|
|
|
|
class DoudizhuDeepSeekAI(LLMAdapter):
|
|
"""
|
|
斗地主 DeepSeek 大模型 AI
|
|
调用 DeepSeek API 做决策
|
|
"""
|
|
|
|
def __init__(self, api_key: str = None, config: Optional[Dict] = None):
|
|
file_config = DOUDIZHU_CONFIG.get('deepseek', {})
|
|
config = config or {}
|
|
# 优先级:传入参数 > 配置文件 > 环境变量 > 硬编码默认值
|
|
final_api_key = api_key or file_config.get('api_key') or os.getenv('DEEPSEEK_API_KEY', '')
|
|
final_model = config.get('model') or file_config.get('model') or 'deepseek-chat'
|
|
final_api_url = config.get('api_url') or file_config.get('api_url') or 'https://api.deepseek.com/v1/chat/completions'
|
|
super().__init__(
|
|
model=final_model,
|
|
api_key=final_api_key,
|
|
api_url=final_api_url,
|
|
config=config
|
|
)
|
|
self.ai_type = 'doudizhu_deepseek'
|
|
|
|
def get_name(self) -> str:
|
|
return f"斗地主DeepSeek({self.model})"
|
|
|
|
def _build_prompt_calling(self, cards: List[Card]) -> str:
|
|
"""构建叫地主提示词"""
|
|
cards_str = ', '.join([f"{c.suit}{c.rank}" for c in cards])
|
|
score = evaluate_hand_strength(cards)
|
|
|
|
return f"""你是一个斗地主高手。请根据你的手牌决定是否叫地主。
|
|
|
|
你的手牌(共17张):
|
|
{cards_str}
|
|
|
|
手牌强度评分: {score}/30 (分数越高手牌越好)
|
|
评分标准:
|
|
- K=1分, A=2分, 2=3分, 王=4分
|
|
- 炸弹额外+6分, 王炸额外+10分
|
|
|
|
规则:
|
|
- 如果叫地主,你将获得3张底牌,总共20张牌
|
|
- 地主需要对抗两个农民
|
|
- 地主先出牌
|
|
|
|
请决定是否叫地主,回答格式:
|
|
{{"call": true/false, "reason": "你的理由"}}
|
|
|
|
只输出JSON,不要其他内容。"""
|
|
|
|
def _build_prompt_playing(self, cards: List[Card], desktop_type: Optional[Dict],
|
|
is_lord: bool, game_history: List[Dict]) -> str:
|
|
"""构建出牌提示词"""
|
|
cards_str = ', '.join([f"{c.suit}{c.rank}({c.id})" for c in cards])
|
|
|
|
desktop_str = "无(你可以出任意牌)"
|
|
if desktop_type:
|
|
# desktop_type 中不包含 cards 字段(为了JSON序列化)
|
|
desktop_name = desktop_type.get('name', '未知')
|
|
desktop_value = desktop_type.get('value', 0)
|
|
desktop_str = f"{desktop_name} (牌值: {desktop_value})"
|
|
|
|
history_str = ""
|
|
if game_history:
|
|
for h in game_history[-5:]: # 最近5步
|
|
action = h.get('action', '')
|
|
player = h.get('player_id', '')[:8]
|
|
history_str += f"- {player}: {action}\n"
|
|
|
|
role = "地主" if is_lord else "农民"
|
|
|
|
return f"""你是一个斗地主高手。请根据当前局面选择最佳出牌策略。
|
|
|
|
你的角色: {role}
|
|
你的手牌(共{len(cards)}张):
|
|
{cards_str}
|
|
|
|
桌面上的牌: {desktop_str}
|
|
|
|
最近的操作记录:
|
|
{history_str if history_str else '无'}
|
|
|
|
牌型说明:
|
|
- 单张:任意一张牌
|
|
- 对子:两张相同牌值的牌
|
|
- 三张:三张相同牌值的牌
|
|
- 三带一:三张 + 一张单牌
|
|
- 三带二:三张 + 一对
|
|
- 顺子:5张或以上连续单牌(如3-4-5-6-7)
|
|
- 炸弹:四张相同牌值的牌
|
|
- 王炸:大王+小王
|
|
|
|
规则:
|
|
- 如果桌面上没有牌或你是最后出牌的,可以出任意合法牌型
|
|
- 否则必须出能管上桌面牌型的牌(同类型且更大,或炸弹/王炸)
|
|
- 如果无法或不想出牌,可以选择"不出"
|
|
|
|
请决定你的行动,回答格式:
|
|
{{"action": "play"/"pass", "card_ids": [牌的ID列表], "reason": "你的理由"}}
|
|
|
|
注意:
|
|
- action为"pass"时,card_ids为空数组
|
|
- 只能从你的手牌中选择牌的ID
|
|
- 只输出JSON,不要其他内容
|
|
|
|
你的决策:"""
|
|
|
|
async def _call_deepseek(self, prompt: str) -> Dict[str, Any]:
|
|
"""调用 DeepSeek API"""
|
|
if not self.api_key:
|
|
raise ValueError("DeepSeek API Key 未配置")
|
|
|
|
headers = {
|
|
'Authorization': f'Bearer {self.api_key}',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
payload = {
|
|
'model': self.model,
|
|
'messages': [
|
|
{'role': 'system', 'content': '你是一个斗地主游戏AI助手。请根据提示做出最佳决策,只输出JSON格式。'},
|
|
{'role': 'user', 'content': prompt}
|
|
],
|
|
'temperature': 0.3,
|
|
'max_tokens': 500
|
|
}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
self.api_url,
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=aiohttp.ClientTimeout(total=self.timeout)
|
|
) as response:
|
|
if response.status != 200:
|
|
error_text = await response.text()
|
|
raise Exception(f"API Error: {response.status} - {error_text}")
|
|
|
|
data = await response.json()
|
|
content = data['choices'][0]['message']['content']
|
|
|
|
# 解析 JSON
|
|
try:
|
|
# 尝试直接解析
|
|
result = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
# 尝试从文本中提取 JSON
|
|
import re
|
|
json_match = re.search(r'\{[^}]+\}', content)
|
|
if json_match:
|
|
result = json.loads(json_match.group())
|
|
else:
|
|
raise Exception(f"无法解析响应: {content}")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
print(f"DeepSeek API 调用失败: {e}")
|
|
# 失败时返回默认决策
|
|
return {'action': 'pass', 'reason': f'API调用失败: {str(e)}'}
|
|
|
|
async def make_decision(self, game_state: Dict[str, Any],
|
|
available_actions: List[str],
|
|
player_context: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""AI 做出决策"""
|
|
import time
|
|
start_time = time.time()
|
|
|
|
# 解析上下文
|
|
phase = game_state.get('phase') or game_state.get('data', {}).get('phase')
|
|
cards_data = player_context.get('cards', [])
|
|
cards = [Card(**c) for c in cards_data]
|
|
|
|
is_lord = player_context.get('is_lord', False)
|
|
game_history = game_state.get('history', [])
|
|
|
|
# desktop_cards 可能在 game_state['doudizhu'] 或 game_state['data'] 中
|
|
desktop_type = None
|
|
if 'doudizhu' in game_state:
|
|
desktop_type = game_state['doudizhu'].get('desktop_cards')
|
|
elif 'data' in game_state:
|
|
desktop_type = game_state['data'].get('desktop_cards')
|
|
|
|
try:
|
|
if 'call' in available_actions or ('calling' in str(phase) and 'pass' in available_actions):
|
|
# 叫地主决策
|
|
prompt = self._build_prompt_calling(cards)
|
|
result = await self._call_deepseek(prompt)
|
|
|
|
want = result.get('call', False)
|
|
reason = result.get('reason', '大模型决策')
|
|
|
|
action_result = {
|
|
'action': 'call' if want else 'pass',
|
|
'data': {'want': want},
|
|
'reasoning': reason,
|
|
'confidence': 0.75
|
|
}
|
|
else:
|
|
# 出牌决策
|
|
prompt = self._build_prompt_playing(cards, desktop_type, is_lord, game_history)
|
|
result = await self._call_deepseek(prompt)
|
|
|
|
action = result.get('action', 'pass')
|
|
card_ids = result.get('card_ids', []) if action == 'play' else []
|
|
reason = result.get('reason', '大模型决策')
|
|
|
|
action_result = {
|
|
'action': action,
|
|
'data': {'card_ids': card_ids} if card_ids else {},
|
|
'reasoning': reason,
|
|
'confidence': 0.75
|
|
}
|
|
|
|
except Exception as e:
|
|
# 出错时回退到规则AI
|
|
print(f"DeepSeek AI 出错,回退到规则AI: {e}")
|
|
rule_ai = DoudizhuRuleAI()
|
|
action_result = await rule_ai.make_decision(game_state, available_actions, player_context)
|
|
|
|
# 记录调用时间
|
|
latency = (time.time() - start_time) * 1000
|
|
self.record_call(latency)
|
|
|
|
return action_result
|
|
|
|
|
|
def create_doudizhu_ai(ai_type: str, **kwargs) -> AIAdapter:
|
|
"""
|
|
创建斗地主AI的工厂函数
|
|
|
|
Args:
|
|
ai_type: 'rule' 或 'deepseek'
|
|
**kwargs: 额外配置
|
|
|
|
Returns:
|
|
AIAdapter 实例
|
|
"""
|
|
if ai_type == 'rule':
|
|
return DoudizhuRuleAI(kwargs.get('config'))
|
|
elif ai_type == 'deepseek':
|
|
api_key = kwargs.get('api_key') or os.getenv('DEEPSEEK_API_KEY')
|
|
return DoudizhuDeepSeekAI(api_key=api_key, config=kwargs.get('config'))
|
|
else:
|
|
raise ValueError(f"未知的AI类型: {ai_type}")
|