国际象棋 (#720)

* init commit for chess

* fix lint & error

* remove issue link

* fix lint

* remove embed

* regex fix

* use strings.Builder

* 改不动了,先 push 了备份下

* 基本上改好了

* limit

* 使用等宽字体渲染棋盘

* use syncx

* 不会更新依赖库😭

* 先 push 备份下

* 更新依赖版本,确保能读取到字体文件

* fix log
This commit is contained in:
Aimer Neige 2023-09-01 22:21:07 +08:00 committed by GitHub
parent 9676b26de0
commit ba0ef37b74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1099 additions and 1 deletions

4
go.mod
View File

@ -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

6
go.sum
View File

@ -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=

View File

@ -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" // 英文字符翻转

165
plugin/chess/chess.go Normal file
View File

@ -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)
}
})
}

707
plugin/chess/core.go Normal file
View File

@ -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
}

100
plugin/chess/db.go Normal file
View File

@ -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
}

37
plugin/chess/elo.go Normal file
View File

@ -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
}

80
plugin/chess/elo_test.go Normal file
View File

@ -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)
}
})
}
}