1545 lines
46 KiB
Go
1545 lines
46 KiB
Go
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 // 真人用户ID(AI 座位为 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)
|
||
}
|
||
}
|
||
}
|