賽博鬥地主——使用大語言模型扮演Agent智慧體玩牌類遊戲。

a1010發表於2024-06-05

透過大模型來實現多個智慧體進行遊戲對局這個想對已經比較成熟了無論是去年驚豔的史丹佛小鎮還是比如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
{
    單牌, 對子, 連子, 順子, 三帶一, 炸彈
}

相關文章