Files
nl-game-api/internal/ai/ddz_llm.go
2026-08-14 13:17:03 +08:00

104 lines
4.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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手牌强度参考分%d5分以上算强牌\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)
}