diff --git a/go.mod b/go.mod index 3bfb86f922..f65fc813ac 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/FloatTech/sqlite v1.6.2 github.com/FloatTech/ttl v0.0.0-20220715042055-15612be72f5b github.com/FloatTech/zbpctrl v1.5.3-0.20230514154630-b74e6fcca380 - github.com/FloatTech/zbputils v1.6.2-0.20230728081122-94d4d957f3bf + github.com/FloatTech/zbputils v1.6.2-0.20230831134542-28c5ba506758 github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5 github.com/antchfx/htmlquery v1.2.5 @@ -31,6 +31,7 @@ require ( github.com/jozsefsallai/gophersauce v1.0.1 github.com/lithammer/fuzzysearch v1.1.5 github.com/mroth/weightedrand v1.0.0 + github.com/notnil/chess v1.9.0 github.com/pkg/errors v0.9.1 github.com/quic-go/quic-go v0.38.1 github.com/shirou/gopsutil/v3 v3.23.1 @@ -46,6 +47,7 @@ require ( ) require ( + github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca // indirect github.com/antchfx/xpath v1.2.1 // indirect github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 // indirect github.com/faiface/beep v1.1.0 // indirect diff --git a/go.sum b/go.sum index 0d2831ea7e..d874e87fd9 100644 --- a/go.sum +++ b/go.sum @@ -20,11 +20,15 @@ github.com/FloatTech/zbpctrl v1.5.3-0.20230514154630-b74e6fcca380 h1:qmwoT8xVaND github.com/FloatTech/zbpctrl v1.5.3-0.20230514154630-b74e6fcca380/go.mod h1:gkGC1C1eEUd/Ld/ja68zas5j2ZktIZCdnj2FMaM+Au0= github.com/FloatTech/zbputils v1.6.2-0.20230728081122-94d4d957f3bf h1:PwH9aMnmN+m204cVIqUrI3e7nsdQi/IGW012Fjzb1bs= github.com/FloatTech/zbputils v1.6.2-0.20230728081122-94d4d957f3bf/go.mod h1:JRnGR7EGeEQgxOs+c0rZAhrS9Es2BTcGHdIDHXIPRzQ= +github.com/FloatTech/zbputils v1.6.2-0.20230831134542-28c5ba506758 h1:z0hhIwGN8ifKExa6xkujZwAQwJNU6AnELt+/A6nAdcY= +github.com/FloatTech/zbputils v1.6.2-0.20230831134542-28c5ba506758/go.mod h1:JRnGR7EGeEQgxOs+c0rZAhrS9Es2BTcGHdIDHXIPRzQ= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e h1:wR3MXQ3VbUlPKOOUwLOYgh/QaJThBTYtsl673O3lqSA= github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e/go.mod h1:vD7Ra3Q9onRtojoY5sMCLQ7JBgjUsrXDnDKyFxqpf9w= github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5 h1:bBmmB7he0iVN4m5mcehfheeRUEer/Avo4ujnxI3uCqs= github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5/go.mod h1:0UcFaCkhp6vZw6l5Dpq0Dp673CoF9GdvA8lTfst0GiU= +github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca h1:kWzLcty5V2rzOqJM7Tp/MfSX0RMSI1x4IOLApEefYxA= +github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/antchfx/htmlquery v1.2.5 h1:1lXnx46/1wtv1E/kzmH8vrfMuUKYgkdDBA9pIdMJnk4= github.com/antchfx/htmlquery v1.2.5/go.mod h1:2MCVBzYVafPBmKbrmwB9F5xdd+IEgRY61ci2oOsOQVw= @@ -152,6 +156,8 @@ github.com/mroth/weightedrand v1.0.0 h1:V8JeHChvl2MP1sAoXq4brElOcza+jxLkRuwvtQu8 github.com/mroth/weightedrand v1.0.0/go.mod h1:3p2SIcC8al1YMzGhAIoXD+r9olo/g/cdJgAD905gyNE= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/notnil/chess v1.9.0 h1:YMxR5kUVjtwcuFptGU0/3q7eG3MSHQNbg0VUekvRKV0= +github.com/notnil/chess v1.9.0/go.mod h1:cRuJUIBFq9Xki05TWHJxHYkC+fFpq45IWwk94DdlCrA= github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= diff --git a/main.go b/main.go index 15c4f10439..043fa850b8 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,7 @@ import ( _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bilibili" // b站相关 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/book_review" // 哀伤雪刃吧推书记录 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/cangtoushi" // 藏头诗 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chess" // 国际象棋 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/choose" // 选择困难症帮手 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chouxianghua" // 说抽象话 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chrev" // 英文字符翻转 diff --git a/plugin/chess/chess.go b/plugin/chess/chess.go new file mode 100644 index 0000000000..ec4552c152 --- /dev/null +++ b/plugin/chess/chess.go @@ -0,0 +1,165 @@ +// Package chess 国际象棋 +package chess + +import ( + "fmt" + "os" + "path" + "strconv" + "strings" + "time" + + ctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + "github.com/FloatTech/zbputils/ctxext" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/extension/single" + "github.com/wdvxdr1123/ZeroBot/message" +) + +const helpString = `- 参与/创建一盘游戏:「下棋」(chess) +- 参与/创建一盘盲棋:「盲棋」(blind) +- 投降认输:「认输」 (resign) +- 请求、接受和棋:「和棋」 (draw) +- 走棋:!Nxf3 中英文感叹号均可,格式请参考“代数记谱法”(Algebraic notation) +- 中断对局:「中断」 (abort)(仅群主/管理员有效) +- 查看等级分排行榜:「排行榜」(ranking) +- 查看自己的等级分:「等级分」(rate) +- 清空等级分:「清空等级分 QQ号」(.clean.rate) (仅超管有效)` + +var ( + limit = ctxext.NewLimiterManager(time.Microsecond*2500, 1) + tempFileDir string + engine = control.Register("chess", &ctrl.Options[*zero.Ctx]{ + DisableOnDefault: false, + Brief: "国际象棋", + Help: helpString, + PrivateDataFolder: "chess", + }).ApplySingle(single.New( + single.WithKeyFn(func(ctx *zero.Ctx) int64 { return ctx.Event.GroupID }), + single.WithPostFn[int64](func(ctx *zero.Ctx) { + ctx.Send( + message.ReplyWithMessage(ctx.Event.MessageID, + message.Text("有操作正在执行, 请稍后再试..."), + ), + ) + }), + )) +) + +func init() { + // 初始化临时文件夹 + tempFileDir = path.Join(engine.DataFolder(), "temp") + err := os.MkdirAll(tempFileDir, 0750) + if err != nil { + panic(err) + } + // 初始化数据库 + dbFilePath := engine.DataFolder() + "chess.db" + initDatabase(dbFilePath) + // 注册指令 + engine.OnFullMatchGroup([]string{"下棋", "chess"}, zero.OnlyGroup). + SetBlock(true). + Limit(limit.LimitByGroup). + Handle(func(ctx *zero.Ctx) { + if ctx.Event.Sender == nil { + return + } + userUin := ctx.Event.UserID + userName := ctx.Event.Sender.NickName + groupCode := ctx.Event.GroupID + if replyMessage := game(groupCode, userUin, userName); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnFullMatchGroup([]string{"认输", "resign"}, zero.OnlyGroup). + SetBlock(true). + Limit(limit.LimitByGroup). + Handle(func(ctx *zero.Ctx) { + userUin := ctx.Event.UserID + groupCode := ctx.Event.GroupID + if replyMessage := resign(groupCode, userUin); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnFullMatchGroup([]string{"和棋", "draw"}, zero.OnlyGroup). + SetBlock(true). + Limit(limit.LimitByGroup). + Handle(func(ctx *zero.Ctx) { + userUin := ctx.Event.UserID + groupCode := ctx.Event.GroupID + if replyMessage := draw(groupCode, userUin); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnFullMatchGroup([]string{"中断", "abort"}, zero.OnlyGroup, zero.AdminPermission). + SetBlock(true). + Limit(limit.LimitByGroup). + Handle(func(ctx *zero.Ctx) { + groupCode := ctx.Event.GroupID + if replyMessage := abort(groupCode); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnFullMatchGroup([]string{"盲棋", "blind"}, zero.OnlyGroup). + SetBlock(true). + Limit(limit.LimitByGroup). + Handle(func(ctx *zero.Ctx) { + if ctx.Event.Sender == nil { + return + } + userUin := ctx.Event.UserID + userName := ctx.Event.Sender.NickName + groupCode := ctx.Event.GroupID + if replyMessage := blindfold(groupCode, userUin, userName); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnRegex("^[!|!]([0-8]|[R|N|B|Q|K|O|a-h|x]|[-|=|+])+$", zero.OnlyGroup). + SetBlock(true). + Limit(limit.LimitByGroup). + Handle(func(ctx *zero.Ctx) { + userUin := ctx.Event.UserID + groupCode := ctx.Event.GroupID + userMsgStr := ctx.State["regex_matched"].([]string)[0] + moveStr := strings.TrimPrefix(strings.TrimPrefix(userMsgStr, "!"), "!") + if replyMessage := play(userUin, groupCode, moveStr); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnFullMatchGroup([]string{"排行榜", "ranking"}). + SetBlock(true). + Limit(limit.LimitByUser). + Handle(func(ctx *zero.Ctx) { + if replyMessage := ranking(); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnFullMatchGroup([]string{"等级分", "rate"}). + SetBlock(true). + Limit(limit.LimitByUser). + Handle(func(ctx *zero.Ctx) { + if ctx.Event.Sender == nil { + return + } + userUin := ctx.Event.UserID + userName := ctx.Event.Sender.NickName + if replyMessage := rate(userUin, userName); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) + engine.OnPrefixGroup([]string{"清空等级分", ".clean.rate"}, zero.SuperUserPermission). + SetBlock(true). + Limit(limit.LimitByUser). + Handle(func(ctx *zero.Ctx) { + args := ctx.State["args"].(string) + playerUin, err := strconv.ParseInt(strings.TrimSpace(args), 10, 64) + if err != nil || playerUin <= 0 { + ctx.Send(fmt.Sprintf("解析失败「%s」不是正确的 QQ 号。", args)) + return + } + if replyMessage := cleanUserRate(playerUin); len(replyMessage) >= 1 { + ctx.Send(replyMessage) + } + }) +} diff --git a/plugin/chess/core.go b/plugin/chess/core.go new file mode 100644 index 0000000000..a5a85260ab --- /dev/null +++ b/plugin/chess/core.go @@ -0,0 +1,707 @@ +package chess + +import ( + "bytes" + "encoding/base64" + "fmt" + "image/color" + "io" + "os" + "os/exec" + "path" + "strings" + "time" + + "github.com/FloatTech/floatbox/binary" + "github.com/FloatTech/floatbox/file" + "github.com/FloatTech/gg" + "github.com/FloatTech/zbputils/control" + "github.com/RomiChan/syncx" + "github.com/jinzhu/gorm" + "github.com/notnil/chess" + "github.com/notnil/chess/image" + log "github.com/sirupsen/logrus" + "github.com/wdvxdr1123/ZeroBot/message" +) + +const eloDefault = 500 + +var chessRoomMap syncx.Map[int64, *chessRoom] + +type chessRoom struct { + chessGame *chess.Game + whitePlayer int64 + whiteName string + blackPlayer int64 + blackName string + drawPlayer int64 + lastMoveTime int64 + isBlindfold bool + whiteErr bool // 违例记录(盲棋用) + blackErr bool +} + +// game 下棋 +func game(groupCode, senderUin int64, senderName string) message.Message { + return createGame(false, groupCode, senderUin, senderName) +} + +// blindfold 盲棋 +func blindfold(groupCode, senderUin int64, senderName string) message.Message { + return createGame(true, groupCode, senderUin, senderName) +} + +// abort 中断对局 +func abort(groupCode int64) message.Message { + if room, ok := chessRoomMap.Load(groupCode); ok { + return abortGame(*room, groupCode, "对局已被管理员中断,游戏结束。") + } + return simpleText("对局不存在,发送「下棋」或「chess」可创建对局。") +} + +// draw 和棋 +func draw(groupCode, senderUin int64) message.Message { + // 检查对局是否存在 + room, ok := chessRoomMap.Load(groupCode) + if !ok { + return simpleText("对局不存在,发送「下棋」或「chess」可创建对局。") + } + // 检查消息发送者是否为对局中的玩家 + if senderUin != room.whitePlayer && senderUin != room.blackPlayer { + return textWithAt(senderUin, "您不是对局中的玩家,无法请求和棋。") + } + // 处理和棋逻辑 + room.lastMoveTime = time.Now().Unix() + if room.drawPlayer == 0 { + room.drawPlayer = senderUin + chessRoomMap.Store(groupCode, room) + return textWithAt(senderUin, "请求和棋,发送「和棋」或「draw」接受和棋。走棋视为拒绝和棋。") + } + if room.drawPlayer == senderUin { + return textWithAt(senderUin, "已发起和棋请求,请勿重复发送。") + } + err := room.chessGame.Draw(chess.DrawOffer) + if err != nil { + log.Debugln("[chess]", "Fail to draw a game.", err) + return textWithAt(senderUin, fmt.Sprintln("程序发生了错误,和棋失败,请反馈开发者修复 bug。\nERROR:", err)) + } + chessString := getChessString(*room) + eloString := "" + if len(room.chessGame.Moves()) > 4 { + // 若走子次数超过 4 认为是有效对局,存入数据库 + dbService := newDBService() + if err := dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { + log.Debugln("[chess]", "Fail to create PGN.", err) + return message.Message{message.Text("ERROR: ", err)} + } + whiteScore, blackScore := 0.5, 0.5 + elo, err := getELOString(*room, whiteScore, blackScore) + if err != nil { + log.Debugln("[chess]", "Fail to get eloString.", eloString, err) + return message.Message{message.Text("ERROR: ", err)} + } + eloString = elo + } + replyMsg := textWithAt(senderUin, "接受和棋,游戏结束。\n"+eloString+chessString) + if err := cleanTempFiles(groupCode); err != nil { + log.Debugln("[chess]", "Fail to clean temp files", err) + return message.Message{message.Text("ERROR: ", err)} + } + chessRoomMap.Delete(groupCode) + return replyMsg +} + +// resign 认输 +func resign(groupCode, senderUin int64) message.Message { + // 检查对局是否存在 + room, ok := chessRoomMap.Load(groupCode) + if !ok { + return simpleText("对局不存在,发送「下棋」或「chess」可创建对局。") + } + // 检查是否是当前游戏玩家 + if senderUin != room.whitePlayer && senderUin != room.blackPlayer { + return textWithAt(senderUin, "不是对局中的玩家,无法认输。") + } + // 如果对局未建立,中断对局 + if room.whitePlayer == 0 || room.blackPlayer == 0 { + chessRoomMap.Delete(groupCode) + return simpleText("对局已释放。") + } + // 计算认输方 + var resignColor chess.Color + if senderUin == room.whitePlayer { + resignColor = chess.White + } else { + resignColor = chess.Black + } + if isAprilFoolsDay() { + if resignColor == chess.White { + resignColor = chess.Black + } else { + resignColor = chess.White + } + } + room.chessGame.Resign(resignColor) + chessString := getChessString(*room) + eloString := "" + if len(room.chessGame.Moves()) > 4 { + // 若走子次数超过 4 认为是有效对局,存入数据库 + dbService := newDBService() + if err := dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { + log.Debugln("[chess]", "Fail to create PGN.", err) + return message.Message{message.Text("ERROR: ", err)} + } + whiteScore, blackScore := 1.0, 1.0 + if resignColor == chess.White { + whiteScore = 0.0 + } else { + blackScore = 0.0 + } + elo, err := getELOString(*room, whiteScore, blackScore) + if err != nil { + log.Debugln("[chess]", "Fail to get eloString.", eloString, err) + return message.Message{message.Text("ERROR: ", err)} + } + eloString = elo + } + replyMsg := textWithAt(senderUin, "认输,游戏结束。\n"+eloString+chessString) + if isAprilFoolsDay() { + replyMsg = textWithAt(senderUin, "对手认输,游戏结束,你胜利了。\n"+eloString+chessString) + } + // 删除临时文件 + if err := cleanTempFiles(groupCode); err != nil { + log.Debugln("[chess]", "Fail to clean temp files", err) + return message.Message{message.Text("ERROR: ", err)} + } + chessRoomMap.Delete(groupCode) + return replyMsg +} + +// play 走棋 +func play(senderUin int64, groupCode int64, moveStr string) message.Message { + // 检查对局是否存在 + room, ok := chessRoomMap.Load(groupCode) + if !ok { + return nil + } + // 不是对局中的玩家,忽略消息 + if (senderUin != room.whitePlayer) && (senderUin != room.blackPlayer) && !isAprilFoolsDay() { + return nil + } + // 对局未建立 + if (room.whitePlayer == 0) || (room.blackPlayer == 0) { + return textWithAt(senderUin, "请等候其他玩家加入游戏。") + } + // 需要对手走棋 + if ((senderUin == room.whitePlayer) && (room.chessGame.Position().Turn() != chess.White)) || ((senderUin == room.blackPlayer) && (room.chessGame.Position().Turn() != chess.Black)) { + return textWithAt(senderUin, "请等待对手走棋。") + } + room.lastMoveTime = time.Now().Unix() + // 走棋 + if err := room.chessGame.MoveStr(moveStr); err != nil { + // 指令错误时检查 + if !room.isBlindfold { + // 未开启盲棋,提示指令错误 + return simpleText(fmt.Sprintf("移动「%s」违规,请检查,格式请参考「代数记谱法」(Algebraic notation)。", moveStr)) + } + // 开启盲棋,判断违例情况 + var currentPlayerColor chess.Color + if senderUin == room.whitePlayer { + currentPlayerColor = chess.White + } else { + currentPlayerColor = chess.Black + } + // 第一次违例,提示 + _flag := false + if (currentPlayerColor == chess.White) && !room.whiteErr { + room.whiteErr = true + chessRoomMap.Store(groupCode, room) + _flag = true + } + if (currentPlayerColor == chess.Black) && !room.blackErr { + room.blackErr = true + chessRoomMap.Store(groupCode, room) + _flag = true + } + if _flag { + return simpleText(fmt.Sprintf("移动「%s」违例,再次违例会立即判负。", moveStr)) + } + // 出现多次违例,判负 + room.chessGame.Resign(currentPlayerColor) + chessString := getChessString(*room) + replyMsg := textWithAt(senderUin, "违例两次,游戏结束。\n"+chessString) + // 删除临时文件 + if err := cleanTempFiles(groupCode); err != nil { + log.Debugln("[chess]", "Fail to clean temp files", err) + return message.Message{message.Text("ERROR: ", err)} + } + chessRoomMap.Delete(groupCode) + return replyMsg + } + // 走子之后,视为拒绝和棋 + if room.drawPlayer != 0 { + room.drawPlayer = 0 + chessRoomMap.Store(groupCode, room) + } + // 生成棋盘图片 + var boardImgEle message.MessageSegment + if !room.isBlindfold { + boardMsg, ok, errMsg := getBoardElement(groupCode) + boardImgEle = boardMsg + if !ok { + return errorText(errMsg) + } + } + // 检查游戏是否结束 + if room.chessGame.Method() != chess.NoMethod { + whiteScore, blackScore := 0.5, 0.5 + var msgBuilder strings.Builder + msgBuilder.WriteString("游戏结束,") + switch room.chessGame.Method() { + case chess.FivefoldRepetition: + msgBuilder.WriteString("和棋,因为五次重复走子。\n") + case chess.SeventyFiveMoveRule: + msgBuilder.WriteString("和棋,因为七十五步规则。\n") + case chess.InsufficientMaterial: + msgBuilder.WriteString("和棋,因为不可能将死。\n") + case chess.Stalemate: + msgBuilder.WriteString("和棋,因为逼和(无子可动和棋)。\n") + case chess.Checkmate: + var winner string + if room.chessGame.Position().Turn() == chess.White { + whiteScore = 0.0 + blackScore = 1.0 + winner = "黑方" + } else { + whiteScore = 1.0 + blackScore = 0.0 + winner = "白方" + } + msgBuilder.WriteString(winner) + msgBuilder.WriteString("胜利,因为将杀。\n") + case chess.NoMethod: + case chess.Resignation: + case chess.DrawOffer: + case chess.ThreefoldRepetition: + case chess.FiftyMoveRule: + default: + } + chessString := getChessString(*room) + eloString := "" + if len(room.chessGame.Moves()) > 4 { + // 若走子次数超过 4 认为是有效对局,存入数据库 + dbService := newDBService() + if err := dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { + log.Debugln("[chess]", "Fail to create PGN.", err) + return message.Message{message.Text("ERROR: ", err)} + } + // 仅有效对局才会计算等级分 + elo, err := getELOString(*room, whiteScore, blackScore) + if err != nil { + log.Debugln("[chess]", "Fail to get eloString.", eloString, err) + return message.Message{message.Text("ERROR: ", err)} + } + eloString = elo + } + msgBuilder.WriteString(eloString) + msgBuilder.WriteString(chessString) + replyMsg := simpleText(msgBuilder.String()) + if !room.isBlindfold { + replyMsg = append(replyMsg, boardImgEle) + } + if err := cleanTempFiles(groupCode); err != nil { + log.Debugln("[chess]", "Fail to clean temp files", err) + return message.Message{message.Text("ERROR: ", err)} + } + chessRoomMap.Delete(groupCode) + return replyMsg + } + // 提示玩家继续游戏 + var currentPlayer int64 + if room.chessGame.Position().Turn() == chess.White { + currentPlayer = room.whitePlayer + } else { + currentPlayer = room.blackPlayer + } + return append(textWithAt(currentPlayer, "对手已走子,游戏继续。"), boardImgEle) +} + +// ranking 排行榜 +func ranking() message.Message { + ranking, err := getRankingString() + if err != nil { + log.Debugln("[chess]", "Fail to get player ranking.", err) + return simpleText(fmt.Sprintln("服务器错误,无法获取排行榜信息。请联系开发者修 bug。", err)) + } + return simpleText(ranking) +} + +// rate 获取等级分 +func rate(senderUin int64, senderName string) message.Message { + dbService := newDBService() + rate, err := dbService.getELORateByUin(senderUin) + if err == gorm.ErrRecordNotFound { + return simpleText("没有查找到等级分信息。请至少进行一局对局。") + } + if err != nil { + log.Debugln("[chess]", "Fail to get player rank.", err) + return simpleText(fmt.Sprintln("服务器错误,无法获取等级分信息。请联系开发者修 bug。", err)) + } + return simpleText(fmt.Sprintf("玩家「%s」目前的等级分:%d", senderName, rate)) +} + +// cleanUserRate 清空用户等级分 +func cleanUserRate(senderUin int64) message.Message { + dbService := newDBService() + err := dbService.cleanELOByUin(senderUin) + if err == gorm.ErrRecordNotFound { + return simpleText("没有查找到等级分信息。请检查用户 uid 是否正确。") + } + if err != nil { + log.Debugln("[chess]", "Fail to clean player rank.", err) + return simpleText(fmt.Sprintln("服务器错误,无法清空等级分。请联系开发者修 bug。", err)) + } + return simpleText(fmt.Sprintf("已清空用户「%d」的等级分。", senderUin)) +} + +// createGame 创建游戏 +func createGame(isBlindfold bool, groupCode int64, senderUin int64, senderName string) message.Message { + room, ok := chessRoomMap.Load(groupCode) + if !ok { + chessRoomMap.Store(groupCode, &chessRoom{ + chessGame: chess.NewGame(), + whitePlayer: senderUin, + whiteName: senderName, + blackPlayer: 0, + blackName: "", + drawPlayer: 0, + lastMoveTime: time.Now().Unix(), + isBlindfold: isBlindfold, + whiteErr: false, + blackErr: false, + }) + if isBlindfold { + return simpleText("已创建新的盲棋对局,发送「盲棋」或「blind」可加入对局。") + } + return simpleText("已创建新的对局,发送「下棋」或「chess」可加入对局。") + } + if room.blackPlayer != 0 { + // 检测对局是否已存在超过 6 小时 + if (time.Now().Unix() - room.lastMoveTime) > 21600 { + autoAbortMsg := abortGame(*room, groupCode, "对局已存在超过 6 小时,游戏结束。") + autoAbortMsg = append(autoAbortMsg, message.Text("\n\n已有对局已被中断,如需创建新对局请重新发送指令。")) + autoAbortMsg = append(autoAbortMsg, message.At(senderUin)) + return autoAbortMsg + } + // 对局在进行 + msg := textWithAt(senderUin, "对局已在进行中,无法创建或加入对局,当前对局玩家为:") + if room.whitePlayer != 0 { + msg = append(msg, message.At(room.whitePlayer)) + } + if room.blackPlayer != 0 { + msg = append(msg, message.At(room.blackPlayer)) + } + msg = append(msg, message.Text(",群主或管理员发送「中断」或「abort」可中断对局(自动判和)。")) + return msg + } + if senderUin == room.whitePlayer { + return textWithAt(senderUin, "请等候其他玩家加入游戏。") + } + if room.isBlindfold && !isBlindfold { + return simpleText("已创建盲棋对局,请加入或等待盲棋对局结束之后创建普通对局。") + } + if !room.isBlindfold && isBlindfold { + return simpleText("已创建普通对局,请加入或等待普通对局结束之后创建盲棋对局。") + } + room.blackPlayer = senderUin + room.blackName = senderName + chessRoomMap.Store(groupCode, room) + var boardImgEle message.MessageSegment + if !room.isBlindfold { + boardMsg, ok, errMsg := getBoardElement(groupCode) + if !ok { + return errorText(errMsg) + } + boardImgEle = boardMsg + } + if isBlindfold { + return append(simpleText("黑棋已加入对局,请白方下棋。"), message.At(room.whitePlayer)) + } + return append(simpleText("黑棋已加入对局,请白方下棋。"), message.At(room.whitePlayer), boardImgEle) +} + +// abortGame 中断游戏 +func abortGame(room chessRoom, groupCode int64, hint string) message.Message { + err := room.chessGame.Draw(chess.DrawOffer) + if err != nil { + log.Debugln("[chess]", "Fail to draw a game.", err) + return simpleText(fmt.Sprintln("程序发生了错误,和棋失败,请反馈开发者修复 bug。", err)) + } + chessString := getChessString(room) + if len(room.chessGame.Moves()) > 4 { + dbService := newDBService() + if err := dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { + log.Debugln("[chess]", "Fail to create PGN.", err) + return message.Message{message.Text("ERROR: ", err)} + } + } + if err := cleanTempFiles(groupCode); err != nil { + log.Debugln("[chess]", "Fail to clean temp files", err) + return message.Message{message.Text("ERROR: ", err)} + } + chessRoomMap.Delete(groupCode) + msg := simpleText(hint) + if room.whitePlayer != 0 { + msg = append(msg, message.At(room.whitePlayer)) + } + if room.blackPlayer != 0 { + msg = append(msg, message.At(room.blackPlayer)) + } + msg = append(msg, message.Text("\n\n"+chessString)) + return msg +} + +// getBoardElement 获取棋盘图片的消息内容 +func getBoardElement(groupCode int64) (message.MessageSegment, bool, string) { + room, ok := chessRoomMap.Load(groupCode) + if !ok { + log.Debugln(fmt.Sprintf("No room for groupCode %d.", groupCode)) + return message.MessageSegment{}, false, "对局不存在" + } + // 未安装 inkscape 直接返回对局字符串 + // TODO: 使用原生 go 库渲染 svg + if !commandExists("inkscape") { + boardString := room.chessGame.Position().Board().Draw() + boardImageB64, err := generateCharBoardImage(boardString) + if err != nil { + return message.MessageSegment{}, false, "生成棋盘图片时发生错误" + } + replyMsg := message.Image("base64://" + boardImageB64) + return replyMsg, true, "" + } + // 获取高亮方块 + highlightSquare := make([]chess.Square, 0, 2) + moves := room.chessGame.Moves() + if len(moves) != 0 { + lastMove := moves[len(moves)-1] + highlightSquare = append(highlightSquare, lastMove.S1()) + highlightSquare = append(highlightSquare, lastMove.S2()) + } + // 生成棋盘 svg 文件 + svgFilePath := path.Join(tempFileDir, fmt.Sprintf("%d.svg", groupCode)) + fenStr := room.chessGame.FEN() + gameTurn := room.chessGame.Position().Turn() + if err := generateBoardSVG(svgFilePath, fenStr, gameTurn, highlightSquare...); err != nil { + log.Debugln("[chess]", "Unable to generate svg file.", err) + return message.MessageSegment{}, false, "无法生成 svg 图片,请检查后台日志。" + } + // 调用 inkscape 将 svg 图片转化为 png 图片 + pngFilePath := path.Join(tempFileDir, fmt.Sprintf("%d.png", groupCode)) + if err := exec.Command("inkscape", "-w", "720", "-h", "720", svgFilePath, "-o", pngFilePath).Run(); err != nil { + log.Debugln("[chess]", "Unable to convert to png.", err) + return message.MessageSegment{}, false, "无法生成 png 图片,请检查 inkscape 安装情况及其依赖 libfuse。" + } + // 尝试读取 png 图片 + imgData, err := os.ReadFile(pngFilePath) + if err != nil { + log.Debugln("[chess]", fmt.Sprintf("Unable to read image file in %s.", pngFilePath), err) + return message.MessageSegment{}, false, "无法读取 png 图片" + } + imgMsg := message.Image("base64://" + base64.StdEncoding.EncodeToString(imgData)) + return imgMsg, true, "" +} + +// getELOString 获得玩家等级分的文本内容 +func getELOString(room chessRoom, whiteScore, blackScore float64) (string, error) { + if room.whitePlayer == 0 || room.blackPlayer == 0 { + return "", nil + } + var msgBuilder strings.Builder + msgBuilder.WriteString("玩家等级分:\n") + dbService := newDBService() + if err := updateELORate(room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName, whiteScore, blackScore, dbService); err != nil { + msgBuilder.WriteString("发生错误,无法更新等级分。") + msgBuilder.WriteString(err.Error()) + return msgBuilder.String(), err + } + whiteRate, blackRate, err := getELORate(room.whitePlayer, room.blackPlayer, dbService) + if err != nil { + msgBuilder.WriteString("发生错误,无法获取等级分。") + msgBuilder.WriteString(err.Error()) + return msgBuilder.String(), err + } + msgBuilder.WriteString(fmt.Sprintf("%s:%d\n%s:%d\n\n", room.whiteName, whiteRate, room.blackName, blackRate)) + return msgBuilder.String(), nil +} + +// getRankingString 获取等级分排行榜的文本内容 +func getRankingString() (string, error) { + dbService := newDBService() + eloList, err := dbService.getHighestRateList() + if err != nil { + return "", err + } + var msgBuilder strings.Builder + msgBuilder.WriteString("当前等级分排行榜:\n\n") + for _, elo := range eloList { + msgBuilder.WriteString(fmt.Sprintf("%s: %d\n", elo.Name, elo.Rate)) + } + return msgBuilder.String(), nil +} + +func simpleText(msg string) message.Message { + return []message.MessageSegment{message.Text(msg)} +} + +func textWithAt(target int64, msg string) message.Message { + if target == 0 { + return simpleText("@全体成员 " + msg) + } + return []message.MessageSegment{message.At(target), message.Text(msg)} +} + +func errorText(errMsg string) message.Message { + return simpleText("发生错误,请联系开发者修 bug。\n错误信息:" + errMsg) +} + +// updateELORate 更新 elo 等级分 +// 当数据库中没有玩家的等级分信息时,自动新建一条记录 +func updateELORate(whiteUin, blackUin int64, whiteName, blackName string, whiteScore, blackScore float64, dbService *chessDBService) error { + whiteRate, err := dbService.getELORateByUin(whiteUin) + if err != nil { + if err != gorm.ErrRecordNotFound { + return err + } + // create white elo + if err := dbService.createELO(whiteUin, whiteName, eloDefault); err != nil { + return err + } + whiteRate = eloDefault + } + blackRate, err := dbService.getELORateByUin(blackUin) + if err != nil { + if err != gorm.ErrRecordNotFound { + return err + } + // create black elo + if err := dbService.createELO(blackUin, blackName, eloDefault); err != nil { + return err + } + blackRate = eloDefault + } + whiteRate, blackRate = calculateNewRate(whiteRate, blackRate, whiteScore, blackScore) + // 更新白棋玩家的 ELO 等级分 + if err := dbService.updateELOByUin(whiteUin, whiteName, whiteRate); err != nil { + return err + } + // 更新黑棋玩家的 ELO 等级分 + return dbService.updateELOByUin(blackUin, blackName, blackRate) +} + +// cleanTempFiles 清理临时文件 +func cleanTempFiles(groupCode int64) error { + svgFilePath := path.Join(tempFileDir, fmt.Sprintf("%d.svg", groupCode)) + if err := os.Remove(svgFilePath); err != nil { + return err + } + pngFilePath := path.Join(tempFileDir, fmt.Sprintf("%d.png", groupCode)) + return os.Remove(pngFilePath) +} + +// generateCharBoardImage 生成文字版的棋盘 +func generateCharBoardImage(boardString string) (string, error) { + boardString = strings.Trim(boardString, "\n") + const FontSize = 72 + h := FontSize*8 + 36 + w := FontSize*9 + 24 + dc := gg.NewContext(h, w) + dc.SetRGB(1, 1, 1) + dc.Clear() + dc.SetRGB(0, 0, 0) + // fnt := text.GNUUnifontFontFile + fontdata, err := file.GetLazyData("text.GNUUnifontFontFile", control.Md5File, true) + if err != nil { + // TODO: err solve + panic(err) + } + if err := dc.ParseFontFace(fontdata, FontSize); err != nil { + return "", err + } + lines := strings.Split(boardString, "\n") + if len(lines) != 9 { + lines = make([]string, 9) + lines[0] = "ERROR [500]" + lines[1] = "程序内部错误" + lines[2] = "棋盘字符串不合法" + lines[3] = "请反馈开发者修复" + } + for i := 0; i < 9; i++ { + dc.DrawString(lines[i], 18, float64(FontSize*(i+1))) + } + imgBuffer := bytes.NewBuffer([]byte{}) + if err := dc.EncodePNG(imgBuffer); err != nil { + return "", err + } + imgData, err := io.ReadAll(imgBuffer) + if err != nil { + return "", err + } + imgB64 := base64.StdEncoding.EncodeToString(imgData) + return imgB64, nil +} + +// generateBoardSVG 生成棋盘 SVG 图片 +func generateBoardSVG(svgFilePath, fenStr string, gameTurn chess.Color, sqs ...chess.Square) error { + os.Remove(svgFilePath) + f, err := os.Create(svgFilePath) + if err != nil { + return err + } + defer f.Close() + + pos := &chess.Position{} + if err := pos.UnmarshalText(binary.StringToBytes(fenStr)); err != nil { + return err + } + yellow := color.RGBA{255, 255, 0, 1} + mark := image.MarkSquares(yellow, sqs...) + board := pos.Board() + fromBlack := image.Perspective(gameTurn) + return image.SVG(f, board, fromBlack, mark) +} + +// getChessString 获取 PGN 字符串 +func getChessString(room chessRoom) string { + game := room.chessGame + dataString := fmt.Sprintf("[Date \"%s\"]\n", time.Now().Format("2006-01-02")) + whiteString := fmt.Sprintf("[White \"%s\"]\n", room.whiteName) + blackString := fmt.Sprintf("[Black \"%s\"]\n", room.blackName) + chessString := game.String() + + return dataString + whiteString + blackString + chessString +} + +// getELORate 获取玩家的 ELO 等级分 +func getELORate(whiteUin, blackUin int64, dbService *chessDBService) (whiteRate int, blackRate int, err error) { + whiteRate, err = dbService.getELORateByUin(whiteUin) + if err != nil { + return + } + blackRate, err = dbService.getELORateByUin(blackUin) + if err != nil { + return + } + return +} + +// commandExists 判断 指令是否存在 +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +// isAprilFoolsDay 判断当前时间是否为愚人节期间 +func isAprilFoolsDay() bool { + now := time.Now() + return now.Month() == 4 && now.Day() == 1 +} diff --git a/plugin/chess/db.go b/plugin/chess/db.go new file mode 100644 index 0000000000..3a61ca22f7 --- /dev/null +++ b/plugin/chess/db.go @@ -0,0 +1,100 @@ +package chess + +import ( + "os" + + "github.com/jinzhu/gorm" +) + +var chessDB *gorm.DB + +// elo user elo info +type elo struct { + gorm.Model + Uin int64 `gorm:"unique_index"` + Name string + Rate int +} + +// pgn chess pgn info +type pgn struct { + gorm.Model + Data string + WhiteUin int64 + BlackUin int64 + WhiteName string + BlackName string +} + +// chessDBService 数据库服务 +type chessDBService struct { + db *gorm.DB +} + +// newDBService 创建数据库服务 +func newDBService() *chessDBService { + return &chessDBService{ + db: chessDB, + } +} + +// initDatabase init database +func initDatabase(dbPath string) { + var err error + if _, err = os.Stat(dbPath); err != nil || os.IsNotExist(err) { + f, err := os.Create(dbPath) + if err != nil { + panic(err) + } + defer f.Close() + } + chessDB, err = gorm.Open("sqlite3", dbPath) + if err != nil { + panic(err) + } + chessDB.AutoMigrate(&elo{}, &pgn{}) +} + +// createELO 创建 ELO +func (s *chessDBService) createELO(uin int64, name string, rate int) error { + return s.db.Create(&elo{ + Uin: uin, + Name: name, + Rate: rate, + }).Error +} + +// getELORateByUin 获取 ELO 等级分 +func (s *chessDBService) getELORateByUin(uin int64) (int, error) { + var elo elo + err := s.db.Select("rate").Where("uin = ?", uin).First(&elo).Error + return elo.Rate, err +} + +// getHighestRateList 获取最高的等级分列表 +func (s *chessDBService) getHighestRateList() ([]elo, error) { + var eloList []elo + err := s.db.Order("rate desc").Limit(10).Find(&eloList).Error + return eloList, err +} + +// updateELOByUin 更新 ELO 等级分 +func (s *chessDBService) updateELOByUin(uin int64, name string, rate int) error { + return s.db.Model(&elo{}).Where("uin = ?", uin).Update("name", name).Update("rate", rate).Error +} + +// cleanELOByUin 清空用户 ELO 等级分 +func (s *chessDBService) cleanELOByUin(uin int64) error { + return s.db.Model(&elo{}).Where("uin = ?", uin).Update("rate", 100).Error +} + +// createPGN 创建 PGN +func (s *chessDBService) createPGN(data string, whiteUin int64, blackUin int64, whiteName string, blackName string) error { + return s.db.Create(&pgn{ + Data: data, + WhiteUin: whiteUin, + BlackUin: blackUin, + WhiteName: whiteName, + BlackName: blackName, + }).Error +} diff --git a/plugin/chess/elo.go b/plugin/chess/elo.go new file mode 100644 index 0000000000..9f000164fe --- /dev/null +++ b/plugin/chess/elo.go @@ -0,0 +1,37 @@ +package chess + +import ( + "math" +) + +// calculateNewRate calculate new rate of the player +func calculateNewRate(whiteRate, blackRate int, whiteScore, blackScore float64) (int, int) { + k := getKFactor(whiteRate, blackRate) + exceptionWhite := calculateException(whiteRate, blackRate) + exceptionBlack := calculateException(blackRate, whiteRate) + whiteRate = calculateRate(whiteRate, whiteScore, exceptionWhite, k) + blackRate = calculateRate(blackRate, blackScore, exceptionBlack, k) + return whiteRate, blackRate +} + +func calculateException(rate int, opponentRate int) float64 { + return 1.0 / (1.0 + math.Pow(10.0, float64(opponentRate-rate)/400.0)) +} + +func calculateRate(rate int, score float64, exception float64, k int) int { + newRate := int(math.Round(float64(rate) + float64(k)*(score-exception))) + if newRate < 1 { + newRate = 1 + } + return newRate +} + +func getKFactor(rateA, rateB int) int { + if rateA > 2400 && rateB > 2400 { + return 16 + } + if rateA > 2100 && rateB > 2100 { + return 24 + } + return 32 +} diff --git a/plugin/chess/elo_test.go b/plugin/chess/elo_test.go new file mode 100644 index 0000000000..7e483d62f7 --- /dev/null +++ b/plugin/chess/elo_test.go @@ -0,0 +1,80 @@ +package chess + +import ( + "math" + "testing" +) + +func TestCalculateNewRate(t *testing.T) { + type args struct { + whiteRate int + blackRate int + whiteScore float64 + blackScore float64 + } + tests := []struct { + name string + args args + want int + want1 int + }{ + { + name: "test1", + args: args{ + whiteRate: 1613, + blackRate: 1573, + whiteScore: 0.5, + blackScore: 0.5, + }, + want: 1611, + want1: 1575, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := calculateNewRate(tt.args.whiteRate, tt.args.blackRate, tt.args.whiteScore, tt.args.blackScore) + if got != tt.want { + t.Errorf("CalculateNewRate() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("CalculateNewRate() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func Test_calculateException(t *testing.T) { + type args struct { + rate int + opponentRate int + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "test1", + args: args{ + rate: 1613, + opponentRate: 1573, + }, + want: 0.5573116, + }, + { + name: "test2", + args: args{ + rate: 1613, + opponentRate: 1613, + }, + want: 0.5, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calculateException(tt.args.rate, tt.args.opponentRate); math.Abs(got-tt.want) > 0.0001 { + t.Errorf("calculateException() = %v, want %v", got, tt.want) + } + }) + } +}