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/FloatTech/zbputils/img/text" "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 inkscapeExists() { 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 inkscapeExists() { 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 inkscapeExists() { 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 inkscapeExists() { 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 inkscapeExists() { 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 !inkscapeExists() { 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) 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 } // inkscapeExists 判断 inkscape 是否存在 func inkscapeExists() bool { _, err := exec.LookPath("inkscape") return err == nil } // isAprilFoolsDay 判断当前时间是否为愚人节期间 func isAprilFoolsDay() bool { now := time.Now() return now.Month() == 4 && now.Day() == 1 }