Files
nl-game-api/internal/room/room.go
2026-08-15 09:14:40 +08:00

1545 lines
46 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 room
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"sync"
"time"
"nl-game-api-gin/internal/ai"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/gamecore/billiards"
"nl-game-api-gin/internal/gamecore/ddz"
"nl-game-api-gin/internal/gamecore/ludo"
"nl-game-api-gin/internal/gamecore/monopoly"
"nl-game-api-gin/internal/gamecore/xiangqi"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/service"
)
// 回合时限(超时自动托管行动)
const (
bidTimeout = 20 * time.Second // 斗地主叫牌时限
playTimeout = 30 * time.Second // 斗地主出牌时限
chessTimeout = 45 * time.Second // 象棋走子时限
monoTimeout = 30 * time.Second // 大富翁掷骰/购买时限
ludoTimeout = 30 * time.Second // 飞行棋掷骰/选棋时限
billTimeout = 45 * time.Second // 台球瞄准击球时限
)
// Seat 房间内的一个座位
type Seat struct {
Index int // 座位号斗地主0-2象棋0红1黑
UserID int // 真人用户IDAI 座位为 0
Name string // 显示昵称
Avatar string // 头像
Skin string // 皮肤编码(饥荒组队联机:更衣室选择的皮肤)
IsAI bool // 是否 AI 座位
Ready bool // 是否已准备AI 恒为 true
Online bool // 是否在线(断线重连用)
Auto bool // 真人主动托管中(每回合由规则 AI 代打,结算后自动取消)
client *Client // 关联的连接AI 为 nil
offlineTimer *time.Timer // 饥荒等待中断线保护30秒后仍未重连才清座
}
// chessState 象棋对局状态
type chessState struct {
board xiangqi.Board // 棋盘
turnSide int // 轮到哪方走1红 -1黑
lastMove *xiangqi.Move // 上一步着法(前端高亮)
moveCount int // 总步数(超过上限判和)
}
// Room 一个对战房间(斗地主/象棋/大富翁/飞行棋/台球)
type Room struct {
Code string // 6位邀请码
Game string // doudizhu / chess / monopoly / ludo / billiards
Mode string // pvp / ai
AIProvider string // AI 提供方spark/deepseek/rule
AIDifficulty string // AI 难度easy/medium/hard
mu sync.Mutex // 保护以下全部状态
hub *Hub // 反向引用(结算时清理映射)
seats []*Seat // 座位列表
hostUserID int // 房主用户ID
status string // waiting / playing
ddzGame *ddz.Game // 斗地主对局(仅斗地主房间)
ddzMarks [3]string // 斗地主叫/抢表态气泡("叫地主!"/"不叫"/"抢地主!"/"不抢",重发时清空)
chessGame *chessState // 象棋对局(仅象棋房间)
monoGame *monopoly.Game // 大富翁对局(仅大富翁房间)
monoEvents []monopoly.Event // 大富翁最近一次动作的动画事件(随状态广播)
monoSeq int // 大富翁动作序号(前端凭连续序号判断是否播放动画)
ludoGame *ludo.Game // 飞行棋对局(仅飞行棋房间)
ludoEvents []ludo.Event // 飞行棋最近一次动作的动画事件(随状态广播)
ludoSeq int // 飞行棋动作序号
billGame *billiards.Game // 台球对局(仅台球房间)
animMs int // 最近动作的前端动画时长(毫秒,调度下一回合时预留观看时间)
gameAI ai.DdzAI // 斗地主 AI 实例(按房间配置构造)
chessAI ai.ChessAI // 象棋 AI 实例
escrowDdz ai.DdzAI // 托管用规则 AI超时/离线代打)
escrowChs ai.ChessAI // 托管用规则象棋 AI
turnSeq int // 回合序号(使过期的定时器与 AI 任务失效)
turnDeadln int64 // 当前回合截止时间戳
turnTimer *time.Timer // 回合超时定时器
starveMs int64 // 饥荒:上次转发主机快照的毫秒时间戳(节流保护)
startedAt int64 // 本局开始时间戳
createdAt int64 // 房间创建时间戳
lastActive int64 // 最近活跃时间戳(清理判定)
lastResult map[string]any // 上一局结算信息(房间内展示)
inviteAt map[int]int64 // 对好友最近一次邀请时间userID → unix冷却 60 秒
closed bool // 房间是否已关闭
}
// 同一好友再次邀请的最短间隔
const inviteCooldown = 60 * time.Second
const starveOfflineGrace = 30 * time.Second // 饥荒等待中断线保护30秒内重连不散房
// newRoom 构造房间:按游戏类型确定座位数,按配置构造 AI 实例
func newRoom(code, game, mode, aiProvider, aiDifficulty string) *Room {
if aiProvider == "" {
aiProvider = ai.ProviderRule
}
if aiDifficulty == "" {
aiDifficulty = ai.DiffMedium
}
// 座位数按游戏类型:斗地主 3、象棋/台球 2、大富翁/飞行棋/饥荒 4
seatCount := 3
switch game {
case "chess", "billiards":
seatCount = 2
case "monopoly", "ludo", "starve":
seatCount = 4
}
seats := make([]*Seat, seatCount)
for i := range seats {
seats[i] = &Seat{Index: i}
}
now := time.Now().Unix()
return &Room{
Code: code, Game: game, Mode: mode,
AIProvider: aiProvider, AIDifficulty: aiDifficulty,
seats: seats,
status: "waiting",
gameAI: ai.NewDdzAI(aiProvider, aiDifficulty),
chessAI: ai.NewChessAI(aiProvider, aiDifficulty),
escrowDdz: ai.NewDdzAI(ai.ProviderRule, ai.DiffMedium),
escrowChs: ai.NewChessAI(ai.ProviderRule, ai.DiffEasy),
createdAt: now, lastActive: now,
}
}
// ---------------------------------------------------------------------
// 座位与成员管理
// ---------------------------------------------------------------------
// join 加入房间isHost 表示建房者AI 模式建房时立即用 AI 填满其余座位)
func (r *Room) join(c *Client, isHost bool) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return fmt.Errorf("房间已解散")
}
if r.status == "playing" {
return fmt.Errorf("对局进行中,无法加入")
}
// 找空位
var seat *Seat
for _, s := range r.seats {
if s.UserID == 0 && !s.IsAI {
seat = s
break
}
}
if seat == nil {
return fmt.Errorf("房间已满")
}
seat.UserID = c.userID
seat.Name = c.name
seat.Avatar = c.avatar
seat.Online = true
seat.Ready = isHost // 房主默认准备
seat.client = c
if isHost {
r.hostUserID = c.userID
r.hub = c.hub
// AI 模式:其余座位立即补 AI
if r.Mode == "ai" {
r.fillAISeatsLocked()
}
}
r.touch()
r.broadcastStateLocked()
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 进入了房间", c.name), false)
return nil
}
// fillAISeatsLocked 把所有空位补成 AI 座位(需持锁)
func (r *Room) fillAISeatsLocked() {
// 大富翁/飞行棋/台球使用本地规则 AI名称固定
aiName := "电脑AI"
switch r.Game {
case "doudizhu":
aiName = r.gameAI.Name()
case "chess":
aiName = r.chessAI.Name()
}
idx := 1
for _, s := range r.seats {
if s.UserID == 0 && !s.IsAI {
s.IsAI = true
s.Name = fmt.Sprintf("%s·%d号", aiName, idx)
s.Avatar = "🤖"
s.Ready = true
s.Online = true
idx++
}
}
}
// reattach 断线重连:把新连接绑回原座位并推送最新状态
func (r *Room) reattach(c *Client) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.seats {
if s.UserID == c.userID {
if s.offlineTimer != nil {
s.offlineTimer.Stop()
s.offlineTimer = nil
}
s.client = c
s.Online = true
r.touch()
r.broadcastStateLocked()
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 重新连接", c.name), false)
return
}
}
}
// onOffline 连接断开:等待中直接移出房间(返回 true对局中标记离线等待重连
func (r *Room) onOffline(c *Client) (leftRoom bool) {
r.mu.Lock()
defer r.mu.Unlock()
seat := r.seatByUser(c.userID)
if seat == nil || seat.client != c {
return false
}
seat.client = nil
seat.Online = false
if r.status == "waiting" {
if r.Game == "starve" {
// 饥荒等待中断线保护:保留座位 30 秒,刷新/断线重连成功后取消清理
if seat.offlineTimer != nil {
seat.offlineTimer.Stop()
}
offlineUID, offlineSeat := seat.UserID, seat.Index
seat.offlineTimer = time.AfterFunc(starveOfflineGrace, func() {
c.hub.onStarveOfflineTimeout(r, offlineUID, offlineSeat)
})
r.broadcastStateLocked()
return false
}
r.clearSeatLocked(seat)
r.broadcastStateLocked()
return true
}
// 饥荒组队:主机断线对局无法继续,直接结束通知各自结算;队友断线保留座位等待重连
if r.Game == "starve" {
if seat.UserID == r.hostUserID {
r.broadcastChatLocked(-1, "系统", "", "主机掉线,冒险结束", false)
r.endStarveLocked("主机掉线,冒险结束")
} else {
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 掉线了", seat.Name), false)
}
r.broadcastStateLocked()
return false
}
// 对局中:保留座位等待重连,超时后由托管 AI 代打
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 掉线了,回合超时将自动托管", seat.Name), false)
r.broadcastStateLocked()
return false
}
// endStarveLocked 结束饥荒联机对局:房间回等待状态并广播事件,客户端各自走单机结算(需持锁)
func (r *Room) endStarveLocked(reason string) {
if r.status != "playing" {
return
}
r.status = "waiting"
for _, s := range r.seats {
if s.UserID > 0 {
s.Ready = s.UserID == r.hostUserID
}
}
for _, s := range r.seats {
if s.client != nil {
s.client.push("starve_end", map[string]any{"reason": reason})
}
}
}
// leave 主动退出:对局中视为认输(大富翁/飞行棋按退赛处理,其余玩家继续)
func (r *Room) leave(c *Client) {
r.mu.Lock()
defer r.mu.Unlock()
seat := r.seatByUser(c.userID)
if seat == nil {
return
}
name := seat.Name
if r.status == "playing" {
switch {
case r.Game == "starve":
// 饥荒组队:房主(主机)退出则对局无法继续,通知全员各自结算;队友退出对局继续
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 离开了世界", name), false)
if seat.UserID == r.hostUserID {
r.endStarveLocked("主机离开,冒险结束")
}
case r.Game == "monopoly" && r.monoGame != nil:
// 多人棋盘游戏:退出者按破产退赛处理,对局继续
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 中途退赛", name), false)
r.monoGame.Eliminate(seat.Index)
r.afterMonoActionLocked()
case r.Game == "ludo" && r.ludoGame != nil:
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 中途退赛", name), false)
r.ludoGame.Eliminate(seat.Index)
r.afterLudoActionLocked()
default:
// 双方/三方对局:退出即认输结算
r.settleLocked(r.winnersWithout(seat.Index), fmt.Sprintf("%s 中途退出,对局结束", name))
}
}
r.clearSeatLocked(seat)
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 离开了房间", name), false)
r.broadcastStateLocked()
}
// clearSeatLocked 清空座位;房主离开时移交房主(需持锁)
func (r *Room) clearSeatLocked(seat *Seat) {
uid := seat.UserID
if seat.offlineTimer != nil {
seat.offlineTimer.Stop()
seat.offlineTimer = nil
}
*seat = Seat{Index: seat.Index}
if uid == r.hostUserID {
r.hostUserID = 0
for _, s := range r.seats {
if s.UserID > 0 {
r.hostUserID = s.UserID
s.Ready = true
break
}
}
}
}
// winnersWithout 认输结算辅助:返回除指定座位所在方之外的获胜座位(需持锁)
func (r *Room) winnersWithout(loserSeat int) []int {
winners := []int{}
switch r.Game {
case "chess", "billiards":
winners = append(winners, 1-loserSeat)
return winners
case "monopoly", "ludo":
// 多人棋盘游戏:其余所有已占用座位获胜
for _, s := range r.seats {
if s.Index != loserSeat && (s.UserID > 0 || s.IsAI) {
winners = append(winners, s.Index)
}
}
return winners
}
// 斗地主:退出者是地主 → 两农民胜;退出者是农民 → 地主胜
g := r.ddzGame
if g == nil || g.Landlord < 0 {
// 还没确定地主就退出:其余两家算胜
for i := 0; i < 3; i++ {
if i != loserSeat {
winners = append(winners, i)
}
}
return winners
}
if loserSeat == g.Landlord {
for i := 0; i < 3; i++ {
if i != g.Landlord {
winners = append(winners, i)
}
}
} else {
winners = append(winners, g.Landlord)
}
return winners
}
// seatByUser 按用户ID找座位需持锁
func (r *Room) seatByUser(uid int) *Seat {
for _, s := range r.seats {
if s.UserID == uid && uid > 0 {
return s
}
}
return nil
}
// empty 房间内是否已无真人
func (r *Room) empty() bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.seats {
if s.UserID > 0 {
return false
}
}
return true
}
// humanUserIDs 房间内全部真人用户ID
func (r *Room) humanUserIDs() []int {
r.mu.Lock()
defer r.mu.Unlock()
ids := []int{}
for _, s := range r.seats {
if s.UserID > 0 {
ids = append(ids, s.UserID)
}
}
return ids
}
// expired 房间是否应被清理无人在线超10分钟或创建超6小时
func (r *Room) expired() bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return true
}
now := time.Now().Unix()
anyOnline := false
for _, s := range r.seats {
if s.UserID > 0 && s.Online {
anyOnline = true
}
}
if !anyOnline && now-r.lastActive > 600 {
return true
}
return now-r.createdAt > 6*3600
}
// close 解散房间并通知所有人
func (r *Room) close(reason string) {
r.mu.Lock()
defer r.mu.Unlock()
r.closed = true
if r.turnTimer != nil {
r.turnTimer.Stop()
}
for _, s := range r.seats {
if s.client != nil {
s.client.push("room_closed", map[string]string{"reason": reason})
}
}
}
// touch 更新活跃时间(需持锁)
func (r *Room) touch() { r.lastActive = time.Now().Unix() }
// ---------------------------------------------------------------------
// 消息处理与广播
// ---------------------------------------------------------------------
// handleMessage 处理房间内消息(准备/开始/聊天/游戏动作)
func (r *Room) handleMessage(c *Client, msgType string, raw json.RawMessage) {
r.mu.Lock()
defer r.mu.Unlock()
seat := r.seatByUser(c.userID)
if seat == nil {
c.pushError("你不在该房间的座位上")
return
}
r.touch()
switch msgType {
case "get_state":
r.pushStateLocked(seat)
case "ready":
var req struct {
Ready bool `json:"ready"`
}
json.Unmarshal(raw, &req)
if r.status != "waiting" {
return
}
seat.Ready = req.Ready
r.broadcastStateLocked()
case "start":
r.handleStartLocked(c, seat)
case "skin":
// 饥荒更衣室:等待阶段设置座位皮肤并广播(对局中不可换装)
var req struct {
Skin string `json:"skin"`
}
json.Unmarshal(raw, &req)
if r.status != "waiting" || len(req.Skin) > 32 {
return
}
seat.Skin = req.Skin
r.broadcastStateLocked()
case "starve_input":
// 饥荒队友输入:解析后附上权威座位号转发给房主(主机权威模拟)
if r.Game != "starve" || r.status != "playing" {
return
}
host := r.seatByUser(r.hostUserID)
if host == nil || host.client == nil || seat.UserID == r.hostUserID {
return
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil || payload == nil {
return
}
payload["seat"] = seat.Index // 以服务端座位为准,防伪造
host.client.push("starve_input", payload)
case "starve_state":
// 饥荒主机快照:仅房主可发,原样广播给其余座位
// 全量世界full:1不限流增量快照间隔 <80ms 丢弃防刷
if r.Game != "starve" || r.status != "playing" || seat.UserID != r.hostUserID {
return
}
isFull := false
var peek struct {
Full int `json:"full"`
}
if json.Unmarshal(raw, &peek) == nil && peek.Full != 0 {
isFull = true
}
now := time.Now().UnixMilli()
if !isFull && now-r.starveMs < 80 {
return
}
r.starveMs = now
for _, s := range r.seats {
if s.client != nil && s.UserID != r.hostUserID {
s.client.push("starve_state", raw)
}
}
case "starve_over":
// 饥荒全灭:房主宣告对局结束,房间回等待状态(各端自行走单机结算)
if r.Game != "starve" || seat.UserID != r.hostUserID {
return
}
r.broadcastChatLocked(-1, "系统", "", "全员阵亡,冒险结束", false)
r.endStarveLocked("全员阵亡,冒险结束")
r.broadcastStateLocked()
case "invite":
// 邀请好友进房:仅房主可发,目标必须是自己的已通过好友且不在房内
// 好友在线则实时推弹窗邀请;离线落一条私聊兜底(上线可见)
var req struct {
UserID int `json:"user_id"`
}
json.Unmarshal(raw, &req)
if r.status != "waiting" {
c.pushError("对局进行中,无法邀请")
return
}
if seat.UserID != r.hostUserID {
c.pushError("只有房主可以邀请好友")
return
}
if req.UserID <= 0 || req.UserID == c.userID {
c.pushError("参数有误")
return
}
// 必须是已通过的好友关系
var cnt int64
database.DB.Model(&model.Friend{}).
Where("(user_id = ? AND friend_id = ? OR user_id = ? AND friend_id = ?) AND status = ?",
c.userID, req.UserID, req.UserID, c.userID, model.FriendStatusAccepted).
Count(&cnt)
if cnt == 0 {
c.pushError("只能邀请好友")
return
}
// 不能已在房内
for _, s := range r.seats {
if s.UserID == req.UserID {
c.pushError("对方已在房间内")
return
}
}
// 同一好友 60 秒内不可重复邀请
now := time.Now().Unix()
if r.inviteAt == nil {
r.inviteAt = map[int]int64{}
}
if last, ok := r.inviteAt[req.UserID]; ok {
left := int64(inviteCooldown.Seconds()) - (now - last)
if left > 0 {
c.pushError(fmt.Sprintf("请 %d 秒后再邀请该好友", left))
return
}
}
gameName := r.Game
var g model.Game
if err := database.DB.Where("code = ?", r.Game).First(&g).Error; err == nil && g.Name != "" {
gameName = g.Name
}
inviteData := map[string]any{
"code": r.Code, "game": r.Game, "game_name": gameName,
"host_id": c.userID, "host_name": seat.Name, "host_avatar": seat.Avatar,
}
// 在线实时弹窗;离线降级为私聊消息兜底(下次上线可见)
if !PushToUser(req.UserID, "room_invite", inviteData) {
var peer model.User
if err := database.DB.First(&peer, req.UserID).Error; err == nil {
database.DB.Create(&model.ChatMessage{
FromID: c.userID, ToID: req.UserID,
Content: fmt.Sprintf("邀请你加入房间【%s】邀请码 %s", gameName, r.Code),
})
}
}
r.inviteAt[req.UserID] = now
c.push("invite_sent", map[string]any{"user_id": req.UserID, "cooldown": int(inviteCooldown.Seconds())})
case "chat":
var req struct {
Text string `json:"text"`
}
json.Unmarshal(raw, &req)
if text := trimChat(req.Text); text != "" {
r.broadcastChatLocked(seat.Index, seat.Name, seat.Avatar, text, false)
}
case "ddz_bid":
var req struct {
Call bool `json:"call"`
}
json.Unmarshal(raw, &req)
r.handleBidLocked(seat.Index, req.Call, c)
case "ddz_play":
var req struct {
Cards []int `json:"cards"`
}
json.Unmarshal(raw, &req)
r.handlePlayLocked(seat.Index, req.Cards, c)
case "ddz_rob":
var req struct {
Rob bool `json:"rob"`
}
json.Unmarshal(raw, &req)
r.handleRobLocked(seat.Index, req.Rob, c)
case "ddz_pass":
r.handlePassLocked(seat.Index, c)
case "ddz_hint":
var req struct {
Seq int `json:"seq"`
}
json.Unmarshal(raw, &req)
r.handleDdzHintLocked(seat.Index, req.Seq, c)
case "chess_move":
var m xiangqi.Move
json.Unmarshal(raw, &m)
r.handleChessMoveLocked(seat.Index, m, c)
case "chess_hints":
// 走法提示:返回指定己方棋子的合法落点(新手高亮)
var req struct {
FromR int `json:"from_r"`
FromC int `json:"from_c"`
}
json.Unmarshal(raw, &req)
r.handleChessHintsLocked(seat.Index, req.FromR, req.FromC, c)
case "mono_roll":
r.handleMonoRollLocked(seat.Index, c)
case "mono_buy":
var req struct {
Buy bool `json:"buy"`
}
json.Unmarshal(raw, &req)
r.handleMonoBuyLocked(seat.Index, req.Buy, c)
case "mono_upgrade":
var req struct {
Up bool `json:"up"`
}
json.Unmarshal(raw, &req)
r.handleMonoUpgradeLocked(seat.Index, req.Up, c)
case "ludo_roll":
r.handleLudoRollLocked(seat.Index, c)
case "ludo_move":
var req struct {
Plane int `json:"plane"`
}
json.Unmarshal(raw, &req)
r.handleLudoMoveLocked(seat.Index, req.Plane, c)
case "bill_shot":
var req struct {
Angle float64 `json:"angle"`
Power float64 `json:"power"`
SpinX float64 `json:"spin_x"` // 左右塞 -1~1右塞为正
SpinY float64 `json:"spin_y"` // 高低杆 -1~1高杆为正
}
json.Unmarshal(raw, &req)
r.handleBillShotLocked(seat.Index, req.Angle, req.Power, req.SpinX, req.SpinY, c)
case "escrow":
// 主动托管开关:开启后每回合由托管规则 AI 代打
var req struct {
On bool `json:"on"`
}
json.Unmarshal(raw, &req)
r.handleEscrowLocked(seat, req.On)
case "resign":
// 认输:双人局直接判负结算;大富翁/飞行棋按退赛处理(留座观战)
if r.status != "playing" {
return
}
switch {
case r.Game == "monopoly" && r.monoGame != nil:
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 认输退赛", seat.Name), false)
r.monoGame.Eliminate(seat.Index)
r.afterMonoActionLocked()
case r.Game == "ludo" && r.ludoGame != nil:
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 认输退赛", seat.Name), false)
r.ludoGame.Eliminate(seat.Index)
r.afterLudoActionLocked()
default:
r.settleLocked(r.winnersWithout(seat.Index), fmt.Sprintf("%s 认输,对局结束", seat.Name))
r.broadcastStateLocked()
}
default:
c.pushError("未知的消息类型")
}
}
// handleEscrowLocked 真人切换托管状态(需持锁):
// 开启后每回合由托管规则 AI 自动代打,结算时自动取消
func (r *Room) handleEscrowLocked(seat *Seat, on bool) {
if seat.IsAI || seat.Auto == on {
return
}
seat.Auto = on
if on {
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 开启托管,由系统代打", seat.Name), false)
} else {
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 取消托管,恢复亲自操作", seat.Name), false)
}
r.broadcastStateLocked()
// 开启时恰好轮到自己:立即安排一次代打
if on && r.status == "playing" {
if cur := r.currentSeatLocked(); cur == seat {
seq := r.turnSeq
go func() {
time.Sleep(600 * time.Millisecond)
r.aiActWith(seq, true)
}()
}
}
}
// trimChat 聊天内容清洗:去空格并限制长度
func trimChat(text string) string {
runes := []rune(text)
if len(runes) > 100 {
runes = runes[:100]
}
return string(runes)
}
// handleStartLocked 房主开始游戏:空位自动补 AI人满且都准备后开局需持锁
func (r *Room) handleStartLocked(c *Client, seat *Seat) {
if r.status != "waiting" {
c.pushError("对局已在进行中")
return
}
if c.userID != r.hostUserID {
c.pushError("只有房主可以开始游戏")
return
}
// 除房主外所有真人必须已准备
for _, s := range r.seats {
if s.UserID > 0 && s.UserID != r.hostUserID && !s.Ready {
c.pushError(fmt.Sprintf("%s 还没准备", s.Name))
return
}
}
// 空位补 AI饥荒组队为 1~4 人弹性开局,空位保持空置)
if r.Game != "starve" {
r.fillAISeatsLocked()
}
r.startGameLocked()
}
// broadcastChatLocked 广播聊天消息seat=-1 表示系统消息,需持锁)
func (r *Room) broadcastChatLocked(seatIdx int, name, avatar, text string, isAI bool) {
msg := map[string]any{
"seat": seatIdx, "name": name, "avatar": avatar,
"text": text, "is_ai": isAI, "ts": time.Now().Unix(),
}
for _, s := range r.seats {
if s.client != nil {
s.client.push("chat", msg)
}
}
}
// broadcastStateLocked 向所有真人座位推送各自视角的房间状态(需持锁)
func (r *Room) broadcastStateLocked() {
for _, s := range r.seats {
if s.client != nil {
r.pushStateLocked(s)
}
}
}
// pushStateLocked 向单个座位推送其视角的状态快照(需持锁)
func (r *Room) pushStateLocked(seat *Seat) {
if seat.client == nil {
return
}
seat.client.push("room_state", r.buildStateLocked(seat.Index))
}
// buildStateLocked 构建指定座位视角的状态快照(隐藏他人手牌,需持锁)
func (r *Room) buildStateLocked(viewSeat int) map[string]any {
seats := make([]map[string]any, len(r.seats))
for i, s := range r.seats {
info := map[string]any{
"index": i, "user_id": s.UserID, "name": s.Name, "avatar": s.Avatar,
"is_ai": s.IsAI, "ready": s.Ready, "online": s.Online,
"occupied": s.UserID > 0 || s.IsAI, "auto": s.Auto, "skin": s.Skin,
}
if r.ddzGame != nil {
info["cards_count"] = len(r.ddzGame.Hands[i])
info["is_landlord"] = r.ddzGame.Landlord == i
}
seats[i] = info
}
state := map[string]any{
"code": r.Code, "game": r.Game, "mode": r.Mode,
"ai_provider": r.AIProvider, "ai_difficulty": r.AIDifficulty,
"status": r.status, "host_id": r.hostUserID,
"my_seat": viewSeat, "seats": seats,
"deadline": r.turnDeadln, "last_result": r.lastResult,
}
// 斗地主视角数据
if r.Game == "doudizhu" && r.ddzGame != nil {
g := r.ddzGame
dd := map[string]any{
"phase": string(g.Phase), "turn": g.Turn,
"my_hand": g.Hands[viewSeat], "bombs": g.Bombs,
"landlord": g.Landlord,
// 当前倍数(叫抢×炸弹,春天在结算时另乘)
"multiplier": g.CurrentMultiplier(),
// 叫/抢表态气泡(前端在叫抢阶段显示在座位旁)
"marks": r.ddzMarks[:],
}
// 底牌只在地主确定后公开
if g.Landlord >= 0 {
dd["bottom"] = g.Bottom
}
// 最近一手牌
if g.LastCombo != nil {
dd["last_seat"] = g.LastSeat
dd["last_cards"] = g.LastCombo.Cards
dd["last_desc"] = g.LastCombo.Desc()
} else {
dd["last_seat"] = -1
}
// 当前玩家能否过牌
dd["can_pass"] = g.LastCombo != nil && g.LastSeat != g.Turn
// 出牌历史尾部最多10条
tail := g.History
if len(tail) > 10 {
tail = tail[len(tail)-10:]
}
dd["history"] = tail
state["ddz"] = dd
}
// 象棋视角数据
if r.Game == "chess" && r.chessGame != nil {
cs := r.chessGame
mySide := xiangqi.Red
if viewSeat == 1 {
mySide = xiangqi.Black
}
state["chess"] = map[string]any{
"board": cs.board, "turn_side": cs.turnSide,
"my_side": mySide, "last_move": cs.lastMove,
"in_check": cs.board.InCheck(cs.turnSide),
"move_count": cs.moveCount,
}
}
// 大富翁/飞行棋/台球数据(信息全公开)
if r.Game == "monopoly" && r.monoGame != nil {
state["mono"] = r.monoStateLocked()
}
if r.Game == "ludo" && r.ludoGame != nil {
state["ludo"] = r.ludoStateLocked()
}
if r.Game == "billiards" && r.billGame != nil {
state["bill"] = r.billStateLocked(viewSeat)
}
return state
}
// ---------------------------------------------------------------------
// 对局流程(开局 → 回合调度 → 动作处理 → 结算)
// ---------------------------------------------------------------------
// startGameLocked 初始化对局并开始第一回合(需持锁)
func (r *Room) startGameLocked() {
r.status = "playing"
r.startedAt = time.Now().Unix()
r.lastResult = nil
switch r.Game {
case "doudizhu":
r.ddzGame = ddz.NewGame()
r.ddzMarks = [3]string{}
case "chess":
r.chessGame = &chessState{board: xiangqi.Initial(), turnSide: xiangqi.Red}
case "monopoly":
r.monoGame = monopoly.NewGame(len(r.seats))
r.monoEvents, r.monoSeq = nil, 0
case "ludo":
r.ludoGame = ludo.NewGame()
r.ludoEvents, r.ludoSeq = nil, 0
case "billiards":
r.billGame = billiards.NewGame()
case "starve":
// 饥荒:主机权威模拟在房主端进行,服务端只做转发,无引擎与回合调度
r.starveMs = 0
r.broadcastChatLocked(-1, "系统", "", "冒险开始!一起活下去!", false)
r.broadcastStateLocked()
return
}
r.animMs = 0
r.broadcastChatLocked(-1, "系统", "", "对局开始!", false)
r.scheduleTurnLocked()
r.broadcastStateLocked()
}
// currentSeatLocked 当前该行动的座位(需持锁)
func (r *Room) currentSeatLocked() *Seat {
if r.status != "playing" {
return nil
}
switch r.Game {
case "doudizhu":
if r.ddzGame != nil {
return r.seats[r.ddzGame.Turn]
}
case "chess":
if r.chessGame != nil {
if r.chessGame.turnSide == xiangqi.Red {
return r.seats[0]
}
return r.seats[1]
}
case "monopoly":
if r.monoGame != nil {
return r.seats[r.monoGame.Turn]
}
case "ludo":
if r.ludoGame != nil {
return r.seats[r.ludoGame.Turn]
}
case "billiards":
if r.billGame != nil {
return r.seats[r.billGame.Turn]
}
}
return nil
}
// turnDuration 当前回合时限(需持锁)
func (r *Room) turnDuration() time.Duration {
switch r.Game {
case "chess":
return chessTimeout
case "monopoly":
return monoTimeout
case "ludo":
return ludoTimeout
case "billiards":
return billTimeout
}
if r.ddzGame != nil && (r.ddzGame.Phase == ddz.PhaseBidding || r.ddzGame.Phase == ddz.PhaseRobbing) {
return bidTimeout
}
return playTimeout
}
// scheduleTurnLocked 开启新回合重置超时定时器AI 座位安排延迟行动(需持锁)
func (r *Room) scheduleTurnLocked() {
r.turnSeq++
seq := r.turnSeq
dur := r.turnDuration()
// 上一动作的前端动画还在播放(台球回放/大富翁走格/飞行棋飞行),超时与 AI 行动都预留观看时间
extra := time.Duration(0)
if r.animMs > 0 {
extra = time.Duration(r.animMs) * time.Millisecond
r.animMs = 0
}
dur += extra
r.turnDeadln = time.Now().Add(dur).Unix()
if r.turnTimer != nil {
r.turnTimer.Stop()
}
r.turnTimer = time.AfterFunc(dur, func() { r.onTimeout(seq) })
// AI 座位1.2~2.7 秒后行动(模拟思考)
seat := r.currentSeatLocked()
if seat != nil && seat.IsAI {
delay := time.Duration(1200+rand.Intn(1500))*time.Millisecond + extra
go func() {
time.Sleep(delay)
r.aiAct(seq)
}()
}
// 真人开启托管:短暂延迟后由托管规则 AI 代打
if seat != nil && !seat.IsAI && seat.Auto {
delay := time.Duration(900+rand.Intn(600))*time.Millisecond + extra
go func() {
time.Sleep(delay)
r.aiActWith(seq, true)
}()
}
}
// onTimeout 回合超时:由托管规则 AI 代替当前座位行动
func (r *Room) onTimeout(seq int) {
r.mu.Lock()
if r.closed || r.status != "playing" || seq != r.turnSeq {
r.mu.Unlock()
return
}
seat := r.currentSeatLocked()
if seat == nil {
r.mu.Unlock()
return
}
if !seat.IsAI {
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 超时,本回合自动托管", seat.Name), false)
}
r.mu.Unlock()
// 超时托管与 AI 行动共用一条路径(用托管规则 AI
r.aiActWith(seq, true)
}
// aiAct AI 座位的正常行动(用房间配置的 AI 实例)
func (r *Room) aiAct(seq int) { r.aiActWith(seq, false) }
// aiActWith AI 决策并执行escrow=true 时使用托管规则 AI
// 决策阶段不持锁LLM 可能耗时数秒),执行前重新校验回合有效性
func (r *Room) aiActWith(seq int, escrow bool) {
// 第一步:持锁快照当前局面
r.mu.Lock()
if r.closed || r.status != "playing" || seq != r.turnSeq {
r.mu.Unlock()
return
}
seat := r.currentSeatLocked()
if seat == nil {
r.mu.Unlock()
return
}
seatIdx := seat.Index
game := r.Game
// 大富翁/飞行棋/台球:本地规则 AI 决策瞬时完成,直接持锁执行后返回
switch game {
case "monopoly":
r.aiMonoActLocked(seatIdx, escrow)
r.mu.Unlock()
return
case "ludo":
r.aiLudoActLocked(seatIdx, escrow)
r.mu.Unlock()
return
case "billiards":
r.aiBillActLocked(seatIdx, escrow)
r.mu.Unlock()
return
}
var (
ddzHand []int
ddzLast *ddz.Combo
ddzPhase ddz.Phase
ddzCtx ai.PlayContext
chessBoard xiangqi.Board
chessSide int
)
if game == "doudizhu" {
g := r.ddzGame
ddzHand = append([]int{}, g.Hands[seatIdx]...)
ddzLast = g.LastCombo
ddzPhase = g.Phase
ddzCtx = r.ddzPlayCtxLocked(seatIdx)
} else {
cs := r.chessGame
chessBoard = cs.board
chessSide = cs.turnSide
}
// 选择决策器:正常 AI 或托管规则 AI
ddzBrain := r.gameAI
chessBrain := r.chessAI
if escrow {
ddzBrain = r.escrowDdz
chessBrain = r.escrowChs
}
r.mu.Unlock()
// 第二步锁外决策LLM 最长十余秒)
var (
bidCall bool
robCall bool
playCards []int
say string
chessMv xiangqi.Move
)
if game == "doudizhu" {
switch ddzPhase {
case ddz.PhaseBidding:
bidCall, say = ddzBrain.DecideBid(ddzHand)
case ddz.PhaseRobbing:
robCall, say = ddzBrain.DecideRob(ddzHand)
default:
playCards, say = ddzBrain.DecidePlay(ddzHand, ddzLast, ddzCtx)
}
} else {
chessMv, say = chessBrain.DecideMove(&chessBoard, chessSide)
}
// 第三步:持锁校验回合仍有效后执行
r.mu.Lock()
defer r.mu.Unlock()
if r.closed || r.status != "playing" || seq != r.turnSeq {
return
}
seat = r.seats[seatIdx]
// AI 台词随聊天下发
if say != "" {
r.broadcastChatLocked(seatIdx, seat.Name, seat.Avatar, say, true)
}
if game == "doudizhu" {
if ddzPhase == ddz.PhaseBidding {
r.applyBidLocked(seatIdx, bidCall)
} else if ddzPhase == ddz.PhaseRobbing {
r.applyRobLocked(seatIdx, robCall)
} else if playCards == nil {
// 托管/AI 选择过牌;不能过时强制出最小牌
if err := r.ddzGame.Pass(seatIdx); err != nil {
if moves := ddz.GenMoves(r.ddzGame.Hands[seatIdx], r.ddzGame.LastCombo); len(moves) > 0 {
r.applyPlayLocked(seatIdx, moves[0])
return
}
} else {
r.afterDdzActionLocked(seatIdx, nil)
}
} else {
// AI 决策基于快照,执行前再校验一次合法性,失败则出最小牌兜底
if _, err := r.tryPlayLocked(seatIdx, playCards); err != nil {
if moves := ddz.GenMoves(r.ddzGame.Hands[seatIdx], r.ddzGame.LastCombo); len(moves) > 0 {
r.applyPlayLocked(seatIdx, moves[0])
} else if r.ddzGame.LastCombo != nil {
r.ddzGame.Pass(seatIdx)
r.afterDdzActionLocked(seatIdx, nil)
}
}
}
} else {
// 象棋:校验合法性,非法则规则 AI 重算
if !r.chessGame.board.IsLegal(chessMv, chessSide) {
chessMv, _ = r.escrowChs.DecideMove(&r.chessGame.board, chessSide)
}
r.applyChessMoveLocked(seatIdx, chessMv)
}
}
// ---------------------------------------------------------------------
// 斗地主动作
// ---------------------------------------------------------------------
// handleBidLocked 真人叫地主(需持锁)
func (r *Room) handleBidLocked(seatIdx int, call bool, c *Client) {
if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhaseBidding {
c.pushError("当前不在叫地主阶段")
return
}
if r.ddzGame.Turn != seatIdx {
c.pushError("还没轮到你表态")
return
}
r.applyBidLocked(seatIdx, call)
}
// applyBidLocked 执行叫地主表态并推进流程(需持锁)
func (r *Room) applyBidLocked(seatIdx int, call bool) {
seat := r.seats[seatIdx]
redeal, err := r.ddzGame.Bid(seatIdx, call)
if err != nil {
return
}
action := "不叫"
if call {
action = "叫地主!"
}
r.ddzMarks[seatIdx] = action
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s%s", seat.Name, action), false)
if redeal {
// 三家都不叫:重新发牌重新叫,表态气泡一并清空
r.broadcastChatLocked(-1, "系统", "", "三家都不叫,重新发牌", false)
r.ddzGame = ddz.NewGame()
r.ddzMarks = [3]string{}
} else if r.ddzGame.Phase == ddz.PhaseRobbing {
r.broadcastChatLocked(-1, "系统", "", "进入抢地主环节其余两家可以抢每抢一次倍数×2", false)
}
r.scheduleTurnLocked()
r.broadcastStateLocked()
}
// handleRobLocked 真人抢地主表态(需持锁)
func (r *Room) handleRobLocked(seatIdx int, rob bool, c *Client) {
if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhaseRobbing {
c.pushError("当前不在抢地主阶段")
return
}
if r.ddzGame.Turn != seatIdx {
c.pushError("还没轮到你表态")
return
}
r.applyRobLocked(seatIdx, rob)
}
// applyRobLocked 执行抢地主表态并推进流程(需持锁)
func (r *Room) applyRobLocked(seatIdx int, rob bool) {
seat := r.seats[seatIdx]
if err := r.ddzGame.Rob(seatIdx, rob); err != nil {
return
}
if rob {
r.ddzMarks[seatIdx] = "抢地主!"
r.broadcastChatLocked(-1, "系统", "",
fmt.Sprintf("%s抢地主倍数翻倍当前 %d 倍)", seat.Name, r.ddzGame.Multiplier), false)
} else {
r.ddzMarks[seatIdx] = "不抢"
r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s不抢", seat.Name), false)
}
if r.ddzGame.Phase == ddz.PhasePlaying {
r.broadcastChatLocked(-1, "系统", "",
fmt.Sprintf("%s 成为地主,亮出底牌", r.seats[r.ddzGame.Landlord].Name), false)
}
r.scheduleTurnLocked()
r.broadcastStateLocked()
}
// handlePlayLocked 真人出牌(需持锁)
func (r *Room) handlePlayLocked(seatIdx int, cards []int, c *Client) {
if _, err := r.tryPlayLocked(seatIdx, cards); err != nil {
c.pushError(err.Error())
}
}
// tryPlayLocked 尝试出牌,成功后推进流程(需持锁)
func (r *Room) tryPlayLocked(seatIdx int, cards []int) (*ddz.Combo, error) {
if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhasePlaying {
return nil, fmt.Errorf("当前不在出牌阶段")
}
combo, err := r.ddzGame.Play(seatIdx, cards)
if err != nil {
return nil, err
}
r.afterDdzActionLocked(seatIdx, combo)
return combo, nil
}
// applyPlayLocked 直接出牌AI 兜底路径,忽略错误,需持锁)
func (r *Room) applyPlayLocked(seatIdx int, cards []int) {
if combo, err := r.ddzGame.Play(seatIdx, cards); err == nil {
r.afterDdzActionLocked(seatIdx, combo)
}
}
// handlePassLocked 真人过牌(需持锁)
func (r *Room) handlePassLocked(seatIdx int, c *Client) {
if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhasePlaying {
c.pushError("当前不在出牌阶段")
return
}
if err := r.ddzGame.Pass(seatIdx); err != nil {
c.pushError(err.Error())
return
}
r.afterDdzActionLocked(seatIdx, nil)
}
// ddzPlayCtxLocked 组装斗地主出牌决策上下文(需持锁):身份与各家剩牌
func (r *Room) ddzPlayCtxLocked(seatIdx int) ai.PlayContext {
g := r.ddzGame
pctx := ai.PlayContext{}
if g == nil || g.Landlord < 0 {
return pctx
}
lord := g.Landlord
pctx.IsLandlord = seatIdx == lord
pctx.LandlordCards = len(g.Hands[lord])
if pctx.IsLandlord {
// 地主视角:敌方是两位平民,取剩牌最少者
for i := 0; i < 3; i++ {
if i == seatIdx {
continue
}
if n := len(g.Hands[i]); pctx.OppMin == 0 || n < pctx.OppMin {
pctx.OppMin = n
}
}
} else {
// 平民视角:队友是另一位平民(三座位号之和为 3敌方只有地主
partner := 3 - seatIdx - lord
pctx.PartnerCards = len(g.Hands[partner])
pctx.OppMin = len(g.Hands[lord])
pctx.LastIsPartner = g.LastCombo != nil && g.LastSeat == partner
}
return pctx
}
// handleDdzHintLocked 出牌提示需持锁seq=0 返回配合策略推荐的一手,
// 连续点击seq 递增)时按牌力从小到大轮换全部可压候选
func (r *Room) handleDdzHintLocked(seatIdx, seq int, c *Client) {
g := r.ddzGame
if g == nil || r.status != "playing" || g.Phase != ddz.PhasePlaying {
c.pushError("当前不在出牌阶段")
return
}
if g.Turn != seatIdx {
c.pushError("还没轮到你出牌")
return
}
moves := ddz.GenMoves(g.Hands[seatIdx], g.LastCombo)
if len(moves) == 0 {
// 没有任何能压的牌:告知只能过
c.push("ddz_hint", map[string]any{"none": true})
return
}
if seq <= 0 {
// 首次提示:走托管规则 AI带平民配合可能建议过牌
hand := append([]int{}, g.Hands[seatIdx]...)
pick, _ := r.escrowDdz.DecidePlay(hand, g.LastCombo, r.ddzPlayCtxLocked(seatIdx))
if pick == nil && g.LastCombo != nil {
c.push("ddz_hint", map[string]any{"suggest_pass": true})
return
}
if pick != nil {
c.push("ddz_hint", map[string]any{"cards": pick})
return
}
}
idx := seq
if idx < 0 {
idx = 0
}
c.push("ddz_hint", map[string]any{"cards": moves[idx%len(moves)]})
}
// afterDdzActionLocked 斗地主动作后的统一收尾:结束判定或进入下一回合(需持锁)
func (r *Room) afterDdzActionLocked(seatIdx int, combo *ddz.Combo) {
g := r.ddzGame
if g.Phase == ddz.PhaseOver {
// 结算:地主胜或农民胜,描述带上春天与总倍数
winners := []int{}
desc := ""
if g.LandlordWon() {
winners = append(winners, g.Landlord)
desc = fmt.Sprintf("地主 %s 获胜!", r.seats[g.Landlord].Name)
if g.SpringMultiplier() == 2 {
desc += "春天!农民一张牌都没出!"
}
} else {
for i := 0; i < 3; i++ {
if i != g.Landlord {
winners = append(winners, i)
}
}
desc = "农民配合获胜!"
if g.SpringMultiplier() == 2 {
desc += "反春!地主只出了一手牌!"
}
}
if m := g.TotalMultiplier(); m > 1 {
desc += fmt.Sprintf("%d 倍结算:叫抢 ×%d、炸弹 ×%d、春天 ×%d",
m, g.Multiplier, 1<<min(g.Bombs, 6), g.SpringMultiplier())
}
r.settleLocked(winners, desc)
} else {
r.scheduleTurnLocked()
}
r.broadcastStateLocked()
}
// ---------------------------------------------------------------------
// 象棋动作
// ---------------------------------------------------------------------
// handleChessMoveLocked 真人走子(需持锁)
func (r *Room) handleChessMoveLocked(seatIdx int, m xiangqi.Move, c *Client) {
if r.status != "playing" || r.chessGame == nil {
c.pushError("对局未开始")
return
}
cs := r.chessGame
mySide := xiangqi.Red
if seatIdx == 1 {
mySide = xiangqi.Black
}
if cs.turnSide != mySide {
c.pushError("还没轮到你走棋")
return
}
if !cs.board.IsLegal(m, mySide) {
c.pushError("不符合规则的着法")
return
}
r.applyChessMoveLocked(seatIdx, m)
}
// handleChessHintsLocked 走法提示(需持锁):返回指定己方棋子的全部合法落点,
// 新手模式高亮用;复用 LegalMoves含蹩马腿、塞象眼、送将与对脸过滤
func (r *Room) handleChessHintsLocked(seatIdx, fromR, fromC int, c *Client) {
if r.status != "playing" || r.chessGame == nil {
return
}
mySide := xiangqi.Red
if seatIdx == 1 {
mySide = xiangqi.Black
}
// 在棋盘副本上生成着法,避免在共享状态上做临时试走
board := r.chessGame.board
targets := []map[string]any{}
for _, m := range board.LegalMoves(mySide) {
if m.FromR != fromR || m.FromC != fromC {
continue
}
targets = append(targets, map[string]any{
"r": m.ToR, "c": m.ToC,
// 目标点有子即为吃子LegalMoves 已保证只会是敌子)
"capture": board[m.ToR][m.ToC] != 0,
})
}
c.push("chess_hints", map[string]any{
"from_r": fromR, "from_c": fromC, "targets": targets,
})
}
// applyChessMoveLocked 执行着法并推进流程(需持锁,调用方保证合法)
func (r *Room) applyChessMoveLocked(seatIdx int, m xiangqi.Move) {
cs := r.chessGame
cs.board.Apply(m)
cs.lastMove = &m
cs.moveCount++
cs.turnSide = -cs.turnSide
// 被将军提示
if cs.board.InCheck(cs.turnSide) {
r.broadcastChatLocked(-1, "系统", "", "将军!", false)
}
// 胜负判定:走子方无合法着法即负
if cs.board.GameOver(cs.turnSide) {
winner := seatIdx
desc := fmt.Sprintf("%s 获胜(将死对方)!", r.seats[winner].Name)
r.settleLocked([]int{winner}, desc)
r.broadcastStateLocked()
return
}
// 步数上限判和(防止无限对局)
if cs.moveCount >= 300 {
r.settleLocked(nil, "步数达到上限,双方战平")
r.broadcastStateLocked()
return
}
r.scheduleTurnLocked()
r.broadcastStateLocked()
}
// ---------------------------------------------------------------------
// 结算
// ---------------------------------------------------------------------
// settleLocked 对局结算:发积分、写对战记录、重置房间为等待状态(需持锁)
// winners 为获胜座位列表nil=平局)
func (r *Room) settleLocked(winners []int, desc string) {
if r.status != "playing" {
return
}
r.status = "waiting"
if r.turnTimer != nil {
r.turnTimer.Stop()
}
r.turnSeq++ // 使遗留的定时器与 AI 任务全部失效
r.turnDeadln = 0
duration := int(time.Now().Unix() - r.startedAt)
winSet := map[int]bool{}
for _, w := range winners {
winSet[w] = true
}
// 查游戏ID与奖励配置
var game model.Game
database.DB.Where("code = ?", r.Game).First(&game)
winPoints := service.GetConfigInt(model.ConfKeyBattleWinPoints, 30)
// 斗地主按倍数放大胜利积分(叫抢 × 炸弹 × 春天,牌局核心已封顶 64 倍)
if r.Game == "doudizhu" && r.ddzGame != nil {
winPoints *= r.ddzGame.TotalMultiplier()
}
mode := model.BattleModePVP
aiProvider, aiDiff := "", ""
if r.Mode == "ai" {
mode = model.BattleModeAI
aiProvider, aiDiff = r.AIProvider, r.AIDifficulty
}
rewards := map[int]int{}
// 给每个真人座位写对战记录与积分
for _, s := range r.seats {
if s.UserID <= 0 {
continue
}
result := model.BattleResultLose
points := 0
if winners == nil {
result = model.BattleResultDraw
} else if winSet[s.Index] {
result = model.BattleResultWin
points = winPoints
}
if points > 0 {
// 发放失败时不能让结算面板与对战记录显示"已获奖"的假象
if _, err := service.ChangePoints(nil, s.UserID, points, model.PointTypeBattle, 0,
fmt.Sprintf("「%s」对战获胜奖励", game.Name)); err != nil {
log.Printf("[结算] 用户 %d 对战奖励 %d 积分发放失败: %v", s.UserID, points, err)
points = 0
} else {
rewards[s.Index] = points
}
}
database.DB.Create(&model.BattleRecord{
UserID: s.UserID, GameID: game.ID, RoomCode: r.Code,
Mode: mode, AIProvider: aiProvider, AIDifficulty: aiDiff,
Result: result, PointsChange: points, Duration: duration, Detail: desc,
})
}
// 组装结算信息(房间内展示)+ 重置准备状态
winnerNames := []string{}
for _, w := range winners {
winnerNames = append(winnerNames, r.seats[w].Name)
}
r.lastResult = map[string]any{
"desc": desc, "winners": winners, "winner_names": winnerNames,
"rewards": rewards, "duration": duration,
}
for _, s := range r.seats {
if s.UserID > 0 {
s.Ready = s.UserID == r.hostUserID // 房主保持准备
}
s.Auto = false // 托管随对局结束自动取消
}
r.broadcastChatLocked(-1, "系统", "", desc, false)
// 广播结算事件(前端弹结算面板)
for _, s := range r.seats {
if s.client != nil {
s.client.push("battle_end", r.lastResult)
}
}
}