赛博斗地主——使用大语言模型扮演Agent智能体玩牌类游戏。
通过大模型来实现多个智能体进行游戏对局这个想对已经比较成熟了无论是去年惊艳的斯坦福小镇还是比如metaGPT或者类似的框架都是使用智能体技术让大模型来操控,从而让大模型跳出自身“预测下一个token”的文字功能去探索更多的应用落地可能性。不过一直没有真正操作过,直到前段时间看到一个新闻《和GPT-4这些大模型玩狼人杀,人类因太蠢被票死,真·反向图灵测试》决定自己来玩一下。
斗地主是一款国人比较熟悉的棋牌游戏,考虑到这个游戏受众群体,所以基础大模型使用国产的通义千问提供的API接口(GPT4太贵用不起)。通过阿里云百炼大模型平台即可简单注册并申请使用:https://bailian.console.aliyun.com/
接着就是整体框架设计,其实整个游戏设计比较简单,随机发牌->随机定义一个玩家作为地主并发出尾牌(由于主要是模拟大模型使用Agent的玩牌所以这里就不加入抢地主环节了)->从地主开始玩家轮流出牌->谁的牌出完根据其角色决定是地主胜利还是农民胜利。
游戏整体使用c#编程,游戏主要的处理逻辑就是检测AI出牌的合法性,包括AI出牌是否是当前智能体的持有的手牌、牌型是否正确(单排/连子/对子/顺子/三带一/炸弹),出的牌是否可以压住上一轮玩家的牌等等逻辑。核心的部分如下:
public (CardsType, int[]) GetCardsType(string[] Cards) { try { if (Cards.Length == 1) return (CardsType.单牌, GetCardsNumber(Cards)); else if (Cards.Length == 2) { if (Cards.OrderBy(x => x).SequenceEqual(new List<string>() { "小王", "大王" }.OrderBy(x => x))) return (CardsType.炸弹, GetCardsNumber(Cards)); if (Cards.Select(ReplaceColor).Distinct().Count() == 1) return (CardsType.对子, GetCardsNumber(Cards)); throw new Exception(""); } else if (Cards.Length == 4) { var groupCards = Cards.Select(ReplaceColor).GroupBy(x => x).OrderByDescending(x => x.Count()).ToList(); //三带一 if (groupCards.Count == 2 && groupCards[0].Count() == 3) return (CardsType.三带一, GetCardsNumber(groupCards[0].ToArray()));//三带一只需要看三张牌的大小即可 //炸弹 if (groupCards.Count == 1) return (CardsType.炸弹, GetCardsNumber(Cards)); throw new Exception(""); } else if (Cards.Length >= 5) { //检测是否是顺子 if (Cards.Length == 6) { var groupCards = Cards.Select(ReplaceColor).GroupBy(x => x).OrderByDescending(x => x.Count()).ToList(); if (groupCards.Count == 3 && groupCards.All(x => x.Count() == 2)) return (CardsType.顺子, GetCardsNumber(groupCards[0].ToArray())); } var cardsnumber = GetCardsNumber(Cards); int? currItem = null; foreach (var item in cardsnumber) { if (currItem == null) currItem = item; else if (currItem + 1 != item) throw new Exception(""); } return (CardsType.连子, cardsnumber); } throw new Exception(""); } catch (Exception e) { throw new Exception($"当所选牌型无效,牌型只能是[{string.Join(",", Enum.GetNames(typeof(CardsType)))}],请检查你的牌型"); } }
以及玩牌部分的核心逻辑:
public void Play(string[] Cards) { var currPlayer = GetCurrnetPlayer(); if (Cards == null || Cards.Length == 0) { if (!GameRecords.Any(x => x.Player != null)) { throw new Exception("当前你是地主,必须进行出牌"); } else if (GameRecords.Last(x => x.Player != null && x.Cards.Any()).Player.Name == currPlayer.Name) { throw new Exception("上一轮你出牌后其他玩家都过了,本轮该你进行出牌(可以考虑出小牌)"); } GameRecords.Add(new GameRecordInfo() { Player = currPlayer, GameRecordText = $"玩家名:{currPlayer.Name},角色:{currPlayer.Role},本轮没有出牌", CardsType = null, Cards = Cards }); return; } //首先检查出牌是否在手牌中 if (IsSubsetWithFrequency(Cards, currPlayer.HandCards.ToArray(), out var missingCards)) { //检查最后一个牌组的情况 if (GameRecords.Any(x => x.Player != null)) { var last = GameRecords.Last(x => x.Player != null && x.Cards.Any()); var lastcardstype = GetCardsType(last.Cards); var cardstype = GetCardsType(Cards); if (last.Player.Name != currPlayer.Name) { if (lastcardstype.Item1 != cardstype.Item1 && cardstype.Item1 != CardsType.炸弹) { throw new Exception($"无效出牌,上一轮的牌型是{lastcardstype.Item1},你必须使用相同牌型出牌"); } //相同牌型则检测大小 if (cardstype.Item1 == CardsType.单牌 || cardstype.Item1 == CardsType.对子 || cardstype.Item1 == CardsType.顺子 || cardstype.Item1 == CardsType.炸弹) { if (lastcardstype.Item2[0] >= cardstype.Item2[0]) throw new Exception($"无效出牌,你的出牌:[{string.Join(",", Cards)}]必须比上一轮出牌:[{string.Join(",", last.Cards)}]更大才行"); } else { //连子的情况需要检测两个牌张数一致和最小长大于对方 if (lastcardstype.Item2.Length != cardstype.Item2.Length) throw new Exception($"无效出牌,由于本轮出牌是连子所以你的出牌数:[{Cards.Length}]必须和一轮出牌数:[{last.Cards.Length}]一致"); if (lastcardstype.Item2[0] >= cardstype.Item2[0]) throw new Exception($"无效出牌,你的出牌:[{string.Join(",", Cards)}]必须比上一轮出牌:[{string.Join(",", last.Cards)}]更大才行"); } } } } else { throw new Exception($"无效出牌,原因:{missingCards}。请重新出牌"); } GameRecords.Add(new GameRecordInfo() { Player = currPlayer, GameRecordText = $"玩家名:{currPlayer.Name},角色:{currPlayer.Role},本轮出牌:[{string.Join(",", Cards)}],牌型:{GetCardsType(Cards).Item1}", CardsType = GetCardsType(Cards).Item1, Cards = Cards }); Players[CurrnetPlayerIndex].HandCards.RemoveAll(x => Cards.Select(x => x.ToLower()).Contains(x.ToLower())); }
接着就是一些游戏状态管理,包括初始化牌组、分派给三个玩家手牌,玩家自身的手牌管理等等这里就不一一赘述了,这里主要讲一下基于阿里千问大模型如何设计Agent代理的部分。在阿里百炼上,可以查看模型的调用示例,这里我们选择阿里目前最大的千亿参数大模型千问-MAX,进入调用示例就可以看到类似如下示例代码(如果你喜欢SDK则可以选择python和java的包。如果是其他语言则只有自己手写http请求调用):
调用的部分比较简单,就是一个httpclient的封装,以及对调用入参和出参DTO的实体定义:
public class ApiClient { private readonly HttpClient _httpClient; public ApiClient(string baseUrl, string apiKey) { _httpClient = new HttpClient { BaseAddress = new Uri(baseUrl) }; // 配置HttpClient实例 _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } public async Task<ApiResponse> PostAsync(string resource, TextGenerationRequest request) { var jsonData = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); using var content = new StringContent(jsonData.ToLower(), Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync(resource, content); if (response.IsSuccessStatusCode) { var responseContent = await response.Content.ReadAsStringAsync(); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true // 忽略大小写 }; return JsonSerializer.Deserialize<ApiResponse>(responseContent, options); } else { // 错误处理 var errorContent = await response.Content.ReadAsStringAsync(); throw new Exception($"API 请求失败: {response.StatusCode}, {errorContent}"); } } }
接着就是比较关键的部分,即入参的定义,这决定了大模型如何调用智能体的关键,这里面其实主要还是编写特定的prompt让大模型知道自己要干嘛。由于是斗地主游戏,所以这里我们需要在系统提示词中编写一些关于斗地主的基本游戏规则、不同角色可以采取的常规游戏策略,游戏当前的对局情况。接着在用户提示词中需要告知大模型扮演智能体的角色、持有的手牌,可以调取的游戏函数。其中游戏函数比较关键,这也是大模型唯一可以让游戏“动起来”的方式。以下是我定义的关于斗地主游戏的请求入参:
TextGenerationRequest GetNowReq() { var userprompt = ""; if (game.GameRecords.Where(x => x.Player != null).Count() == 0) { userprompt = $"现在是第一轮,你是{game.GetCurrnetPlayer().Name},你的角色是:{game.GetCurrnetPlayer().Role}请先出牌(如果单牌较少可以考虑尽可能出顺子、连子、三带一或者对子,如果单牌较多则优先考虑出单牌)\r\n手持牌组:{game.GetCurrnetPlayerHandCards()}"; } else if (game.GameRecords.Any(x => x.Player != null) && game.GameRecords.Last(x => x.Player != null && x.Cards.Any()).Player.Name == game.GetCurrnetPlayer().Name) { userprompt = $"你是{game.GetCurrnetPlayer().Name},你的角色是:{game.GetCurrnetPlayer().Role}。上一轮其他玩家都过了你的牌,请你出牌(如果单牌较少可以考虑尽可能出顺子、连子、三带一或者对子,如果单牌较多则优先考虑出单牌)\r\n手持牌组:{game.GetCurrnetPlayerHandCards()}"; } else { userprompt = $"你是{game.GetCurrnetPlayer().Name},你的角色是:{game.GetCurrnetPlayer().Role}。请出牌(如果单牌较少可以考虑尽可能出顺子、连子、三带一或者对子,如果单牌较多则优先考虑出单牌),或者选择本轮不出牌(当你的手牌都小于最后的出牌或者上一轮出牌的玩家是同组玩家时可以不出牌)\r\n手持牌组:{game.GetCurrnetPlayerHandCards()}"; } return new TextGenerationRequest { Model = "qwen-max", Input = new InputData { Messages = new List<Message> { new Message { Role = "system", Content = $""" 你正在参与一场斗地主游戏, #游戏规则 参与游戏的玩家由一个地主和两个农民组成,如果你是地主,你需要出掉所有的牌才能获得胜利。如果你是农民,你和你的队友任意一人出完所有的牌即可获胜。 可以出单牌、【对子】(两张相同数字的牌,如:["♥3","♣3"])、【连子】(从小到大顺序数字的牌5张起出,如:["♥4","♣5","♦6","♣7","♥8"])、【顺子】(三个连起来的对子,如:["♥9","♣9","♦10","♣10","♣j","♥j"])、【三带一】(三张相同数字的牌+一张任意单牌,如:["♥4","♣4","♦4","♣6"])、【炸弹】(四个相同数字的牌或者双王,如:["♥4","♣4","♦4","♣4"]或者["小王","大王"]),牌从小到大顺序:3,4,5,6,7,8,9,10,j,q,k,a,2,小王,大王。 每一轮出牌必须【大于】对方的出牌,并且必须和对方牌型一致[{string.Join(",",Enum.GetNames(typeof(CardsType)))}] ##关于炸弹的特别规则 如果当前手牌里有【炸弹】同时手牌里没有【大于】对方的出牌时,可以根据使用炸弹,炸弹可以最大程度的确保你拥有下一轮次的出牌权除非对手有比你更大的炸弹。所以尽可能的不要将炸弹的牌拆成对子、连子、顺子、三带一,如手牌是:["♥7","♣9","♦10","♣10","♣10","♥10"]尽可能不要拆成["♥7","♦10","♣10","♣10"]或者["♣10","♣10"]出牌 注意双王是最大炸弹,四个2是第二大的炸弹,请谨慎使用。 ##斗地主常见出牌策略参考: #地主的策略 快速出牌:地主的首要策略是尽可能快地出牌,减少农民合作的机会。地主手中有更多的牌,可以更灵活地控制游戏节奏。 控制大牌:保留关键的大牌(如2、王等)来在关键时刻打破农民的配合或结束游戏。 分割农民的牌:尝试通过出牌强迫农民拆散他们的对子或连牌,破坏他们的出牌计划。 压制对手:地主可以通过连续出牌来压制农民,尤其是当发现农民手牌较少时,增加出牌速度,迫使他们出掉保留的大牌。 记牌:地主需要注意记住已出的关键牌,尤其是农民已经出过的高牌,以合理规划自己的出牌策略。 #农民的策略 配合与合作:两名农民需要通过默契的配合来阻挡地主,比如其中一个尝试出小牌逼地主出大牌,另一个则保留大牌来后期制胜。 堵牌:注意地主可能会形成的牌型,比如顺子、对子等,并尝试通过出相同类型的牌来堵截地主的出牌。 牺牲策略:有时候,一名农民可能需要牺牲自己的一些好牌,以帮助另一名农民形成更强的牌型或打断地主的出牌计划。 保存关键牌:农民应保存一些关键牌,如单张的王或2,用来在关键时刻打断地主的连胜。 记牌与推算:农民需要密切注意牌局的走向和地主的出牌习惯,推算出地主可能保留的牌,合理规划自己的出牌策略。 #所有玩家策略 在斗地主中,观察和记牌是所有玩家的重要技能。无论是地主还是农民,合理利用手中的牌,观察对手的出牌习惯,以及与队友或自己的牌进行策略性的搭配,都是赢得游戏的关键因素。 ##游戏已进行的历史 {game.GetGameRecordsHistory()} """ }, new Message { Role = "user", Content =userprompt } } }, Parameters = new InputParametersData() { Tools = new List<Tool> { new Tool { Type = "function", Function = new FunctionDetail { Name = "send_cards", Description = "出牌函数,用于本轮游戏出牌。你的出牌必须包含在你的手持牌组中", Parameters = new List<FunctionDetailParameter>(){ new FunctionDetailParameter() { properties=new { Cards=new { type="string[]", description= "选择你要出的牌组,使用逗号\",\"分割,每一个牌必须使用\"\"包裹" } } } } } } } } }; }
接下来就是游戏的运行主要部分逻辑,定义一个游戏实例,通过一个死循环检测是否已经有玩家手牌出尽来判断游戏是否已经达到结局,没有出尽则依次让大模型调用智能体通过函数玩游戏,并且当模型出牌不符合规则时通过函数回调告知模型出错的逻辑指导模型重新进行对应的出牌:
Console.OutputEncoding = System.Text.Encoding.UTF8; Game game = new Game(); var apiKey = "通过百炼模型平台申请你的API-KEY"; var baseUrl = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; var apiClient = new ApiClient(baseUrl, apiKey); var request = GetNowReq(); try { var rollindex = 0; var rollbigindex = 1; while (!game.Players.Any(x=>x.HandCards.Count==0)) { if (!game.GameRecords.Any()) { game.GameRecords.Add(new GameRecordInfo() { GameRecordText = $"第{rollbigindex}轮开始" }); } ApiResponse response = default; try { response = await apiClient.PostAsync("", request); } catch (Exception e) { var jsonData = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); File.WriteAllText("errdata.json", jsonData.ToLower()); throw new Exception("接口请求异常,原始信息:" + e.Message); } if (response.Output.Choices[0].Message.Tool_Calls != null && response.Output.Choices[0].Message.Tool_Calls.Any()) { try { var argument = JsonSerializer.Deserialize<CardsDto>(response.Output.Choices[0].Message.Tool_Calls.First().Function.Arguments); game.Play(argument.cards ?? Array.Empty<string>()); var last = game.GameRecords.LastOrDefault(x => x.Player != null); Console.ForegroundColor = ConsoleColor.Green; if (Console.CursorLeft != 0) Console.WriteLine($""); Console.Write($"({last.Player.Role})玩家:{last.Player.Name}:"); Console.ForegroundColor = ConsoleColor.Red; Console.Write((last.Cards == null || last.Cards.Length == 0) ? "过" : game.GetCardsNumberText(last.Cards)); Console.ResetColor(); var messageContent = response.Output.Choices[0].Message.Content; if (!string.IsNullOrWhiteSpace(messageContent)) { messageContent = messageContent.Replace("\r\n", "").Replace("\n", "").Replace("\r", ""); } Console.WriteLine($"({messageContent},余牌:{game.GetCurrnetPlayerHandCards()})"); rollindex++; if (rollindex == 3) { rollindex = 0; game.GameRecords.Add(new GameRecordInfo() { GameRecordText = $"第{rollbigindex}轮结束,进入下一轮" }); rollbigindex++; } game.MoveNextPlayer(); request = GetNowReq(); } catch(JsonException je) { var last = game.GetCurrnetPlayer(); request = GetNowReq(); request.Input.Messages.Add(response.Output.Choices[0].Message); request.Input.Messages.Add(new Message() { Role = "tool", Name = "send_cards", Content = "传递了错误的函数调用字符串,无法转化成标准的json格式,原始字符串:" + response.Output.Choices[0].Message.Tool_Calls.First().Function.Arguments }); } catch (Exception e) { var last = game.GetCurrnetPlayer(); request = GetNowReq(); request.Input.Messages.Add(response.Output.Choices[0].Message); request.Input.Messages.Add(new Message() { Role = "tool", Name = "send_cards", Content = e.Message }); } } else { try { game.Play(Array.Empty<string>());//不进行出牌 var last = game.GameRecords.LastOrDefault(x => x.Player != null); Console.ForegroundColor = ConsoleColor.Green; if (Console.CursorLeft != 0) Console.WriteLine($""); Console.Write($"({last.Player.Role})玩家:{last.Player.Name}:"); Console.ForegroundColor = ConsoleColor.Red; Console.Write((last.Cards == null || last.Cards.Length == 0) ? "过" : game.GetCardsNumberText(last.Cards)); Console.ResetColor(); var messageContent = response.Output.Choices[0].Message.Content; if (!string.IsNullOrWhiteSpace(messageContent)) { messageContent = messageContent.Replace("\r\n", "").Replace("\n", "").Replace("\r", ""); } Console.WriteLine($"({messageContent},余牌:{game.GetCurrnetPlayerHandCards()})"); rollindex++; if (rollindex == 3) { rollindex = 0; game.GameRecords.Add(new GameRecordInfo() { GameRecordText = $"第{rollbigindex}轮结束,进入下一轮" }); rollbigindex++; } game.MoveNextPlayer(); request = GetNowReq(); } catch (Exception e) { var last = game.GetCurrnetPlayer(); request = GetNowReq(); request.Input.Messages.Add(response.Output.Choices[0].Message); request.Input.Messages.Add(new Message() { Role = "tool", Name = "send_cards", Content = e.Message }); } } } Console.WriteLine($"游戏结束,{(game.Players.Any(x => x.Role == "地主" && x.HandCards.Any()) ? "农民胜利" : "地主胜利")}"); } catch (Exception ex) { Console.WriteLine(ex.Message); Console.WriteLine(ex.StackTrace); }
以上内容基本就是主要的部分,演示的内容如下:
可以看到模型的表现还是比较“蠢”,这是因为斗地主是一个典型的信息不完全(信息不透明)的游戏。这意味着在游戏过程中不是所有的信息都是对所有玩家开放的。策略的多样性和不确定性让玩家在游戏中必须基于有限的信息做出决策,比如是否抢地主(本示例没有)、如何出牌以及如何配合或对抗其他玩家。玩家的策略不仅受到手牌的限制,还受到对其他玩家策略的猜测和解读的影响。加之当前大模型对于数学的理解能力较差和逻辑短板导致其表现的比较“智障”。一般的斗地主AI主要依赖搜索算法+剪枝策略或者基于神经网络+强化学习+搜索算法来实现比如典型的棋牌类AI比如Pluribus和AlphaGo都是依赖类似的技术来实现,而大模型本身主要并非转向基于游戏决策做过训练,所以这里也就不展开了。本作主要还是想讨论大模型在智能体应用上有哪些可能的落地方式。
完整的代码如下,有兴趣的朋友可以自行申请百炼的千问API接口进行尝试(没有依赖任何包,所以可以创建一个控制台程序直接粘贴到program.cs即可运行):
using System.Collections; using System.Collections.Generic; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; Console.OutputEncoding = System.Text.Encoding.UTF8; Game game = new Game(); var apiKey = "通过百炼模型平台申请你的API-KEY"; var baseUrl = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; var apiClient = new ApiClient(baseUrl, apiKey); var request = GetNowReq(); try { var rollindex = 0; var rollbigindex = 1; while (!game.Players.Any(x=>x.HandCards.Count==0)) { if (!game.GameRecords.Any()) { game.GameRecords.Add(new GameRecordInfo() { GameRecordText = $"第{rollbigindex}轮开始" }); } ApiResponse response = default; try { response = await apiClient.PostAsync("", request); } catch (Exception e) { var jsonData = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); File.WriteAllText("errdata.json", jsonData.ToLower()); throw new Exception("接口请求异常,原始信息:" + e.Message); } if (response.Output.Choices[0].Message.Tool_Calls != null && response.Output.Choices[0].Message.Tool_Calls.Any()) { try { var argument = JsonSerializer.Deserialize<CardsDto>(response.Output.Choices[0].Message.Tool_Calls.First().Function.Arguments); game.Play(argument.cards ?? Array.Empty<string>()); var last = game.GameRecords.LastOrDefault(x => x.Player != null); Console.ForegroundColor = ConsoleColor.Green; if (Console.CursorLeft != 0) Console.WriteLine($""); Console.Write($"({last.Player.Role})玩家:{last.Player.Name}:"); Console.ForegroundColor = ConsoleColor.Red; Console.Write((last.Cards == null || last.Cards.Length == 0) ? "过" : game.GetCardsNumberText(last.Cards)); Console.ResetColor(); var messageContent = response.Output.Choices[0].Message.Content; if (!string.IsNullOrWhiteSpace(messageContent)) { messageContent = messageContent.Replace("\r\n", "").Replace("\n", "").Replace("\r", ""); } Console.WriteLine($"({messageContent},余牌:{game.GetCurrnetPlayerHandCards()})"); rollindex++; if (rollindex == 3) { rollindex = 0; game.GameRecords.Add(new GameRecordInfo() { GameRecordText = $"第{rollbigindex}轮结束,进入下一轮" }); rollbigindex++; } game.MoveNextPlayer(); request = GetNowReq(); } catch(JsonException je) { var last = game.GetCurrnetPlayer(); request = GetNowReq(); request.Input.Messages.Add(response.Output.Choices[0].Message); request.Input.Messages.Add(new Message() { Role = "tool", Name = "send_cards", Content = "传递了错误的函数调用字符串,无法转化成标准的json格式,原始字符串:" + response.Output.Choices[0].Message.Tool_Calls.First().Function.Arguments }); } catch (Exception e) { var last = game.GetCurrnetPlayer(); request = GetNowReq(); request.Input.Messages.Add(response.Output.Choices[0].Message); request.Input.Messages.Add(new Message() { Role = "tool", Name = "send_cards", Content = e.Message }); } } else { try { game.Play(Array.Empty<string>());//不进行出牌 var last = game.GameRecords.LastOrDefault(x => x.Player != null); Console.ForegroundColor = ConsoleColor.Green; if (Console.CursorLeft != 0) Console.WriteLine($""); Console.Write($"({last.Player.Role})玩家:{last.Player.Name}:"); Console.ForegroundColor = ConsoleColor.Red; Console.Write((last.Cards == null || last.Cards.Length == 0) ? "过" : game.GetCardsNumberText(last.Cards)); Console.ResetColor(); var messageContent = response.Output.Choices[0].Message.Content; if (!string.IsNullOrWhiteSpace(messageContent)) { messageContent = messageContent.Replace("\r\n", "").Replace("\n", "").Replace("\r", ""); } Console.WriteLine($"({messageContent},余牌:{game.GetCurrnetPlayerHandCards()})"); rollindex++; if (rollindex == 3) { rollindex = 0; game.GameRecords.Add(new GameRecordInfo() { GameRecordText = $"第{rollbigindex}轮结束,进入下一轮" }); rollbigindex++; } game.MoveNextPlayer(); request = GetNowReq(); } catch (Exception e) { var last = game.GetCurrnetPlayer(); request = GetNowReq(); request.Input.Messages.Add(response.Output.Choices[0].Message); request.Input.Messages.Add(new Message() { Role = "tool", Name = "send_cards", Content = e.Message }); } } } Console.WriteLine($"游戏结束,{(game.Players.Any(x => x.Role == "地主" && x.HandCards.Any()) ? "农民胜利" : "地主胜利")}"); } catch (Exception ex) { Console.WriteLine(ex.Message); Console.WriteLine(ex.StackTrace); } void readerrdatatest() { var json = File.ReadAllText("errdata.json"); var obj = JsonSerializer.Deserialize<TextGenerationRequest>(json,new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } TextGenerationRequest GetNowReq() { var userprompt = ""; if (game.GameRecords.Where(x => x.Player != null).Count() == 0) { userprompt = $"现在是第一轮,你是{game.GetCurrnetPlayer().Name},你的角色是:{game.GetCurrnetPlayer().Role}请先出牌(如果单牌较少可以考虑尽可能出顺子、连子、三带一或者对子,如果单牌较多则优先考虑出单牌)\r\n手持牌组:{game.GetCurrnetPlayerHandCards()}"; } else if (game.GameRecords.Any(x => x.Player != null) && game.GameRecords.Last(x => x.Player != null && x.Cards.Any()).Player.Name == game.GetCurrnetPlayer().Name) { userprompt = $"你是{game.GetCurrnetPlayer().Name},你的角色是:{game.GetCurrnetPlayer().Role}。上一轮其他玩家都过了你的牌,请你出牌(如果单牌较少可以考虑尽可能出顺子、连子、三带一或者对子,如果单牌较多则优先考虑出单牌)\r\n手持牌组:{game.GetCurrnetPlayerHandCards()}"; } else { userprompt = $"你是{game.GetCurrnetPlayer().Name},你的角色是:{game.GetCurrnetPlayer().Role}。请出牌(如果单牌较少可以考虑尽可能出顺子、连子、三带一或者对子,如果单牌较多则优先考虑出单牌),或者选择本轮不出牌(当你的手牌都小于最后的出牌或者上一轮出牌的玩家是同组玩家时可以不出牌)\r\n手持牌组:{game.GetCurrnetPlayerHandCards()}"; } return new TextGenerationRequest { Model = "qwen-max", Input = new InputData { Messages = new List<Message> { new Message { Role = "system", Content = $""" 你正在参与一场斗地主游戏, #游戏规则 参与游戏的玩家由一个地主和两个农民组成,如果你是地主,你需要出掉所有的牌才能获得胜利。如果你是农民,你和你的队友任意一人出完所有的牌即可获胜。 可以出单牌、【对子】(两张相同数字的牌,如:["♥3","♣3"])、【连子】(从小到大顺序数字的牌5张起出,如:["♥4","♣5","♦6","♣7","♥8"])、【顺子】(三个连起来的对子,如:["♥9","♣9","♦10","♣10","♣j","♥j"])、【三带一】(三张相同数字的牌+一张任意单牌,如:["♥4","♣4","♦4","♣6"])、【炸弹】(四个相同数字的牌或者双王,如:["♥4","♣4","♦4","♣4"]或者["小王","大王"]),牌从小到大顺序:3,4,5,6,7,8,9,10,j,q,k,a,2,小王,大王。 每一轮出牌必须【大于】对方的出牌,并且必须和对方牌型一致[{string.Join(",",Enum.GetNames(typeof(CardsType)))}] ##关于炸弹的特别规则 如果当前手牌里有【炸弹】同时手牌里没有【大于】对方的出牌时,可以根据使用炸弹,炸弹可以最大程度的确保你拥有下一轮次的出牌权除非对手有比你更大的炸弹。所以尽可能的不要将炸弹的牌拆成对子、连子、顺子、三带一,如手牌是:["♥7","♣9","♦10","♣10","♣10","♥10"]尽可能不要拆成["♥7","♦10","♣10","♣10"]或者["♣10","♣10"]出牌 注意双王是最大炸弹,四个2是第二大的炸弹,请谨慎使用。 ##斗地主常见出牌策略参考: #地主的策略 快速出牌:地主的首要策略是尽可能快地出牌,减少农民合作的机会。地主手中有更多的牌,可以更灵活地控制游戏节奏。 控制大牌:保留关键的大牌(如2、王等)来在关键时刻打破农民的配合或结束游戏。 分割农民的牌:尝试通过出牌强迫农民拆散他们的对子或连牌,破坏他们的出牌计划。 压制对手:地主可以通过连续出牌来压制农民,尤其是当发现农民手牌较少时,增加出牌速度,迫使他们出掉保留的大牌。 记牌:地主需要注意记住已出的关键牌,尤其是农民已经出过的高牌,以合理规划自己的出牌策略。 #农民的策略 配合与合作:两名农民需要通过默契的配合来阻挡地主,比如其中一个尝试出小牌逼地主出大牌,另一个则保留大牌来后期制胜。 堵牌:注意地主可能会形成的牌型,比如顺子、对子等,并尝试通过出相同类型的牌来堵截地主的出牌。 牺牲策略:有时候,一名农民可能需要牺牲自己的一些好牌,以帮助另一名农民形成更强的牌型或打断地主的出牌计划。 保存关键牌:农民应保存一些关键牌,如单张的王或2,用来在关键时刻打断地主的连胜。 记牌与推算:农民需要密切注意牌局的走向和地主的出牌习惯,推算出地主可能保留的牌,合理规划自己的出牌策略。 #所有玩家策略 在斗地主中,观察和记牌是所有玩家的重要技能。无论是地主还是农民,合理利用手中的牌,观察对手的出牌习惯,以及与队友或自己的牌进行策略性的搭配,都是赢得游戏的关键因素。 ##游戏已进行的历史 {game.GetGameRecordsHistory()} """ }, new Message { Role = "user", Content =userprompt } } }, Parameters = new InputParametersData() { Tools = new List<Tool> { new Tool { Type = "function", Function = new FunctionDetail { Name = "send_cards", Description = "出牌函数,用于本轮游戏出牌。你的出牌必须包含在你的手持牌组中", Parameters = new List<FunctionDetailParameter>(){ new FunctionDetailParameter() { properties=new { Cards=new { type="string[]", description= "选择你要出的牌组,使用逗号\",\"分割,每一个牌必须使用\"\"包裹" } } } } } } } } }; } public class ApiClient { private readonly HttpClient _httpClient; public ApiClient(string baseUrl, string apiKey) { _httpClient = new HttpClient { BaseAddress = new Uri(baseUrl) }; // 配置HttpClient实例 _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } public async Task<ApiResponse> PostAsync(string resource, TextGenerationRequest request) { var jsonData = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); using var content = new StringContent(jsonData.ToLower(), Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync(resource, content); if (response.IsSuccessStatusCode) { var responseContent = await response.Content.ReadAsStringAsync(); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true // 忽略大小写 }; return JsonSerializer.Deserialize<ApiResponse>(responseContent, options); } else { // 错误处理 var errorContent = await response.Content.ReadAsStringAsync(); throw new Exception($"API 请求失败: {response.StatusCode}, {errorContent}"); } } } public class TextGenerationRequest { public string Model { get; set; } public InputData Input { get; set; } public InputParametersData Parameters { get; set; } } public class InputParametersData { public string Result_Format { get; set; } = "message"; public List<Tool> Tools { get; set; } } public class InputData { public List<Message> Messages { get; set; } } public class Message { public string Name { get; set; } public string Role { get; set; } public List<ToolCall> Tool_Calls { get; set; } public string Content { get; set; } } public class Tool { public string Type { get; set; } public FunctionDetail Function { get; set; } } public class ToolCall { public FunctionCall Function { get; set; } public string Id { get; set; } public string Type { get; set; } } public class FunctionCall { public string Name { get; set; } public string Arguments { get; set; } } public class FunctionDetail { public string Name { get; set; } public string Description { get; set; } public List<FunctionDetailParameter> Parameters { get; set; } } public class FunctionDetailParameter { public string type { get; set; } = "object"; public object properties { get; set; } } public class ApiResponse { public OutputResponse Output { get; set; } public UsageData Usage { get; set; } public string RequestId { get; set; } } public class OutputResponse { public List<Choice> Choices { get; set; } } public class Choice { public string FinishReason { get; set; } public Message Message { get; set; } } public class UsageData { public int TotalTokens { get; set; } public int OutputTokens { get; set; } public int InputTokens { get; set; } } public class CardsDto { public string[] cards { get; set; } } public class Game { string[] array = ["3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k", "a", "2", "小王", "大王"]; public List<string> Deck { get; private set; } public List<Player> Players { get; private set; } public List<string> BottomCards { get; private set; } public Player Landlord { get; private set; } public int CurrnetPlayerIndex = 0; public List<GameRecordInfo> GameRecords { get; set; } public Game() { Players = new List<Player> { new Player("Player 1"), new Player("Player 2"), new Player("Player 3") }; GameRecords = new List<GameRecordInfo>(); Deck = GenerateDeck(); ShuffleDeck(); DealCards(); ChooseLandlord(); } public string GetGameRecordsHistory() { return string.Join("\r\n", GameRecords.Select(x => x.GameRecordText)); } public string GetCurrnetPlayerHandCards(int? index = null) { return string.Join(",", Players[index ?? CurrnetPlayerIndex].HandCards.OrderBy(x => Array.IndexOf(array, ReplaceColor(x)))); } public Player GetCurrnetPlayer() { return Players[CurrnetPlayerIndex]; } public int[] GetCardsNumber(string[] Cards) { var cardsnumber = Cards.Select(x => { if (x == "小王" || x == "大王") return Array.IndexOf(array, x); else { var num = ReplaceColor(x); return Array.IndexOf(array, num); } }).ToArray(); return cardsnumber.Order().ToArray(); } string ReplaceColor(string card) => card.Replace("♠", "").Replace("♥", "").Replace("♦", "").Replace("♣", "").ToLower(); public string GetCardsNumberText(string[] Cards) { return string.Join(",", Cards); } public (CardsType, int[]) GetCardsType(string[] Cards) { try { if (Cards.Length == 1) return (CardsType.单牌, GetCardsNumber(Cards)); else if (Cards.Length == 2) { if (Cards.OrderBy(x => x).SequenceEqual(new List<string>() { "小王", "大王" }.OrderBy(x => x))) return (CardsType.炸弹, GetCardsNumber(Cards)); if (Cards.Select(ReplaceColor).Distinct().Count() == 1) return (CardsType.对子, GetCardsNumber(Cards)); throw new Exception(""); } else if (Cards.Length == 4) { var groupCards = Cards.Select(ReplaceColor).GroupBy(x => x).OrderByDescending(x => x.Count()).ToList(); //三带一 if (groupCards.Count == 2 && groupCards[0].Count() == 3) return (CardsType.三带一, GetCardsNumber(groupCards[0].ToArray()));//三带一只需要看三张牌的大小即可 //炸弹 if (groupCards.Count == 1) return (CardsType.炸弹, GetCardsNumber(Cards)); throw new Exception(""); } else if (Cards.Length >= 5) { //检测是否是顺子 if (Cards.Length == 6) { var groupCards = Cards.Select(ReplaceColor).GroupBy(x => x).OrderByDescending(x => x.Count()).ToList(); if (groupCards.Count == 3 && groupCards.All(x => x.Count() == 2)) return (CardsType.顺子, GetCardsNumber(groupCards[0].ToArray())); } var cardsnumber = GetCardsNumber(Cards); int? currItem = null; foreach (var item in cardsnumber) { if (currItem == null) currItem = item; else if (currItem + 1 != item) throw new Exception(""); } return (CardsType.连子, cardsnumber); } throw new Exception(""); } catch (Exception e) { throw new Exception($"当所选牌型无效,牌型只能是[{string.Join(",", Enum.GetNames(typeof(CardsType)))}],请检查你的牌型"); } } public void MoveNextPlayer() { CurrnetPlayerIndex++; if (CurrnetPlayerIndex == Players.Count) CurrnetPlayerIndex = 0; } public void Play(string[] Cards) { var currPlayer = GetCurrnetPlayer(); if (Cards == null || Cards.Length == 0) { if (!GameRecords.Any(x => x.Player != null)) { throw new Exception("当前你是地主,必须进行出牌"); } else if (GameRecords.Last(x => x.Player != null && x.Cards.Any()).Player.Name == currPlayer.Name) { throw new Exception("上一轮你出牌后其他玩家都过了,本轮该你进行出牌(可以考虑出小牌)"); } GameRecords.Add(new GameRecordInfo() { Player = currPlayer, GameRecordText = $"玩家名:{currPlayer.Name},角色:{currPlayer.Role},本轮没有出牌", CardsType = null, Cards = Cards }); return; } //首先检查出牌是否在手牌中 if (IsSubsetWithFrequency(Cards, currPlayer.HandCards.ToArray(), out var missingCards)) { //检查最后一个牌组的情况 if (GameRecords.Any(x => x.Player != null)) { var last = GameRecords.Last(x => x.Player != null && x.Cards.Any()); var lastcardstype = GetCardsType(last.Cards); var cardstype = GetCardsType(Cards); if (last.Player.Name != currPlayer.Name) { if (lastcardstype.Item1 != cardstype.Item1 && cardstype.Item1 != CardsType.炸弹) { throw new Exception($"无效出牌,上一轮的牌型是{lastcardstype.Item1},你必须使用相同牌型出牌"); } //相同牌型则检测大小 if (cardstype.Item1 == CardsType.单牌 || cardstype.Item1 == CardsType.对子 || cardstype.Item1 == CardsType.顺子 || cardstype.Item1 == CardsType.炸弹) { if (lastcardstype.Item2[0] >= cardstype.Item2[0]) throw new Exception($"无效出牌,你的出牌:[{string.Join(",", Cards)}]必须比上一轮出牌:[{string.Join(",", last.Cards)}]更大才行"); } else { //连子的情况需要检测两个牌张数一致和最小长大于对方 if (lastcardstype.Item2.Length != cardstype.Item2.Length) throw new Exception($"无效出牌,由于本轮出牌是连子所以你的出牌数:[{Cards.Length}]必须和一轮出牌数:[{last.Cards.Length}]一致"); if (lastcardstype.Item2[0] >= cardstype.Item2[0]) throw new Exception($"无效出牌,你的出牌:[{string.Join(",", Cards)}]必须比上一轮出牌:[{string.Join(",", last.Cards)}]更大才行"); } } } } else { throw new Exception($"无效出牌,原因:{missingCards}。请重新出牌"); } GameRecords.Add(new GameRecordInfo() { Player = currPlayer, GameRecordText = $"玩家名:{currPlayer.Name},角色:{currPlayer.Role},本轮出牌:[{string.Join(",", Cards)}],牌型:{GetCardsType(Cards).Item1}", CardsType = GetCardsType(Cards).Item1, Cards = Cards }); Players[CurrnetPlayerIndex].HandCards.RemoveAll(x => Cards.Select(x => x.ToLower()).Contains(x.ToLower())); } private bool IsSubsetWithFrequency(string[] smallList, string[] bigList, out string missingElements) { var bigCount = bigList.GroupBy(x => x).ToDictionary(g => g.Key.ToLower(), g => g.Count()); var smallCount = smallList.GroupBy(x => x).ToDictionary(g => g.Key.ToLower(), g => g.Count()); var missingList = new List<string>(); foreach (var num in smallList.Select(x => x.ToLower())) { if (!bigCount.ContainsKey(num) || bigCount[num] == 0) { missingList.Add(num); } else { bigCount[num]--; } } bigCount = bigList.GroupBy(x => x).ToDictionary(g => g.Key.ToLower(), g => g.Count()); StringBuilder sb = new StringBuilder(); foreach (var item in missingList.Distinct().ToArray()) { var smallval = smallCount[item]; //检测一下其他色号 var num = ReplaceColor(item); Func<string, bool> check = x => ReplaceColor(x) == num; if (!bigCount.ContainsKey(item)) { if (bigList.Any(check)) { var all = bigList.Where(check).ToList(); sb.AppendLine($"你所选的牌{item},不在你的手牌中,可以选择手牌中同数字不同花色的牌:{string.Join(",", all)}"); } else { sb.AppendLine($"你所选的牌{item},不在你的手牌中"); } } else { if (bigList.Any(check)) { var all = bigList.Where(check).ToList(); sb.AppendLine($"你选了{smallval}张{item},但是你的手牌中只有{bigCount[item]}张{item}, 可以选择手牌中同数字不同花色的牌:{string.Join(",", all.Where(x => x != item))}"); } else { sb.AppendLine($"你选了{smallval}张{item},但是你的手牌中只有{bigCount[item]}张{item}"); } } } missingElements = sb.ToString(); return missingList.Distinct().ToArray().Length == 0; } private List<string> GenerateDeck() { string[] suits = { "♠", "♥", "♦", "♣" }; string[] ranks = { "3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k", "a", "2" }; List<string> deck = new List<string>(); foreach (var suit in suits) { foreach (var rank in ranks) { deck.Add($"{suit}{rank}"); } } deck.Add("小王"); deck.Add("大王"); return deck; } private void ShuffleDeck() { Random rng = new Random(); int n = Deck.Count; while (n > 1) { n--; int k = rng.Next(n + 1); var value = Deck[k]; Deck[k] = Deck[n]; Deck[n] = value; } } private void DealCards() { for (int i = 0; i < 17; i++) { foreach (var player in Players) { player.HandCards.Add(Deck.First()); Deck.RemoveAt(0); } } BottomCards = Deck.ToList(); Deck.Clear(); } private void ChooseLandlord() { int landlordIndex = new Random().Next(Players.Count); Landlord = Players[landlordIndex]; CurrnetPlayerIndex = landlordIndex; Console.WriteLine($"{Landlord.Name} 是候选地主。"); Landlord.HandCards.AddRange(BottomCards); Landlord.Role = "地主"; Console.WriteLine($"{Landlord.Name} 成为地主,获得底牌。"); } } public class Player { public string Role { get; set; } public string Name { get; private set; } public List<string> HandCards { get; private set; } public Player(string name) { Name = name; Role = "农民"; HandCards = new List<string>(); } } public class GameRecordInfo { public Player Player { get; set; } public string GameRecordText { get; set; } public CardsType? CardsType { get; set; } public int[] CardsNumber { get; set; } public string[] Cards { get; set; } } public enum CardsType { 单牌, 对子, 连子, 顺子, 三带一, 炸弹 }