104 lines
4.1 KiB
Go
104 lines
4.1 KiB
Go
package ai
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"nl-game-api-gin/internal/gamecore/ddz"
|
||
)
|
||
|
||
// llmDdzAI 大模型斗地主 AI:把候选出牌列表交给 LLM 挑选,非法/超时由规则 AI 兜底
|
||
type llmDdzAI struct {
|
||
client *LLMClient // LLM 客户端
|
||
difficulty string // 难度(决定提示词人设与温度)
|
||
fallback DdzAI // 规则 AI 兜底
|
||
name string // 显示名
|
||
}
|
||
|
||
// Name AI 显示名
|
||
func (a *llmDdzAI) Name() string { return a.name }
|
||
|
||
// DecideBid 叫地主:把手牌交给 LLM 判断叫不叫
|
||
func (a *llmDdzAI) DecideBid(hand []int) (bool, string) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||
defer cancel()
|
||
system := styleFor(a.difficulty) + "你在玩斗地主,现在是叫地主阶段。" +
|
||
"只输出 JSON:{\"choice\":0或1,\"say\":\"一句台词\"},0=不叫,1=叫地主。"
|
||
user := fmt.Sprintf("你的手牌:%s\n手牌强度参考分:%d(5分以上算强牌)\n请决定是否叫地主。",
|
||
ddz.CardsName(hand), ddz.HandStrength(hand))
|
||
// 带返回校验与重试的决策调用(传输层自动重试 + 输出非法自动纠错重问)
|
||
if d, err := a.client.ChatDecision(ctx, system, user, temperatureFor(a.difficulty), 1); err == nil {
|
||
return d.Choice == 1, d.Say
|
||
}
|
||
// LLM 多次重试仍失败:走规则兜底
|
||
return a.fallback.DecideBid(hand)
|
||
}
|
||
|
||
// DecideRob 抢地主:直接复用规则 AI 的牌力判断(避免为一次表态多花一轮 LLM 往返)
|
||
func (a *llmDdzAI) DecideRob(hand []int) (bool, string) {
|
||
return a.fallback.DecideRob(hand)
|
||
}
|
||
|
||
// DecidePlay 出牌:服务端生成全部合法候选,LLM 只需选编号(天然保证合法性)
|
||
// 提示词中带入身份与队友信息:平民身份的 LLM 会被引导与另一位平民配合
|
||
func (a *llmDdzAI) DecidePlay(hand []int, last *ddz.Combo, pctx PlayContext) ([]int, string) {
|
||
moves := ddz.GenMoves(hand, last)
|
||
if len(moves) == 0 {
|
||
return nil, "要不起。"
|
||
}
|
||
// 候选清单:0=过牌(跟牌时才有),1..n=具体出牌
|
||
options := []string{}
|
||
canPass := last != nil
|
||
if canPass {
|
||
options = append(options, "0: 过牌(不出)")
|
||
}
|
||
offset := len(options)
|
||
// 候选太多时截取(保留最小若干手 + 最大一手 + 炸弹)避免提示词过长
|
||
shown := moves
|
||
if len(shown) > 12 {
|
||
shown = append(append([][]int{}, moves[:10]...), moves[len(moves)-2:]...)
|
||
}
|
||
for i, mv := range shown {
|
||
combo := ddz.Parse(mv)
|
||
options = append(options, fmt.Sprintf("%d: %s", i+offset, combo.Desc()))
|
||
}
|
||
lastDesc := "你是本轮首家,自由出牌"
|
||
if last != nil {
|
||
lastDesc = "上家出了「" + last.Desc() + "」,你需要压过它"
|
||
if pctx.LastIsPartner {
|
||
lastDesc += "(这是你队友出的牌)"
|
||
} else if !pctx.IsLandlord {
|
||
lastDesc += "(这是地主出的牌)"
|
||
}
|
||
}
|
||
// 身份与配合原则
|
||
role := "你是地主,1 打 2,两位平民会联手对付你。"
|
||
if !pctx.IsLandlord {
|
||
role = fmt.Sprintf(
|
||
"你是平民,与另一位平民组队对抗地主(地主剩 %d 张牌,队友剩 %d 张牌)。"+
|
||
"配合原则:队友的牌尽量不压(除非你能直接出完);队友只剩 1-2 张时优先喂出最小的单张或对子;"+
|
||
"炸弹和大牌留着对付地主,不要浪费在队友身上。",
|
||
pctx.LandlordCards, pctx.PartnerCards)
|
||
}
|
||
system := styleFor(a.difficulty) + "你在玩斗地主。" + role +
|
||
"只输出 JSON:{\"choice\":候选编号,\"say\":\"一句台词\"},不要输出其他内容。"
|
||
user := fmt.Sprintf("你的手牌:%s\n%s\n候选操作:\n%s\n请选择一个候选编号。",
|
||
ddz.CardsName(hand), lastDesc, strings.Join(options, "\n"))
|
||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||
defer cancel()
|
||
// 带返回校验与重试的决策调用(choice 越界或非 JSON 输出会自动纠错重问)
|
||
if d, err := a.client.ChatDecision(ctx, system, user, temperatureFor(a.difficulty), len(options)-1); err == nil {
|
||
if canPass && d.Choice == 0 {
|
||
return nil, d.Say
|
||
}
|
||
idx := d.Choice - offset
|
||
if idx >= 0 && idx < len(shown) {
|
||
return shown[idx], d.Say
|
||
}
|
||
}
|
||
// LLM 多次重试仍失败:走规则兜底
|
||
return a.fallback.DecidePlay(hand, last, pctx)
|
||
}
|