初始化
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/server_run.exe
|
||||
/api-err.log
|
||||
/api-out.log
|
||||
/nl-game-api.exe
|
||||
/nl-game-api-gin
|
||||
/nl-game-api-gin.exe
|
||||
/run_server.log
|
||||
/server_test.log
|
||||
/server_test_err.log
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/nl-game-api-gin.iml" filepath="$PROJECT_DIR$/.idea/nl-game-api-gin.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/nl-game-api-gin.iml
generated
Normal file
9
.idea/nl-game-api-gin.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
128
cmd/dbtool/main.go
Normal file
128
cmd/dbtool/main.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// dbtool 数据库初始化工具:读取 sql/init.sql 并逐条执行
|
||||
// 用法(在 nl-game-api-gin 目录下):go run ./cmd/dbtool -f ../sql/init.sql
|
||||
// 连接参数复用 config.yaml 中的 mysql 配置(不指定库名,脚本自带 CREATE DATABASE)
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"nl-game-api-gin/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
file := flag.String("f", "../sql/init.sql", "SQL 脚本路径")
|
||||
conf := flag.String("c", "config.yaml", "配置文件路径")
|
||||
flag.Parse()
|
||||
if err := config.Load(*conf); err != nil {
|
||||
log.Fatalln("配置加载失败:", err)
|
||||
}
|
||||
raw, err := os.ReadFile(*file)
|
||||
if err != nil {
|
||||
log.Fatalln("读取 SQL 文件失败:", err)
|
||||
}
|
||||
// 服务器级 DSN(不带库名),multiStatements 关闭、逐条执行便于定位错误
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?charset=utf8mb4&parseTime=false",
|
||||
config.C.MySQL.User, config.C.MySQL.Password, config.C.MySQL.Host, config.C.MySQL.Port)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
log.Fatalln("连接 MySQL 失败:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
// 限制单连接:保证 USE xiaoyouxi 对后续语句生效
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalln("MySQL 无法连通,请确认服务已启动且账号密码正确:", err)
|
||||
}
|
||||
stmts := splitSQL(string(raw))
|
||||
log.Printf("共解析出 %d 条语句,开始执行…\n", len(stmts))
|
||||
for i, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
head := s
|
||||
if len(head) > 80 {
|
||||
head = head[:80] + "…"
|
||||
}
|
||||
log.Fatalf("第 %d 条语句执行失败: %v\n语句开头: %s", i+1, err, head)
|
||||
}
|
||||
}
|
||||
log.Println("✅ 数据库初始化完成(库名 xiaoyouxi,含种子数据与超管 liqi)")
|
||||
}
|
||||
|
||||
// splitSQL 把脚本按分号拆成独立语句:跟踪引号与注释状态,避免误切字符串内的分号
|
||||
func splitSQL(text string) []string {
|
||||
var stmts []string
|
||||
var sb strings.Builder
|
||||
inSingle, inDouble, inBacktick, inLineComment := false, false, false, false
|
||||
runes := []rune(text)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
ch := runes[i]
|
||||
// 行注释:遇到换行结束(注释内容不写入语句)
|
||||
if inLineComment {
|
||||
if ch == '\n' {
|
||||
inLineComment = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 检测行注释起始(不在任何引号内)
|
||||
if !inSingle && !inDouble && !inBacktick && ch == '-' && i+1 < len(runes) && runes[i+1] == '-' {
|
||||
inLineComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// 引号状态翻转(MySQL 字符串内以 \' 转义,需要跳过转义符)
|
||||
switch {
|
||||
case inSingle:
|
||||
if ch == '\\' && i+1 < len(runes) {
|
||||
sb.WriteRune(ch)
|
||||
i++
|
||||
sb.WriteRune(runes[i])
|
||||
continue
|
||||
}
|
||||
if ch == '\'' {
|
||||
inSingle = false
|
||||
}
|
||||
case inDouble:
|
||||
if ch == '\\' && i+1 < len(runes) {
|
||||
sb.WriteRune(ch)
|
||||
i++
|
||||
sb.WriteRune(runes[i])
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inDouble = false
|
||||
}
|
||||
case inBacktick:
|
||||
if ch == '`' {
|
||||
inBacktick = false
|
||||
}
|
||||
default:
|
||||
if ch == '\'' {
|
||||
inSingle = true
|
||||
} else if ch == '"' {
|
||||
inDouble = true
|
||||
} else if ch == '`' {
|
||||
inBacktick = true
|
||||
} else if ch == ';' {
|
||||
// 语句结束
|
||||
stmt := strings.TrimSpace(sb.String())
|
||||
if stmt != "" {
|
||||
stmts = append(stmts, stmt)
|
||||
}
|
||||
sb.Reset()
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.WriteRune(ch)
|
||||
}
|
||||
// 收尾:最后一条可能没有分号
|
||||
if tail := strings.TrimSpace(sb.String()); tail != "" {
|
||||
stmts = append(stmts, tail)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
31
config.yaml
Normal file
31
config.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# =====================================================================
|
||||
# 像素游戏厅后端配置文件
|
||||
# =====================================================================
|
||||
server:
|
||||
# 后端 HTTP 服务监听端口
|
||||
port: 8080
|
||||
mysql:
|
||||
# 本地 MySQL 连接配置(库名 xiaoyouxi 由 sql/init.sql 创建)
|
||||
host: 127.0.0.1
|
||||
port: 3306
|
||||
user: root
|
||||
password: root
|
||||
database: xiaoyouxi
|
||||
jwt:
|
||||
# JWT 签名密钥(生产环境请更换为随机长字符串)
|
||||
secret: xiaoyouxi-jwt-secret-2026
|
||||
# Token 有效期(小时)
|
||||
expire_hours: 168
|
||||
ai:
|
||||
# 讯飞星火 Lite(OpenAI 兼容接口)配置
|
||||
# 获取方式:讯飞开放平台 https://console.xfyun.cn/ 创建应用后获取 APIPassword
|
||||
spark:
|
||||
api_key: ""
|
||||
base_url: https://spark-api-open.xf-yun.com/v1
|
||||
model: lite
|
||||
# DeepSeek 配置
|
||||
# 获取方式:https://platform.deepseek.com/ 创建 API Key
|
||||
deepseek:
|
||||
api_key: ""
|
||||
base_url: https://api.deepseek.com/v1
|
||||
model: deepseek-chat
|
||||
49
go.mod
Normal file
49
go.mod
Normal file
@@ -0,0 +1,49 @@
|
||||
module nl-game-api-gin
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/crypto v0.54.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/plugin/soft_delete v1.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
125
go.sum
Normal file
125
go.sum
Normal file
@@ -0,0 +1,125 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
|
||||
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
|
||||
88
internal/ai/chess_llm.go
Normal file
88
internal/ai/chess_llm.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/xiangqi"
|
||||
)
|
||||
|
||||
// llmChessAI 大模型象棋 AI:服务端先用搜索引擎给候选着法排序,
|
||||
// 再按难度截取候选交给 LLM 挑选(困难=只给高分着法,简单=全量随机)
|
||||
type llmChessAI struct {
|
||||
client *LLMClient // LLM 客户端
|
||||
difficulty string // 难度
|
||||
fallback ChessAI // 规则 AI 兜底
|
||||
name string // 显示名
|
||||
}
|
||||
|
||||
// Name AI 显示名
|
||||
func (a *llmChessAI) Name() string { return a.name }
|
||||
|
||||
// boardText 把棋盘渲染成文本(LLM 提示词用)
|
||||
func boardText(b *xiangqi.Board) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" 0 1 2 3 4 5 6 7 8\n")
|
||||
for r := 0; r < 10; r++ {
|
||||
sb.WriteString(fmt.Sprintf("%d ", r))
|
||||
for c := 0; c < 9; c++ {
|
||||
p := b[r][c]
|
||||
if p == 0 {
|
||||
sb.WriteString(". ")
|
||||
} else {
|
||||
sb.WriteString(xiangqi.PieceName(p) + " ")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// DecideMove 走子:候选着法按引擎评分排序后交 LLM 选择
|
||||
func (a *llmChessAI) DecideMove(b *xiangqi.Board, side int) (xiangqi.Move, string) {
|
||||
// 用 2 层搜索为候选排序(困难截取前6,中等前12,简单全部)
|
||||
ranked := b.RankedMoves(side, 2)
|
||||
if len(ranked) == 0 {
|
||||
return xiangqi.Move{}, ""
|
||||
}
|
||||
limit := len(ranked)
|
||||
switch a.difficulty {
|
||||
case DiffHard:
|
||||
limit = 6
|
||||
case DiffMedium:
|
||||
limit = 12
|
||||
}
|
||||
if limit > len(ranked) {
|
||||
limit = len(ranked)
|
||||
}
|
||||
shown := ranked[:limit]
|
||||
// 组装候选清单(附带吃子信息帮助 LLM 判断)
|
||||
options := make([]string, 0, len(shown))
|
||||
for i, m := range shown {
|
||||
target := b[m.ToR][m.ToC]
|
||||
capture := ""
|
||||
if target != 0 {
|
||||
capture = "(吃" + xiangqi.PieceName(target) + ")"
|
||||
}
|
||||
options = append(options, fmt.Sprintf("%d: %s从(%d,%d)走到(%d,%d)%s",
|
||||
i, b.MoveDesc(m), m.FromR, m.FromC, m.ToR, m.ToC, capture))
|
||||
}
|
||||
sideName := "红方"
|
||||
if side == xiangqi.Black {
|
||||
sideName = "黑方"
|
||||
}
|
||||
system := styleFor(a.difficulty) + "你在下中国象棋,执" + sideName + "。" +
|
||||
"只输出 JSON:{\"choice\":候选编号,\"say\":\"一句台词\"},不要输出其他内容。"
|
||||
user := fmt.Sprintf("当前棋盘(行0在上为黑方,行9在下为红方):\n%s\n候选着法(已按棋力排序,越靠前越好):\n%s\n请选择一个候选编号。",
|
||||
boardText(b), strings.Join(options, "\n"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
// 带返回校验与重试的决策调用(传输层自动重试 + 输出非法自动纠错重问)
|
||||
if d, err := a.client.ChatDecision(ctx, system, user, temperatureFor(a.difficulty), len(shown)-1); err == nil {
|
||||
return shown[d.Choice], d.Say
|
||||
}
|
||||
// LLM 多次重试仍失败:走规则兜底
|
||||
return a.fallback.DecideMove(b, side)
|
||||
}
|
||||
44
internal/ai/chess_rule.go
Normal file
44
internal/ai/chess_rule.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/xiangqi"
|
||||
)
|
||||
|
||||
// ruleChessAI 本地规则象棋 AI:极大极小搜索,深度随难度提升
|
||||
type ruleChessAI struct {
|
||||
difficulty string // 难度:easy=1层+随机扰动 medium=2层 hard=3层
|
||||
name string // 显示名
|
||||
}
|
||||
|
||||
// Name AI 显示名
|
||||
func (a *ruleChessAI) Name() string { return a.name }
|
||||
|
||||
// searchDepth 难度对应的搜索深度
|
||||
func (a *ruleChessAI) searchDepth() int {
|
||||
switch a.difficulty {
|
||||
case DiffEasy:
|
||||
return 1
|
||||
case DiffHard:
|
||||
return 3
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// DecideMove 走子:搜索最优着法;简单难度 40% 概率走随机合法着法(模拟新手失误)
|
||||
func (a *ruleChessAI) DecideMove(b *xiangqi.Board, side int) (xiangqi.Move, string) {
|
||||
if a.difficulty == DiffEasy && rand.Float64() < 0.4 {
|
||||
if m, ok := b.RandomMove(side); ok {
|
||||
return m, ""
|
||||
}
|
||||
}
|
||||
move, score := b.BestMove(side, a.searchDepth())
|
||||
say := ""
|
||||
// 分数大幅领先时补一句台词增加氛围
|
||||
if score > 800 {
|
||||
say = "这步棋你可要小心了。"
|
||||
}
|
||||
return move, say
|
||||
}
|
||||
103
internal/ai/ddz_llm.go
Normal file
103
internal/ai/ddz_llm.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/ddz"
|
||||
)
|
||||
|
||||
// llmDdzAI 大模型斗地主 AI:把候选出牌列表交给 LLM 挑选,非法/超时由规则 AI 兜底
|
||||
type llmDdzAI struct {
|
||||
client *LLMClient // LLM 客户端
|
||||
difficulty string // 难度(决定提示词人设与温度)
|
||||
fallback DdzAI // 规则 AI 兜底
|
||||
name string // 显示名
|
||||
}
|
||||
|
||||
// Name AI 显示名
|
||||
func (a *llmDdzAI) Name() string { return a.name }
|
||||
|
||||
// DecideBid 叫地主:把手牌交给 LLM 判断叫不叫
|
||||
func (a *llmDdzAI) DecideBid(hand []int) (bool, string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
system := styleFor(a.difficulty) + "你在玩斗地主,现在是叫地主阶段。" +
|
||||
"只输出 JSON:{\"choice\":0或1,\"say\":\"一句台词\"},0=不叫,1=叫地主。"
|
||||
user := fmt.Sprintf("你的手牌:%s\n手牌强度参考分:%d(5分以上算强牌)\n请决定是否叫地主。",
|
||||
ddz.CardsName(hand), ddz.HandStrength(hand))
|
||||
// 带返回校验与重试的决策调用(传输层自动重试 + 输出非法自动纠错重问)
|
||||
if d, err := a.client.ChatDecision(ctx, system, user, temperatureFor(a.difficulty), 1); err == nil {
|
||||
return d.Choice == 1, d.Say
|
||||
}
|
||||
// LLM 多次重试仍失败:走规则兜底
|
||||
return a.fallback.DecideBid(hand)
|
||||
}
|
||||
|
||||
// DecideRob 抢地主:直接复用规则 AI 的牌力判断(避免为一次表态多花一轮 LLM 往返)
|
||||
func (a *llmDdzAI) DecideRob(hand []int) (bool, string) {
|
||||
return a.fallback.DecideRob(hand)
|
||||
}
|
||||
|
||||
// DecidePlay 出牌:服务端生成全部合法候选,LLM 只需选编号(天然保证合法性)
|
||||
// 提示词中带入身份与队友信息:平民身份的 LLM 会被引导与另一位平民配合
|
||||
func (a *llmDdzAI) DecidePlay(hand []int, last *ddz.Combo, pctx PlayContext) ([]int, string) {
|
||||
moves := ddz.GenMoves(hand, last)
|
||||
if len(moves) == 0 {
|
||||
return nil, "要不起。"
|
||||
}
|
||||
// 候选清单:0=过牌(跟牌时才有),1..n=具体出牌
|
||||
options := []string{}
|
||||
canPass := last != nil
|
||||
if canPass {
|
||||
options = append(options, "0: 过牌(不出)")
|
||||
}
|
||||
offset := len(options)
|
||||
// 候选太多时截取(保留最小若干手 + 最大一手 + 炸弹)避免提示词过长
|
||||
shown := moves
|
||||
if len(shown) > 12 {
|
||||
shown = append(append([][]int{}, moves[:10]...), moves[len(moves)-2:]...)
|
||||
}
|
||||
for i, mv := range shown {
|
||||
combo := ddz.Parse(mv)
|
||||
options = append(options, fmt.Sprintf("%d: %s", i+offset, combo.Desc()))
|
||||
}
|
||||
lastDesc := "你是本轮首家,自由出牌"
|
||||
if last != nil {
|
||||
lastDesc = "上家出了「" + last.Desc() + "」,你需要压过它"
|
||||
if pctx.LastIsPartner {
|
||||
lastDesc += "(这是你队友出的牌)"
|
||||
} else if !pctx.IsLandlord {
|
||||
lastDesc += "(这是地主出的牌)"
|
||||
}
|
||||
}
|
||||
// 身份与配合原则
|
||||
role := "你是地主,1 打 2,两位平民会联手对付你。"
|
||||
if !pctx.IsLandlord {
|
||||
role = fmt.Sprintf(
|
||||
"你是平民,与另一位平民组队对抗地主(地主剩 %d 张牌,队友剩 %d 张牌)。"+
|
||||
"配合原则:队友的牌尽量不压(除非你能直接出完);队友只剩 1-2 张时优先喂出最小的单张或对子;"+
|
||||
"炸弹和大牌留着对付地主,不要浪费在队友身上。",
|
||||
pctx.LandlordCards, pctx.PartnerCards)
|
||||
}
|
||||
system := styleFor(a.difficulty) + "你在玩斗地主。" + role +
|
||||
"只输出 JSON:{\"choice\":候选编号,\"say\":\"一句台词\"},不要输出其他内容。"
|
||||
user := fmt.Sprintf("你的手牌:%s\n%s\n候选操作:\n%s\n请选择一个候选编号。",
|
||||
ddz.CardsName(hand), lastDesc, strings.Join(options, "\n"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
// 带返回校验与重试的决策调用(choice 越界或非 JSON 输出会自动纠错重问)
|
||||
if d, err := a.client.ChatDecision(ctx, system, user, temperatureFor(a.difficulty), len(options)-1); err == nil {
|
||||
if canPass && d.Choice == 0 {
|
||||
return nil, d.Say
|
||||
}
|
||||
idx := d.Choice - offset
|
||||
if idx >= 0 && idx < len(shown) {
|
||||
return shown[idx], d.Say
|
||||
}
|
||||
}
|
||||
// LLM 多次重试仍失败:走规则兜底
|
||||
return a.fallback.DecidePlay(hand, last, pctx)
|
||||
}
|
||||
216
internal/ai/ddz_rule.go
Normal file
216
internal/ai/ddz_rule.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/ddz"
|
||||
)
|
||||
|
||||
// ruleDdzAI 本地规则斗地主 AI:不依赖网络,永远可用
|
||||
type ruleDdzAI struct {
|
||||
difficulty string // 难度:影响叫牌阈值与出牌策略
|
||||
name string // 显示名
|
||||
}
|
||||
|
||||
// Name AI 显示名
|
||||
func (a *ruleDdzAI) Name() string { return a.name }
|
||||
|
||||
// DecideBid 叫地主:按手牌强度阈值决定(难度越高越敢叫强牌)
|
||||
func (a *ruleDdzAI) DecideBid(hand []int) (bool, string) {
|
||||
strength := ddz.HandStrength(hand)
|
||||
threshold := 5
|
||||
switch a.difficulty {
|
||||
case DiffEasy:
|
||||
// 简单:随缘叫牌
|
||||
threshold = 4 + rand.Intn(3)
|
||||
case DiffHard:
|
||||
threshold = 6
|
||||
}
|
||||
if strength >= threshold {
|
||||
return true, "这把牌不错,我来当地主!"
|
||||
}
|
||||
return false, "牌太散了,不叫。"
|
||||
}
|
||||
|
||||
// DecideRob 抢地主:抢意味着倍数翻倍,门槛比叫地主高 2 分(牌力显著强才抢)
|
||||
func (a *ruleDdzAI) DecideRob(hand []int) (bool, string) {
|
||||
strength := ddz.HandStrength(hand)
|
||||
threshold := 7
|
||||
switch a.difficulty {
|
||||
case DiffEasy:
|
||||
// 简单:偶尔头脑发热跟着抢
|
||||
threshold = 6 + rand.Intn(3)
|
||||
case DiffHard:
|
||||
threshold = 8
|
||||
}
|
||||
if strength >= threshold {
|
||||
return true, "这牌必须抢!翻倍走起!"
|
||||
}
|
||||
return false, "不抢,你来吧。"
|
||||
}
|
||||
|
||||
// DecidePlay 出牌决策:中等/困难难度的平民会与另一位平民打配合
|
||||
// (不压队友的牌、队友快跑完时喂单张/对子、炸弹只留给地主、封锁残局地主)
|
||||
func (a *ruleDdzAI) DecidePlay(hand []int, last *ddz.Combo, ctx PlayContext) ([]int, string) {
|
||||
moves := ddz.GenMoves(hand, last)
|
||||
if len(moves) == 0 {
|
||||
return nil, "要不起。"
|
||||
}
|
||||
// 任何难度:有能一手出完的牌直接打出去收工
|
||||
for _, mv := range moves {
|
||||
if len(mv) == len(hand) {
|
||||
return mv, "我走啦!"
|
||||
}
|
||||
}
|
||||
// 敌方剩牌很少时进入紧张状态:地主看两个平民,平民只看地主(不把队友当威胁)
|
||||
danger := ctx.OppMin > 0 && ctx.OppMin <= 2
|
||||
if a.difficulty == DiffEasy {
|
||||
// 简单:不懂配合,30% 概率直接过牌,否则随机出非炸弹牌
|
||||
if last != nil && rand.Float64() < 0.3 {
|
||||
return nil, "先过一手~"
|
||||
}
|
||||
pick := moves[rand.Intn(len(moves))]
|
||||
combo := ddz.Parse(pick)
|
||||
// 新手舍不得放炸弹
|
||||
if (combo.Type == ddz.ComboBomb || combo.Type == ddz.ComboRocket) && len(moves) > 1 {
|
||||
pick = moves[0]
|
||||
}
|
||||
return pick, ""
|
||||
}
|
||||
// —— 平民配合(中等/困难)——
|
||||
if !ctx.IsLandlord {
|
||||
if last != nil && ctx.LastIsPartner {
|
||||
// 要压的是队友的牌:队友快跑完或牌已够大(J 以上)时直接让牌
|
||||
if ctx.PartnerCards <= 4 || last.MainRank >= 11 {
|
||||
return nil, "队友你走,我不拆台~"
|
||||
}
|
||||
// 队友前期的小牌可以用小牌顺手接管节奏(不动炸弹、不动 2 和王)
|
||||
if mv := smallestQuiet(moves, 12); mv != nil {
|
||||
return mv, ""
|
||||
}
|
||||
return nil, "你继续~"
|
||||
}
|
||||
if last == nil {
|
||||
// 自由出牌:优先喂牌给快跑完的队友 / 封锁只剩一两张的地主
|
||||
if mv := farmerLead(moves, ctx); mv != nil {
|
||||
return mv, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
// —— 常规压制(对地主的牌或地主自身的策略)——
|
||||
switch a.difficulty {
|
||||
case DiffHard:
|
||||
// 困难:敌方快出完时敢放炸弹压制;否则出能压住的最小牌,且尽量不拆炸弹
|
||||
if danger {
|
||||
// 有炸弹直接炸(moves 已把炸弹排在最后)
|
||||
lastMove := moves[len(moves)-1]
|
||||
if c := ddz.Parse(lastMove); c.Type == ddz.ComboBomb || c.Type == ddz.ComboRocket {
|
||||
return lastMove, "看我王牌!"
|
||||
}
|
||||
// 没炸弹就出最大的非炸弹牌
|
||||
return moves[len(moves)-1], "顶住!"
|
||||
}
|
||||
return a.pickSmart(hand, moves), ""
|
||||
default:
|
||||
// 中等:出能压住的最小一手(moves 已按牌力升序)
|
||||
pick := moves[0]
|
||||
if c := ddz.Parse(pick); (c.Type == ddz.ComboBomb || c.Type == ddz.ComboRocket) && last != nil && !danger {
|
||||
// 不紧急时不放炸弹,选择过牌
|
||||
return nil, "先忍一手。"
|
||||
}
|
||||
return pick, ""
|
||||
}
|
||||
}
|
||||
|
||||
// smallestQuiet 最小的"安静"牌:非炸弹且主牌点数不超过 maxRank(接队友牌时避免浪费火力)
|
||||
func smallestQuiet(moves [][]int, maxRank int) []int {
|
||||
for _, mv := range moves {
|
||||
c := ddz.Parse(mv)
|
||||
if c.Type == ddz.ComboBomb || c.Type == ddz.ComboRocket {
|
||||
continue
|
||||
}
|
||||
if c.MainRank <= maxRank {
|
||||
return mv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// smallestOfType 最小的指定牌型(moves 已按牌力升序)
|
||||
func smallestOfType(moves [][]int, t ddz.ComboType) []int {
|
||||
for _, mv := range moves {
|
||||
if c := ddz.Parse(mv); c.Type == t {
|
||||
return mv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// farmerLead 平民自由出牌时的配合选择,返回 nil 表示走常规策略:
|
||||
// 队友剩 1 张喂最小单张、剩 2 张喂最小对子;地主只剩一两张时不送小单牌
|
||||
func farmerLead(moves [][]int, ctx PlayContext) []int {
|
||||
// 喂牌:队友只差一两张就赢,把节奏让给队友
|
||||
if ctx.PartnerCards == 1 {
|
||||
if mv := smallestOfType(moves, ddz.ComboSingle); mv != nil {
|
||||
return mv
|
||||
}
|
||||
}
|
||||
if ctx.PartnerCards == 2 {
|
||||
if mv := smallestOfType(moves, ddz.ComboPair); mv != nil {
|
||||
return mv
|
||||
}
|
||||
if mv := smallestOfType(moves, ddz.ComboSingle); mv != nil {
|
||||
return mv
|
||||
}
|
||||
}
|
||||
// 封锁:地主只剩 1-2 张时避免送小单牌,优先甩长牌型,否则顶最大的非炸弹牌
|
||||
if ctx.LandlordCards > 0 && ctx.LandlordCards <= 2 {
|
||||
for _, mv := range moves {
|
||||
if len(mv) >= 3 {
|
||||
if c := ddz.Parse(mv); c.Type != ddz.ComboBomb && c.Type != ddz.ComboRocket {
|
||||
return mv
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := len(moves) - 1; i >= 0; i-- {
|
||||
if c := ddz.Parse(moves[i]); c.Type != ddz.ComboBomb && c.Type != ddz.ComboRocket {
|
||||
return moves[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickSmart 困难难度的挑牌:优先出"不拆散手牌结构"的最小牌
|
||||
// 简化实现:跳过会拆散炸弹的牌,其余取最小
|
||||
func (a *ruleDdzAI) pickSmart(hand []int, moves [][]int) []int {
|
||||
// 统计手牌中构成炸弹的点数
|
||||
bombRanks := map[int]bool{}
|
||||
cnt := map[int]int{}
|
||||
for _, c := range hand {
|
||||
cnt[ddz.Rank(c)]++
|
||||
}
|
||||
for r, k := range cnt {
|
||||
if k == 4 {
|
||||
bombRanks[r] = true
|
||||
}
|
||||
}
|
||||
for _, mv := range moves {
|
||||
combo := ddz.Parse(mv)
|
||||
// 完整炸弹不算拆
|
||||
if combo.Type == ddz.ComboBomb || combo.Type == ddz.ComboRocket {
|
||||
continue
|
||||
}
|
||||
breaksBomb := false
|
||||
for _, c := range mv {
|
||||
if bombRanks[ddz.Rank(c)] {
|
||||
breaksBomb = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !breaksBomb {
|
||||
return mv
|
||||
}
|
||||
}
|
||||
return moves[0]
|
||||
}
|
||||
132
internal/ai/ddz_rule_test.go
Normal file
132
internal/ai/ddz_rule_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/ddz"
|
||||
)
|
||||
|
||||
// card 按点数造一张牌(花色取 0-3 循环,保证同点数多张时编码不同)
|
||||
// 点数 r:3-15(15=2),16=小王,17=大王
|
||||
func card(r, i int) int {
|
||||
if r == 16 {
|
||||
return 52
|
||||
}
|
||||
if r == 17 {
|
||||
return 53
|
||||
}
|
||||
return (r-3)*4 + i
|
||||
}
|
||||
|
||||
// TestFarmerNotBeatPartner 平民不压队友:队友出的中等牌(J 以上)应选择过牌
|
||||
func TestFarmerNotBeatPartner(t *testing.T) {
|
||||
a := &ruleDdzAI{difficulty: DiffMedium, name: "测试"}
|
||||
// 手牌里有 2(能压 J)但队友出的 J 不该压
|
||||
hand := []int{card(15, 0), card(10, 0), card(5, 0), card(5, 1)}
|
||||
last := ddz.Parse([]int{card(11, 0)}) // 队友出单张 J
|
||||
pick, _ := a.DecidePlay(hand, last, PlayContext{
|
||||
IsLandlord: false, LastIsPartner: true,
|
||||
PartnerCards: 8, LandlordCards: 10, OppMin: 10,
|
||||
})
|
||||
if pick != nil {
|
||||
t.Fatalf("平民不应压队友的大牌,却出了 %v", pick)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFarmerBeatPartnerSmall 队友前期小牌可以用小牌接管,但不能动 2/王/炸弹
|
||||
func TestFarmerBeatPartnerSmall(t *testing.T) {
|
||||
a := &ruleDdzAI{difficulty: DiffMedium, name: "测试"}
|
||||
hand := []int{card(15, 0), card(8, 0), card(6, 0), card(6, 1)}
|
||||
last := ddz.Parse([]int{card(4, 0)}) // 队友出单张 4(早期小牌)
|
||||
pick, _ := a.DecidePlay(hand, last, PlayContext{
|
||||
IsLandlord: false, LastIsPartner: true,
|
||||
PartnerCards: 10, LandlordCards: 12, OppMin: 12,
|
||||
})
|
||||
if pick == nil {
|
||||
t.Fatal("队友的小牌应该用小牌接管节奏")
|
||||
}
|
||||
if c := ddz.Parse(pick); c.MainRank > 12 {
|
||||
t.Fatalf("接队友的牌不应动大牌,却出了主点数 %d", c.MainRank)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFarmerFinishOverridesCoop 能一手出完时即使是队友的牌也直接出完取胜
|
||||
func TestFarmerFinishOverridesCoop(t *testing.T) {
|
||||
a := &ruleDdzAI{difficulty: DiffMedium, name: "测试"}
|
||||
hand := []int{card(14, 0)} // 只剩一张 A
|
||||
last := ddz.Parse([]int{card(11, 0)}) // 队友出单张 J
|
||||
pick, _ := a.DecidePlay(hand, last, PlayContext{
|
||||
IsLandlord: false, LastIsPartner: true,
|
||||
PartnerCards: 3, LandlordCards: 9, OppMin: 9,
|
||||
})
|
||||
if len(pick) != 1 {
|
||||
t.Fatalf("能直接出完应立即取胜,实际出牌 %v", pick)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFarmerFeedsPartnerSingle 队友只剩 1 张时,平民自由出牌应喂最小单张
|
||||
func TestFarmerFeedsPartnerSingle(t *testing.T) {
|
||||
a := &ruleDdzAI{difficulty: DiffMedium, name: "测试"}
|
||||
hand := []int{card(15, 0), card(12, 0), card(12, 1), card(3, 0)}
|
||||
pick, _ := a.DecidePlay(hand, nil, PlayContext{
|
||||
IsLandlord: false, LastIsPartner: false,
|
||||
PartnerCards: 1, LandlordCards: 8, OppMin: 8,
|
||||
})
|
||||
c := ddz.Parse(pick)
|
||||
if c == nil || c.Type != ddz.ComboSingle {
|
||||
t.Fatalf("队友剩 1 张应喂单张,实际 %v", pick)
|
||||
}
|
||||
if c.MainRank != 3 {
|
||||
t.Fatalf("应喂最小单张 3,实际主点数 %d", c.MainRank)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFarmerFeedsPartnerPair 队友只剩 2 张时优先喂最小对子
|
||||
func TestFarmerFeedsPartnerPair(t *testing.T) {
|
||||
a := &ruleDdzAI{difficulty: DiffMedium, name: "测试"}
|
||||
hand := []int{card(15, 0), card(9, 0), card(9, 1), card(4, 0), card(4, 1)}
|
||||
pick, _ := a.DecidePlay(hand, nil, PlayContext{
|
||||
IsLandlord: false, LastIsPartner: false,
|
||||
PartnerCards: 2, LandlordCards: 8, OppMin: 8,
|
||||
})
|
||||
c := ddz.Parse(pick)
|
||||
if c == nil || c.Type != ddz.ComboPair {
|
||||
t.Fatalf("队友剩 2 张应喂对子,实际 %v", pick)
|
||||
}
|
||||
if c.MainRank != 4 {
|
||||
t.Fatalf("应喂最小对子 4,实际主点数 %d", c.MainRank)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFarmerBlocksLandlordEndgame 地主只剩 1 张时,平民领出应避免送小单牌
|
||||
func TestFarmerBlocksLandlordEndgame(t *testing.T) {
|
||||
a := &ruleDdzAI{difficulty: DiffMedium, name: "测试"}
|
||||
// 手牌:小单 3、对 7、大单 A
|
||||
hand := []int{card(14, 0), card(7, 0), card(7, 1), card(3, 0)}
|
||||
pick, _ := a.DecidePlay(hand, nil, PlayContext{
|
||||
IsLandlord: false, LastIsPartner: false,
|
||||
PartnerCards: 10, LandlordCards: 1, OppMin: 1,
|
||||
})
|
||||
c := ddz.Parse(pick)
|
||||
if c == nil {
|
||||
t.Fatal("应有出牌")
|
||||
}
|
||||
// 不应送出最小单张 3(地主最后一张很可能压过)
|
||||
if c.Type == ddz.ComboSingle && c.MainRank == 3 {
|
||||
t.Fatal("地主只剩 1 张时不应送最小单牌")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLandlordUnchanged 地主视角:正常出能压住的最小牌,不受配合逻辑影响
|
||||
func TestLandlordUnchanged(t *testing.T) {
|
||||
a := &ruleDdzAI{difficulty: DiffMedium, name: "测试"}
|
||||
hand := []int{card(15, 0), card(10, 0), card(6, 0)}
|
||||
last := ddz.Parse([]int{card(5, 0)})
|
||||
pick, _ := a.DecidePlay(hand, last, PlayContext{
|
||||
IsLandlord: true, LandlordCards: 3, OppMin: 10,
|
||||
})
|
||||
c := ddz.Parse(pick)
|
||||
if c == nil || c.MainRank != 6 {
|
||||
t.Fatalf("地主应出能压住的最小牌 6,实际 %v", pick)
|
||||
}
|
||||
}
|
||||
168
internal/ai/factory.go
Normal file
168
internal/ai/factory.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"nl-game-api-gin/internal/config"
|
||||
"nl-game-api-gin/internal/gamecore/ddz"
|
||||
"nl-game-api-gin/internal/gamecore/xiangqi"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
)
|
||||
|
||||
// Provider 提供方标识
|
||||
const (
|
||||
ProviderSpark = "spark" // 讯飞星火 Lite
|
||||
ProviderDeepSeek = "deepseek" // DeepSeek
|
||||
ProviderRule = "rule" // 本地规则 AI
|
||||
)
|
||||
|
||||
// Difficulty 难度标识
|
||||
const (
|
||||
DiffEasy = "easy" // 简单
|
||||
DiffMedium = "medium" // 中等
|
||||
DiffHard = "hard" // 困难
|
||||
)
|
||||
|
||||
// PlayContext 斗地主出牌决策上下文:身份与各家剩牌情况
|
||||
// 平民 AI 依赖这些信息与另一位平民打配合(不压队友、喂牌、封锁地主)
|
||||
type PlayContext struct {
|
||||
IsLandlord bool // 自己是否地主
|
||||
LastIsPartner bool // 当前要压的牌是否队友(另一位平民)所出
|
||||
PartnerCards int // 队友剩牌数(自己是地主时为 0)
|
||||
LandlordCards int // 地主剩牌数
|
||||
OppMin int // 敌方最少剩牌数(地主视角=两平民的最小值;平民视角=地主剩牌)
|
||||
}
|
||||
|
||||
// DdzAI 斗地主 AI 决策接口
|
||||
type DdzAI interface {
|
||||
// Name AI 显示名称(房间座位与聊天展示)
|
||||
Name() string
|
||||
// DecideBid 叫地主决策:返回是否叫地主 + 一句台词
|
||||
DecideBid(hand []int) (bool, string)
|
||||
// DecideRob 抢地主决策:返回是否抢 + 一句台词(抢一次倍数×2)
|
||||
DecideRob(hand []int) (bool, string)
|
||||
// DecidePlay 出牌决策:last 为需要压的牌(nil=自由出牌)
|
||||
// 返回出的牌(nil=过牌)+ 一句台词
|
||||
DecidePlay(hand []int, last *ddz.Combo, ctx PlayContext) ([]int, string)
|
||||
}
|
||||
|
||||
// ChessAI 中国象棋 AI 决策接口
|
||||
type ChessAI interface {
|
||||
// Name AI 显示名称
|
||||
Name() string
|
||||
// DecideMove 走子决策:给定棋盘与执子方,返回着法 + 一句台词
|
||||
DecideMove(b *xiangqi.Board, side int) (xiangqi.Move, string)
|
||||
}
|
||||
|
||||
// GlobalProvider 全站统一的对战 AI 提供方(后台「AI 设置」页配置,玩家前台只选难度)
|
||||
// 未配置或值非法时回退本地规则 AI
|
||||
func GlobalProvider() string {
|
||||
p := service.GetConfig(model.ConfKeyAIProvider, ProviderRule)
|
||||
if p != ProviderSpark && p != ProviderDeepSeek {
|
||||
return ProviderRule
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// providerName 提供方的中文显示名
|
||||
func providerName(provider string) string {
|
||||
switch provider {
|
||||
case ProviderSpark:
|
||||
return "星火AI"
|
||||
case ProviderDeepSeek:
|
||||
return "DeepSeekAI"
|
||||
default:
|
||||
return "电脑AI"
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveConf 计算提供方的生效配置:后台(数据库)非空项覆盖 config.yaml
|
||||
// 这样管理员在后台改完 Key 立即生效,无需重启服务
|
||||
func effectiveConf(provider string) config.LLMConf {
|
||||
var base config.LLMConf
|
||||
var kKey, kBase, kModel string
|
||||
switch provider {
|
||||
case ProviderSpark:
|
||||
base, kKey, kBase, kModel = config.C.AI.Spark, model.ConfKeyAISparkKey, model.ConfKeyAISparkBase, model.ConfKeyAISparkModel
|
||||
case ProviderDeepSeek:
|
||||
base, kKey, kBase, kModel = config.C.AI.DeepSeek, model.ConfKeyAIDeepSeekKey, model.ConfKeyAIDeepSeekBase, model.ConfKeyAIDeepSeekModel
|
||||
default:
|
||||
return config.LLMConf{}
|
||||
}
|
||||
if v := service.GetConfig(kKey, ""); v != "" {
|
||||
base.APIKey = v
|
||||
}
|
||||
if v := service.GetConfig(kBase, ""); v != "" {
|
||||
base.BaseURL = v
|
||||
}
|
||||
if v := service.GetConfig(kModel, ""); v != "" {
|
||||
base.Model = v
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// clientFor 按提供方构造 LLM 客户端(未配置 Key 返回 nil)
|
||||
func clientFor(provider string) *LLMClient {
|
||||
switch provider {
|
||||
case ProviderSpark, ProviderDeepSeek:
|
||||
return newLLMClient(effectiveConf(provider))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ClientForTest 暴露给后台连通性测试使用的客户端构造(未配置 Key 返回 nil)
|
||||
func ClientForTest(provider string) *LLMClient {
|
||||
return clientFor(provider)
|
||||
}
|
||||
|
||||
// temperatureFor 难度对应的采样温度:难度越低随机性越大
|
||||
func temperatureFor(difficulty string) float64 {
|
||||
switch difficulty {
|
||||
case DiffEasy:
|
||||
return 1.2
|
||||
case DiffHard:
|
||||
return 0.3
|
||||
default:
|
||||
return 0.7
|
||||
}
|
||||
}
|
||||
|
||||
// styleFor 难度对应的提示词人设(影响 LLM 决策风格)
|
||||
func styleFor(difficulty string) string {
|
||||
switch difficulty {
|
||||
case DiffEasy:
|
||||
return "你是一位刚学会玩的新手,水平一般,偶尔会犯错,说话呆萌可爱。"
|
||||
case DiffHard:
|
||||
return "你是一位身经百战的顶尖高手,每一步都追求最优解,说话简短犀利、气场十足。"
|
||||
default:
|
||||
return "你是一位经验不错的业余玩家,打法稳健,说话轻松幽默。"
|
||||
}
|
||||
}
|
||||
|
||||
// NewDdzAI 斗地主 AI 工厂:按「提供方 × 难度」组装实例
|
||||
// LLM 提供方未配置 Key 时自动降级为规则 AI
|
||||
func NewDdzAI(provider, difficulty string) DdzAI {
|
||||
rule := &ruleDdzAI{difficulty: difficulty, name: providerName(ProviderRule)}
|
||||
client := clientFor(provider)
|
||||
if client == nil {
|
||||
return rule
|
||||
}
|
||||
// LLM 实例持有规则 AI 作为兜底
|
||||
return &llmDdzAI{
|
||||
client: client, difficulty: difficulty,
|
||||
fallback: rule, name: providerName(provider),
|
||||
}
|
||||
}
|
||||
|
||||
// NewChessAI 中国象棋 AI 工厂:按「提供方 × 难度」组装实例
|
||||
func NewChessAI(provider, difficulty string) ChessAI {
|
||||
rule := &ruleChessAI{difficulty: difficulty, name: providerName(ProviderRule)}
|
||||
client := clientFor(provider)
|
||||
if client == nil {
|
||||
return rule
|
||||
}
|
||||
return &llmChessAI{
|
||||
client: client, difficulty: difficulty,
|
||||
fallback: rule, name: providerName(provider),
|
||||
}
|
||||
}
|
||||
226
internal/ai/llm.go
Normal file
226
internal/ai/llm.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Package ai 用工厂模式封装对战 AI:
|
||||
// - LLM Provider(讯飞星火 Lite / DeepSeek,OpenAI 兼容接口)负责"思考"
|
||||
// - 本地规则 AI 负责兜底(LLM 未配置、超时或返回非法决策时接管)
|
||||
// - 难度(easy/medium/hard)通过提示词与候选着法筛选实现
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nl-game-api-gin/internal/config"
|
||||
)
|
||||
|
||||
// LLMClient OpenAI 兼容格式的大模型 HTTP 客户端
|
||||
type LLMClient struct {
|
||||
BaseURL string // 接口基础地址(如 https://api.deepseek.com/v1)
|
||||
APIKey string // 鉴权密钥
|
||||
Model string // 模型名(如 lite / deepseek-chat)
|
||||
http *http.Client // 复用的 HTTP 客户端
|
||||
}
|
||||
|
||||
// newLLMClient 根据配置构造客户端(Key 为空返回 nil 表示不可用)
|
||||
func newLLMClient(conf config.LLMConf) *LLMClient {
|
||||
if conf.APIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &LLMClient{
|
||||
BaseURL: conf.BaseURL,
|
||||
APIKey: conf.APIKey,
|
||||
Model: conf.Model,
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// chatMessage OpenAI 格式的对话消息
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"` // system / user / assistant
|
||||
Content string `json:"content"` // 消息内容
|
||||
}
|
||||
|
||||
// chatRequest OpenAI 格式的补全请求
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"` // 模型名
|
||||
Messages []chatMessage `json:"messages"` // 对话消息
|
||||
Temperature float64 `json:"temperature"` // 采样温度(难度越低越随机)
|
||||
MaxTokens int `json:"max_tokens"` // 最大生成长度
|
||||
}
|
||||
|
||||
// chatResponse OpenAI 格式的补全响应(只取需要的字段)
|
||||
type chatResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// 并发抢答策略:每次决策同时发出 3 路请求,先通过校验者胜出;单路请求限时 6 秒
|
||||
const (
|
||||
chatParallel = 3
|
||||
chatAttemptTimeout = 6 * time.Second
|
||||
)
|
||||
|
||||
// Chat 发送一轮对话请求(并发抢答 + 返回校验),返回模型输出文本。
|
||||
// 同时异步发出 chatParallel 路相同请求,结果逐个校验:
|
||||
// 第一个通过校验的立即采用(其余在途请求自动取消);全部失败才汇总错误返回。
|
||||
// 相比串行重试,网络抖动时无需退避等待,正常时延迟等于最快一路
|
||||
func (c *LLMClient) Chat(ctx context.Context, system, user string, temperature float64) (string, error) {
|
||||
raceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel() // 胜出后取消其余在途请求
|
||||
results := make(chan error, chatParallel)
|
||||
contents := make(chan string, chatParallel)
|
||||
for i := 0; i < chatParallel; i++ {
|
||||
go func() {
|
||||
content, err := c.chatOnce(raceCtx, system, user, temperature)
|
||||
if err == nil {
|
||||
contents <- content
|
||||
results <- nil
|
||||
return
|
||||
}
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
// 等待全部返回并逐个校验(chatOnce 内已完成三层校验),一有合格结果立刻采用
|
||||
uniqErrs := []string{}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < chatParallel; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("等待模型返回超时:%w", ctx.Err())
|
||||
case err := <-results:
|
||||
if err == nil {
|
||||
return <-contents, nil
|
||||
}
|
||||
if msg := err.Error(); !seen[msg] {
|
||||
seen[msg] = true
|
||||
uniqErrs = append(uniqErrs, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%d 路并发请求全部失败:%s", chatParallel, strings.Join(uniqErrs, ";"))
|
||||
}
|
||||
|
||||
// chatOnce 单路请求 + 三层返回校验(HTTP 状态码 → 响应体结构 → 内容非空)
|
||||
func (c *LLMClient) chatOnce(ctx context.Context, system, user string, temperature float64) (content string, err error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, chatAttemptTimeout)
|
||||
defer cancel()
|
||||
body, _ := json.Marshal(chatRequest{
|
||||
Model: c.Model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
Temperature: temperature,
|
||||
MaxTokens: 300,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(attemptCtx, "POST", c.BaseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求失败:%w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
// 防御超大响应体:最多读 1MB
|
||||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
// 校验一:HTTP 状态码(错误信息带状态码与响应体片段,便于后台测试时定位)
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("HTTP %d:%s", res.StatusCode, bodySnippet(raw))
|
||||
}
|
||||
// 校验二:响应体必须是结构完整的 JSON
|
||||
var parsed chatResponse
|
||||
if jerr := json.Unmarshal(raw, &parsed); jerr != nil {
|
||||
return "", fmt.Errorf("响应不是合法 JSON:%s", bodySnippet(raw))
|
||||
}
|
||||
if parsed.Error != nil {
|
||||
return "", errors.New(parsed.Error.Message)
|
||||
}
|
||||
if len(parsed.Choices) == 0 {
|
||||
return "", errors.New("模型未返回任何候选内容")
|
||||
}
|
||||
// 校验三:输出内容非空
|
||||
content = strings.TrimSpace(parsed.Choices[0].Message.Content)
|
||||
if content == "" {
|
||||
return "", errors.New("模型返回了空内容")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// bodySnippet 截取响应体前 200 字符用于报错展示(避免日志被撑爆)
|
||||
func bodySnippet(raw []byte) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if runes := []rune(s); len(runes) > 200 {
|
||||
s = string(runes[:200]) + "…"
|
||||
}
|
||||
if s == "" {
|
||||
return "(空响应体)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ChatDecision 请求一次"从候选中选编号"的决策并校验输出:
|
||||
// 模型输出不是合法 JSON 或 choice 越界时,追加纠错指令自动重问一次(传输层故障由 Chat 的并发抢答兜底)
|
||||
func (c *LLMClient) ChatDecision(ctx context.Context, system, user string, temperature float64, maxChoice int) (*llmDecision, error) {
|
||||
sys := system
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
output, err := c.Chat(ctx, sys, user, temperature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, perr := parseDecision(output, maxChoice)
|
||||
if perr == nil {
|
||||
return d, nil
|
||||
}
|
||||
lastErr = perr
|
||||
sys = system + fmt.Sprintf(
|
||||
"注意:你上一次的输出无法解析(%v)。必须只输出一个 JSON 对象,choice 取值范围 0~%d,不要任何多余文字。",
|
||||
perr, maxChoice)
|
||||
}
|
||||
return nil, fmt.Errorf("模型输出连续两次未通过校验:%w", lastErr)
|
||||
}
|
||||
|
||||
// jsonBlockRe 从模型输出中提取第一个 JSON 对象(模型偶尔会包 markdown 代码块)
|
||||
var jsonBlockRe = regexp.MustCompile(`\{[\s\S]*\}`)
|
||||
|
||||
// llmDecision LLM 决策的统一 JSON 结构:从候选列表中选一项 + 一句台词
|
||||
type llmDecision struct {
|
||||
Choice int `json:"choice"` // 候选编号
|
||||
Say string `json:"say"` // 台词(可为空)
|
||||
}
|
||||
|
||||
// parseDecision 解析模型输出中的决策 JSON,choice 越界视为失败
|
||||
func parseDecision(output string, maxChoice int) (*llmDecision, error) {
|
||||
match := jsonBlockRe.FindString(output)
|
||||
if match == "" {
|
||||
return nil, errors.New("输出中没有 JSON")
|
||||
}
|
||||
var d llmDecision
|
||||
if err := json.Unmarshal([]byte(match), &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Choice < 0 || d.Choice > maxChoice {
|
||||
return nil, fmt.Errorf("choice=%d 越界", d.Choice)
|
||||
}
|
||||
// 台词过长时截断,避免刷屏
|
||||
runes := []rune(d.Say)
|
||||
if len(runes) > 40 {
|
||||
d.Say = string(runes[:40])
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
171
internal/ai/llm_test.go
Normal file
171
internal/ai/llm_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestClient 构造指向本地假服务器的 LLM 客户端
|
||||
func newTestClient(srv *httptest.Server) *LLMClient {
|
||||
return &LLMClient{
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "test-key",
|
||||
Model: "test-model",
|
||||
http: srv.Client(),
|
||||
}
|
||||
}
|
||||
|
||||
// okBody 组装一条合法的 OpenAI 格式响应
|
||||
func okBody(content string) string {
|
||||
return fmt.Sprintf(`{"choices":[{"message":{"content":%q}}]}`, content)
|
||||
}
|
||||
|
||||
// TestChatOneValidWins 三路并发中只要有一路合格就应成功(其余 500/非法 JSON)
|
||||
func TestChatOneValidWins(t *testing.T) {
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch atomic.AddInt32(&calls, 1) {
|
||||
case 1:
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte("overloaded"))
|
||||
case 2:
|
||||
w.Write([]byte("<html>bad gateway</html>"))
|
||||
default:
|
||||
w.Write([]byte(okBody("你好")))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
out, err := newTestClient(srv).Chat(context.Background(), "s", "u", 0.5)
|
||||
if err != nil || out != "你好" {
|
||||
t.Fatalf("有一路合格就应成功,got out=%q err=%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAllFailAggregates 三路全部失败:应发满 3 路并汇总去重后的错误
|
||||
func TestChatAllFailAggregates(t *testing.T) {
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(`{"error":{"message":"invalid api key"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := newTestClient(srv).Chat(context.Background(), "s", "u", 0.5)
|
||||
if err == nil {
|
||||
t.Fatal("全部失败应返回错误")
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != chatParallel {
|
||||
t.Fatalf("应并发发出 %d 路请求,实际 %d", chatParallel, got)
|
||||
}
|
||||
// 相同错误应去重,不会把同一句话重复三遍
|
||||
if strings.Count(err.Error(), "HTTP 401") != 1 {
|
||||
t.Fatalf("相同错误应去重,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatEarlyReturn 一路秒回合格结果时不应等慢的两路跑完
|
||||
func TestChatEarlyReturn(t *testing.T) {
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
w.Write([]byte(okBody("快答")))
|
||||
return
|
||||
}
|
||||
// 其余两路拖 3 秒(可被取消提前结束)
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
w.Write([]byte(okBody("慢答")))
|
||||
}))
|
||||
defer srv.Close()
|
||||
start := time.Now()
|
||||
out, err := newTestClient(srv).Chat(context.Background(), "s", "u", 0.5)
|
||||
if err != nil || out != "快答" {
|
||||
t.Fatalf("应采用最快的合格结果,got out=%q err=%v", out, err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 1500*time.Millisecond {
|
||||
t.Fatalf("拿到合格结果后应立即返回,实际耗时 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatValidationChain 空 choices、空内容都应被校验拦下,仅内容合格的一路胜出
|
||||
func TestChatValidationChain(t *testing.T) {
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch atomic.AddInt32(&calls, 1) {
|
||||
case 1:
|
||||
w.Write([]byte(`{"choices":[]}`))
|
||||
case 2:
|
||||
w.Write([]byte(okBody(" ")))
|
||||
default:
|
||||
w.Write([]byte(okBody("有效内容")))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
out, err := newTestClient(srv).Chat(context.Background(), "s", "u", 0.5)
|
||||
if err != nil || out != "有效内容" {
|
||||
t.Fatalf("校验链应过滤空返回,got out=%q err=%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatRespectContext 调用方超时应尽快中止等待
|
||||
func TestChatRespectContext(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer srv.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
_, err := newTestClient(srv).Chat(ctx, "s", "u", 0.5)
|
||||
if err == nil {
|
||||
t.Fatal("超时应返回错误")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("超时后应立即中止,实际耗时 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatDecisionReask 输出不是决策 JSON 时应追加纠错提示重问一次
|
||||
// (用请求体是否含纠错标记做确定性分流,避免并发时序影响)
|
||||
func TestChatDecisionReask(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(body), "无法解析") {
|
||||
// 第二轮(带纠错提示):返回合法决策
|
||||
w.Write([]byte(okBody(`{"choice":1,"say":"就选它"}`)))
|
||||
return
|
||||
}
|
||||
// 第一轮:返回闲聊文本(通过 Chat 校验但不是决策 JSON)
|
||||
w.Write([]byte(okBody("我出王炸!哈哈哈")))
|
||||
}))
|
||||
defer srv.Close()
|
||||
d, err := newTestClient(srv).ChatDecision(context.Background(), "s", "u", 0.5, 3)
|
||||
if err != nil || d.Choice != 1 {
|
||||
t.Fatalf("纠错重问应成功,got d=%+v err=%v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatDecisionGiveUp choice 连续越界:两轮后放弃报错(调用方走规则兜底)
|
||||
func TestChatDecisionGiveUp(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(okBody(`{"choice":99,"say":"乱选"}`)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := newTestClient(srv).ChatDecision(context.Background(), "s", "u", 0.5, 3)
|
||||
if err == nil {
|
||||
t.Fatal("连续越界应返回错误")
|
||||
}
|
||||
}
|
||||
59
internal/config/config.go
Normal file
59
internal/config/config.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package config 负责加载并全局提供 config.yaml 中的配置
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LLMConf 单个大模型提供方的连接配置
|
||||
type LLMConf struct {
|
||||
APIKey string `yaml:"api_key"` // API 密钥(为空则该模型不可用,走规则 AI 兜底)
|
||||
BaseURL string `yaml:"base_url"` // OpenAI 兼容接口的基础地址
|
||||
Model string `yaml:"model"` // 模型名称(如 lite / deepseek-chat)
|
||||
}
|
||||
|
||||
// Config 后端全部配置的根结构
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Port int `yaml:"port"` // HTTP 服务监听端口
|
||||
} `yaml:"server"`
|
||||
MySQL struct {
|
||||
Host string `yaml:"host"` // 数据库主机
|
||||
Port int `yaml:"port"` // 数据库端口
|
||||
User string `yaml:"user"` // 数据库账号
|
||||
Password string `yaml:"password"` // 数据库密码
|
||||
Database string `yaml:"database"` // 库名
|
||||
} `yaml:"mysql"`
|
||||
JWT struct {
|
||||
Secret string `yaml:"secret"` // JWT 签名密钥
|
||||
ExpireHours int `yaml:"expire_hours"` // Token 有效期(小时)
|
||||
} `yaml:"jwt"`
|
||||
AI struct {
|
||||
Spark LLMConf `yaml:"spark"` // 讯飞星火 Lite 配置
|
||||
DeepSeek LLMConf `yaml:"deepseek"` // DeepSeek 配置
|
||||
} `yaml:"ai"`
|
||||
}
|
||||
|
||||
// C 全局配置实例(Load 成功后可直接读取)
|
||||
var C Config
|
||||
|
||||
// Load 从指定路径读取 YAML 配置并解析到全局变量 C
|
||||
func Load(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &C); err != nil {
|
||||
return fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DSN 拼接 GORM 使用的 MySQL 连接串
|
||||
func (c *Config) DSN() string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=false&loc=Local",
|
||||
c.MySQL.User, c.MySQL.Password, c.MySQL.Host, c.MySQL.Port, c.MySQL.Database)
|
||||
}
|
||||
39
internal/database/db.go
Normal file
39
internal/database/db.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Package database 负责数据库连接初始化与 GORM 实例管理
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"nl-game-api-gin/internal/config"
|
||||
)
|
||||
|
||||
// DB 全局数据库实例,Init 成功后可直接使用
|
||||
var DB *gorm.DB
|
||||
|
||||
// Init 连接 MySQL 并配置连接池
|
||||
func Init() error {
|
||||
db, err := gorm.Open(mysql.Open(config.C.DSN()), &gorm.Config{
|
||||
// 只打印慢查询与错误 SQL,避免日志刷屏
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
// dev 自动建表时不生成物理外键约束,与 sql/init.sql 的手写表结构保持一致
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接 MySQL 失败: %w", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取底层连接池失败: %w", err)
|
||||
}
|
||||
// 连接池参数:空闲 10、最大 100、连接最长存活 1 小时
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
DB = db
|
||||
return nil
|
||||
}
|
||||
68
internal/database/migrate.go
Normal file
68
internal/database/migrate.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// dev 环境自动建表(仅 go run 生效)
|
||||
//
|
||||
// 安全约定:表结构迁移只允许发生在开发模式(go run 启动)。
|
||||
// go build 打包出的二进制启动时绝不会执行任何迁移——生产库的结构
|
||||
// 变更一律通过 sql/ 目录下的脚本人工执行,防止 GORM 自动改表出问题。
|
||||
//
|
||||
// dev 下也只做最保守的一件事:补建"库里不存在的表"。
|
||||
// 已存在的表一律不动(不加列、不加索引),因为手写 SQL 的索引命名
|
||||
// (uk_username 等)与 GORM 默认命名不同,全量 AutoMigrate 会重复建索引。
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"nl-game-api-gin/internal/model"
|
||||
)
|
||||
|
||||
// IsDevRun 判断当前进程是否由 go run 启动:
|
||||
// go run 会先把临时二进制编译到 go-build 缓存目录(如 %TEMP%\go-buildXXXX)再运行,
|
||||
// 而 go build 的产物路径不含该特征,因此打包后的服务永远不会命中 dev 分支。
|
||||
func IsDevRun() bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(exe, "go-build")
|
||||
}
|
||||
|
||||
// AutoMigrateMissing 遍历全部 GORM 模型,为缺失的表建表(含索引),已存在的表跳过。
|
||||
// 新增 model 后在下方清单登记一行,dev 启动即可自动建表;种子数据仍需执行对应 SQL 脚本。
|
||||
func AutoMigrateMissing() error {
|
||||
models := []interface{ TableName() string }{
|
||||
// 用户与登录
|
||||
&model.User{}, &model.LoginLog{},
|
||||
// 游戏与对局记录
|
||||
&model.Game{}, &model.GameRecord{}, &model.BattleRecord{},
|
||||
// 积分与签到
|
||||
&model.PointRecord{}, &model.SignIn{},
|
||||
// 商城:购物车 / 订单 / 已购游戏 / 道具
|
||||
&model.Cart{}, &model.Order{}, &model.OrderItem{}, &model.UserGame{}, &model.Prop{}, &model.UserProp{},
|
||||
// 社交:好友 / 私聊
|
||||
&model.Friend{}, &model.ChatMessage{},
|
||||
// 主题与站点配置
|
||||
&model.Theme{}, &model.SiteConfig{},
|
||||
// VIP 等级
|
||||
&model.VipLevel{},
|
||||
// 关卡进度 / 云存档 / 皮肤
|
||||
&model.GameProgress{}, &model.GameSave{},
|
||||
&model.GameSkin{}, &model.UserSkin{},
|
||||
}
|
||||
created := 0
|
||||
for _, m := range models {
|
||||
if DB.Migrator().HasTable(m) {
|
||||
continue
|
||||
}
|
||||
if err := DB.Migrator().CreateTable(m); err != nil {
|
||||
return err
|
||||
}
|
||||
created++
|
||||
log.Printf("[dev迁移] 已创建缺失表 %s(种子数据请执行 sql/ 对应脚本)", m.TableName())
|
||||
}
|
||||
if created == 0 {
|
||||
log.Println("[dev迁移] 全部表已存在,无需创建")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
554
internal/gamecore/billiards/billiards.go
Normal file
554
internal/gamecore/billiards/billiards.go
Normal file
@@ -0,0 +1,554 @@
|
||||
// Package billiards 2D 八球台球:服务端权威物理模拟 + 简化八球规则
|
||||
// 玩家提交击球角度与力度,服务端逐帧模拟碰撞、库边反弹与落袋,
|
||||
// 并把关键帧序列返回给前端做回放动画,保证双方看到一致的结果
|
||||
package billiards
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// 桌面与物理常量(单位:像素、秒)
|
||||
const (
|
||||
TableW = 800.0 // 桌面宽
|
||||
TableH = 400.0 // 桌面高
|
||||
BallR = 10.0 // 球半径
|
||||
PocketR = 26.0 // 角袋捕获半径
|
||||
PocketRm = 24.0 // 中袋捕获半径
|
||||
FricLin = 55.0 // 线性摩擦减速度
|
||||
FricProp = 0.30 // 速度比例阻尼系数
|
||||
Restitut = 0.92 // 库边反弹恢复系数
|
||||
StopSpeed = 4.0 // 低于该速度视为停止
|
||||
SimDt = 1.0 / 120.0 // 物理步长
|
||||
FrameEach = 6 // 每 6 个物理步记录一帧(20fps 回放)
|
||||
MaxSimSec = 20.0 // 单杆模拟时长上限
|
||||
CueStartX = 200.0 // 白球初始/重置位置
|
||||
CueStartY = 200.0
|
||||
)
|
||||
|
||||
// 球组
|
||||
const (
|
||||
GroupNone = 0 // 未分组
|
||||
GroupSolid = 1 // 全色球(1-7)
|
||||
GroupStripe = 2 // 花色球(9-15)
|
||||
)
|
||||
|
||||
// 阶段
|
||||
const (
|
||||
PhaseAim = "aim" // 等待当前玩家击球
|
||||
PhaseOver = "over" // 对局结束
|
||||
)
|
||||
|
||||
// Ball 一颗球
|
||||
type Ball struct {
|
||||
ID int `json:"id"` // 0=白球 1-7全色 8=黑八 9-15花色
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
On bool `json:"on"` // 是否还在桌面上
|
||||
vx float64
|
||||
vy float64
|
||||
// 杆法旋转(仅白球有效):spinX 左右塞 -1~1(右为正),spinY 高低杆 -1~1(高杆为正)
|
||||
spinX float64
|
||||
spinY float64
|
||||
}
|
||||
|
||||
// Game 一局台球(两座位)
|
||||
type Game struct {
|
||||
Balls [16]*Ball // 全部球
|
||||
Turn int // 当前击球座位(0/1)
|
||||
Groups [2]int // 双方球组(未定为 GroupNone)
|
||||
Phase string // aim / over
|
||||
Winner int // 获胜座位(-1 未定)
|
||||
Shots int // 已击球杆数
|
||||
}
|
||||
|
||||
// ShotResult 一杆的模拟结果
|
||||
type ShotResult struct {
|
||||
Frames [][][3]float64 // 回放关键帧:每帧为 [ [id,x,y], ... ](仅在桌球)
|
||||
Potted []int // 本杆落袋的球 ID(含白球)
|
||||
Foul bool // 是否犯规(白球落袋或空杆)
|
||||
Continue bool // 击球方是否继续击球
|
||||
Over bool // 对局是否结束
|
||||
Winner int // 结束时的获胜座位
|
||||
Desc string // 一句话战报
|
||||
}
|
||||
|
||||
// pockets 六个袋口坐标与捕获半径
|
||||
var pockets = [6][3]float64{
|
||||
{0, 0, PocketR}, {TableW, 0, PocketR}, {0, TableH, PocketR}, {TableW, TableH, PocketR},
|
||||
{TableW / 2, -4, PocketRm}, {TableW / 2, TableH + 4, PocketRm},
|
||||
}
|
||||
|
||||
// NewGame 摆球开局:白球在左侧,15 颗彩球在右侧摆三角(黑八居中)
|
||||
func NewGame() *Game {
|
||||
g := &Game{Turn: 0, Phase: PhaseAim, Winner: -1}
|
||||
g.Balls[0] = &Ball{ID: 0, X: CueStartX, Y: CueStartY, On: true}
|
||||
// 三角阵五排:花色与全色交错、黑八在第三排中心
|
||||
rack := [][]int{
|
||||
{1},
|
||||
{9, 2},
|
||||
{3, 8, 10},
|
||||
{11, 4, 12, 5},
|
||||
{6, 13, 7, 14, 15},
|
||||
}
|
||||
apexX, apexY := 560.0, 200.0
|
||||
dx := BallR * 2 * math.Cos(math.Pi/6) // 排间距(略留缝隙)
|
||||
for row, ids := range rack {
|
||||
for j, id := range ids {
|
||||
x := apexX + float64(row)*(dx+0.5)
|
||||
y := apexY + (float64(j)-float64(row)/2)*(BallR*2+1)
|
||||
g.Balls[id] = &Ball{ID: id, X: x, Y: y, On: true}
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// groupOf 球 ID 所属球组
|
||||
func groupOf(id int) int {
|
||||
if id >= 1 && id <= 7 {
|
||||
return GroupSolid
|
||||
}
|
||||
if id >= 9 && id <= 15 {
|
||||
return GroupStripe
|
||||
}
|
||||
return GroupNone
|
||||
}
|
||||
|
||||
// GroupCleared 某座位的组内球是否已全部落袋(未分组视为未清台)
|
||||
func (g *Game) GroupCleared(seat int) bool {
|
||||
grp := g.Groups[seat]
|
||||
if grp == GroupNone {
|
||||
return false
|
||||
}
|
||||
for _, b := range g.Balls {
|
||||
if b.On && groupOf(b.ID) == grp {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Shoot 当前玩家击球(中杆无旋转):angle 弧度、power 1-100,返回模拟结果
|
||||
func (g *Game) Shoot(seat int, angle, power float64) (*ShotResult, error) {
|
||||
return g.ShootSpin(seat, angle, power, 0, 0)
|
||||
}
|
||||
|
||||
// ShootSpin 带杆法的击球:spinX 左右塞 -1~1(右塞为正),spinY 高低杆 -1~1(高杆为正)
|
||||
// 高低杆在白球首次碰到目标球后沿原行进方向跟进/回拉;左右塞让白球碰库时反弹方向偏转
|
||||
func (g *Game) ShootSpin(seat int, angle, power, spinX, spinY float64) (*ShotResult, error) {
|
||||
if g.Phase != PhaseAim {
|
||||
return nil, fmt.Errorf("对局已结束")
|
||||
}
|
||||
if g.Turn != seat {
|
||||
return nil, fmt.Errorf("还没轮到你击球")
|
||||
}
|
||||
if math.IsNaN(angle) || math.IsInf(angle, 0) {
|
||||
return nil, fmt.Errorf("击球角度不合法")
|
||||
}
|
||||
if math.IsNaN(power) || math.IsInf(power, 0) {
|
||||
power = 50
|
||||
}
|
||||
power = math.Max(5, math.Min(100, power))
|
||||
if math.IsNaN(spinX) || math.IsInf(spinX, 0) {
|
||||
spinX = 0
|
||||
}
|
||||
if math.IsNaN(spinY) || math.IsInf(spinY, 0) {
|
||||
spinY = 0
|
||||
}
|
||||
cue := g.Balls[0]
|
||||
speed := power * 13 // 最大约 1300 px/s
|
||||
cue.vx = math.Cos(angle) * speed
|
||||
cue.vy = math.Sin(angle) * speed
|
||||
cue.spinX = math.Max(-1, math.Min(1, spinX))
|
||||
cue.spinY = math.Max(-1, math.Min(1, spinY))
|
||||
g.Shots++
|
||||
res := g.simulate()
|
||||
g.applyRules(seat, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// simulate 逐帧物理模拟直到所有球停止,返回关键帧与落袋列表
|
||||
func (g *Game) simulate() *ShotResult {
|
||||
res := &ShotResult{Winner: -1}
|
||||
res.Frames = append(res.Frames, g.snapshot())
|
||||
maxSteps := int(MaxSimSec / SimDt)
|
||||
anyCueContact := false
|
||||
for step := 0; step < maxSteps; step++ {
|
||||
moving := false
|
||||
// 位置积分 + 摩擦
|
||||
for _, b := range g.Balls {
|
||||
if !b.On {
|
||||
continue
|
||||
}
|
||||
sp := math.Hypot(b.vx, b.vy)
|
||||
if sp < StopSpeed {
|
||||
b.vx, b.vy = 0, 0
|
||||
continue
|
||||
}
|
||||
moving = true
|
||||
b.X += b.vx * SimDt
|
||||
b.Y += b.vy * SimDt
|
||||
// 摩擦:线性减速 + 速度比例阻尼
|
||||
dec := (FricLin + FricProp*sp) * SimDt
|
||||
ns := sp - dec
|
||||
if ns < 0 {
|
||||
ns = 0
|
||||
}
|
||||
b.vx *= ns / sp
|
||||
b.vy *= ns / sp
|
||||
// 台呢摩擦消耗旋转:约 1 秒后旋转余量降至 40%
|
||||
if b.spinX != 0 || b.spinY != 0 {
|
||||
b.spinX *= 1 - 0.9*SimDt
|
||||
b.spinY *= 1 - 0.9*SimDt
|
||||
}
|
||||
}
|
||||
if !moving {
|
||||
break
|
||||
}
|
||||
// 落袋检测
|
||||
for _, b := range g.Balls {
|
||||
if !b.On {
|
||||
continue
|
||||
}
|
||||
for _, p := range pockets {
|
||||
if math.Hypot(b.X-p[0], b.Y-p[1]) < p[2] {
|
||||
b.On = false
|
||||
b.vx, b.vy = 0, 0
|
||||
res.Potted = append(res.Potted, b.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 库边反弹(袋口附近不反弹,让球能滚进袋)
|
||||
// 白球带左右塞时反弹方向发生偏转:切向获得与法向来速成正比的分量,每碰库消耗一半塞量
|
||||
for _, b := range g.Balls {
|
||||
if !b.On || g.nearPocket(b) {
|
||||
continue
|
||||
}
|
||||
if b.X < BallR {
|
||||
vn := math.Abs(b.vx)
|
||||
b.X = BallR
|
||||
b.vx = -b.vx * Restitut
|
||||
if b.spinX != 0 {
|
||||
b.vy += b.spinX * 0.45 * vn // 左库(法线 +x):右塞往 +y 偏
|
||||
b.spinX *= 0.5
|
||||
}
|
||||
} else if b.X > TableW-BallR {
|
||||
vn := math.Abs(b.vx)
|
||||
b.X = TableW - BallR
|
||||
b.vx = -b.vx * Restitut
|
||||
if b.spinX != 0 {
|
||||
b.vy -= b.spinX * 0.45 * vn // 右库(法线 -x):右塞往 -y 偏
|
||||
b.spinX *= 0.5
|
||||
}
|
||||
}
|
||||
if b.Y < BallR {
|
||||
vn := math.Abs(b.vy)
|
||||
b.Y = BallR
|
||||
b.vy = -b.vy * Restitut
|
||||
if b.spinX != 0 {
|
||||
b.vx -= b.spinX * 0.45 * vn // 上库(法线 +y):右塞往 -x 偏
|
||||
b.spinX *= 0.5
|
||||
}
|
||||
} else if b.Y > TableH-BallR {
|
||||
vn := math.Abs(b.vy)
|
||||
b.Y = TableH - BallR
|
||||
b.vy = -b.vy * Restitut
|
||||
if b.spinX != 0 {
|
||||
b.vx += b.spinX * 0.45 * vn // 下库(法线 -y):右塞往 +x 偏
|
||||
b.spinX *= 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
// 球间弹性碰撞(等质量:交换法线方向速度分量)
|
||||
for i := 0; i < len(g.Balls); i++ {
|
||||
bi := g.Balls[i]
|
||||
if !bi.On {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(g.Balls); j++ {
|
||||
bj := g.Balls[j]
|
||||
if !bj.On {
|
||||
continue
|
||||
}
|
||||
dx, dy := bj.X-bi.X, bj.Y-bi.Y
|
||||
dist := math.Hypot(dx, dy)
|
||||
if dist >= BallR*2 || dist == 0 {
|
||||
continue
|
||||
}
|
||||
var cueB *Ball
|
||||
if bi.ID == 0 {
|
||||
cueB = bi
|
||||
} else if bj.ID == 0 {
|
||||
cueB = bj
|
||||
}
|
||||
if cueB != nil {
|
||||
anyCueContact = true
|
||||
}
|
||||
nx, ny := dx/dist, dy/dist
|
||||
// 先推开重叠,避免粘连
|
||||
overlap := (BallR*2 - dist) / 2
|
||||
bi.X -= nx * overlap
|
||||
bi.Y -= ny * overlap
|
||||
bj.X += nx * overlap
|
||||
bj.Y += ny * overlap
|
||||
// 法线方向速度分量交换(含少量能量损耗)
|
||||
vi := bi.vx*nx + bi.vy*ny
|
||||
vj := bj.vx*nx + bj.vy*ny
|
||||
if vi-vj <= 0 {
|
||||
continue
|
||||
}
|
||||
// 等质量弹性碰撞:双方交换法向速度分量(bi 失去 vi-vj,bj 获得 vi-vj)
|
||||
const loss = 0.97
|
||||
bi.vx += (vj - vi) * nx * loss
|
||||
bi.vy += (vj - vi) * ny * loss
|
||||
bj.vx += (vi - vj) * nx * loss
|
||||
bj.vy += (vi - vj) * ny * loss
|
||||
// 高低杆:碰撞后白球沿连心线跟进(高杆)或回拉(低杆),
|
||||
// 冲量与法向撞击速度成正比 → 薄切时效果自然减弱;一次碰撞即消耗旋转
|
||||
if cueB != nil && cueB.spinY != 0 {
|
||||
k := cueB.spinY * 0.55 * math.Min(vi-vj, 900)
|
||||
if cueB == bi {
|
||||
cueB.vx += k * nx
|
||||
cueB.vy += k * ny
|
||||
} else {
|
||||
cueB.vx -= k * nx
|
||||
cueB.vy -= k * ny
|
||||
}
|
||||
cueB.spinY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
// 关键帧采样
|
||||
if step%FrameEach == 0 {
|
||||
res.Frames = append(res.Frames, g.snapshot())
|
||||
}
|
||||
}
|
||||
res.Frames = append(res.Frames, g.snapshot())
|
||||
// 空杆(白球没碰到任何球)也算犯规
|
||||
res.Foul = !anyCueContact
|
||||
return res
|
||||
}
|
||||
|
||||
// nearPocket 球是否在袋口捕获区附近(此时不做库边反弹)
|
||||
func (g *Game) nearPocket(b *Ball) bool {
|
||||
for _, p := range pockets {
|
||||
if math.Hypot(b.X-p[0], b.Y-p[1]) < p[2]+BallR {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// snapshot 当前桌面关键帧(仅在桌球,坐标保留 1 位小数减小体积)
|
||||
func (g *Game) snapshot() [][3]float64 {
|
||||
frame := make([][3]float64, 0, 16)
|
||||
for _, b := range g.Balls {
|
||||
if b.On {
|
||||
frame = append(frame, [3]float64{
|
||||
float64(b.ID),
|
||||
math.Round(b.X*10) / 10,
|
||||
math.Round(b.Y*10) / 10,
|
||||
})
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
// applyRules 按简化八球规则处理一杆的结果:分组、犯规、胜负与击球权
|
||||
func (g *Game) applyRules(seat int, res *ShotResult) {
|
||||
cuePotted := false
|
||||
eightPotted := false
|
||||
var pottedGroups []int
|
||||
for _, id := range res.Potted {
|
||||
switch {
|
||||
case id == 0:
|
||||
cuePotted = true
|
||||
case id == 8:
|
||||
eightPotted = true
|
||||
default:
|
||||
pottedGroups = append(pottedGroups, groupOf(id))
|
||||
}
|
||||
}
|
||||
if cuePotted {
|
||||
res.Foul = true
|
||||
}
|
||||
// 黑八落袋:清完自己组打进为胜,否则直接告负(白球同落也告负)
|
||||
if eightPotted {
|
||||
g.Phase = PhaseOver
|
||||
res.Over = true
|
||||
if g.GroupCleared(seat) && !cuePotted {
|
||||
g.Winner = seat
|
||||
res.Desc = "打进黑八,制胜一杆!"
|
||||
} else {
|
||||
g.Winner = 1 - seat
|
||||
res.Desc = "黑八提前落袋,痛失好局"
|
||||
}
|
||||
res.Winner = g.Winner
|
||||
return
|
||||
}
|
||||
// 首次打进彩球确定分组
|
||||
if g.Groups[seat] == GroupNone && len(pottedGroups) > 0 {
|
||||
g.Groups[seat] = pottedGroups[0]
|
||||
g.Groups[1-seat] = 3 - pottedGroups[0]
|
||||
if pottedGroups[0] == GroupSolid {
|
||||
res.Desc = "分组确定:你打全色球(1-7)"
|
||||
} else {
|
||||
res.Desc = "分组确定:你打花色球(9-15)"
|
||||
}
|
||||
}
|
||||
// 白球落袋:重置回开球点(顺移避开占位球)
|
||||
if cuePotted {
|
||||
cue := g.Balls[0]
|
||||
cue.On = true
|
||||
cue.X, cue.Y = CueStartX, CueStartY
|
||||
cue.vx, cue.vy = 0, 0
|
||||
cue.spinX, cue.spinY = 0, 0
|
||||
for g.overlapAny(cue) {
|
||||
cue.X += BallR * 2.2
|
||||
if cue.X > TableW-BallR*2 {
|
||||
cue.X = BallR * 2
|
||||
cue.Y = math.Mod(cue.Y+BallR*3, TableH-BallR*4) + BallR*2
|
||||
}
|
||||
}
|
||||
}
|
||||
// 击球权:无犯规且打进自己组的球(或未分组时打进任意彩球)则继续
|
||||
ownPotted := false
|
||||
for _, grp := range pottedGroups {
|
||||
if g.Groups[seat] == GroupNone || grp == g.Groups[seat] {
|
||||
ownPotted = true
|
||||
}
|
||||
}
|
||||
res.Continue = !res.Foul && ownPotted
|
||||
if !res.Continue {
|
||||
g.Turn = 1 - seat
|
||||
}
|
||||
}
|
||||
|
||||
// overlapAny 白球重置时是否与其他在桌球重叠
|
||||
func (g *Game) overlapAny(cue *Ball) bool {
|
||||
for _, b := range g.Balls {
|
||||
if b.ID == 0 || !b.On {
|
||||
continue
|
||||
}
|
||||
if math.Hypot(b.X-cue.X, b.Y-cue.Y) < BallR*2.1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AIShot 规则 AI 选杆:遍历「目标球 × 袋口」找角度最顺的进球线路
|
||||
// 难度决定瞄准噪声:easy 偏差大,hard 几乎指哪打哪
|
||||
func AIShot(g *Game, seat int, difficulty string) (angle, power float64) {
|
||||
cue := g.Balls[0]
|
||||
targets := g.aiTargets(seat)
|
||||
type plan struct {
|
||||
angle float64
|
||||
power float64
|
||||
score float64
|
||||
}
|
||||
best := plan{score: -1e18}
|
||||
for _, t := range targets {
|
||||
for _, p := range pockets {
|
||||
// 目标球到袋口方向
|
||||
tpx, tpy := p[0]-t.X, p[1]-t.Y
|
||||
tpd := math.Hypot(tpx, tpy)
|
||||
if tpd < 1 {
|
||||
continue
|
||||
}
|
||||
// 幽灵球点:白球需要击中的位置(目标球背向袋口 2R 处)
|
||||
gx := t.X - tpx/tpd*BallR*2
|
||||
gy := t.Y - tpy/tpd*BallR*2
|
||||
cgx, cgy := gx-cue.X, gy-cue.Y
|
||||
cgd := math.Hypot(cgx, cgy)
|
||||
if cgd < 1 {
|
||||
continue
|
||||
}
|
||||
// 切球角余弦:白球行进方向与目标球进袋方向的夹角,太薄不选
|
||||
cosCut := (cgx*tpx + cgy*tpy) / (cgd * tpd)
|
||||
if cosCut < 0.2 {
|
||||
continue
|
||||
}
|
||||
score := cosCut*1000 - cgd*0.35 - tpd*0.25 - g.pathBlockPenalty(cue.X, cue.Y, gx, gy, t.ID)
|
||||
if score > best.score {
|
||||
pw := 28 + cgd*0.075 + tpd*0.06/math.Max(cosCut, 0.35)
|
||||
best = plan{angle: math.Atan2(cgy, cgx), power: math.Min(92, pw), score: score}
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.score <= -1e17 {
|
||||
// 没有好线路:朝最近的目标球直打
|
||||
var near *Ball
|
||||
nd := 1e18
|
||||
for _, t := range targets {
|
||||
d := math.Hypot(t.X-cue.X, t.Y-cue.Y)
|
||||
if d < nd {
|
||||
nd, near = d, t
|
||||
}
|
||||
}
|
||||
if near == nil {
|
||||
return rand.Float64() * math.Pi * 2, 50
|
||||
}
|
||||
best = plan{angle: math.Atan2(near.Y-cue.Y, near.X-cue.X), power: 55}
|
||||
}
|
||||
// 难度噪声
|
||||
sigma := 0.02
|
||||
switch difficulty {
|
||||
case "easy":
|
||||
sigma = 0.05
|
||||
case "hard":
|
||||
sigma = 0.006
|
||||
}
|
||||
return best.angle + rand.NormFloat64()*sigma, best.power
|
||||
}
|
||||
|
||||
// aiTargets AI 的合法目标球:已分组打自己组,清台后打黑八,未分组打任意彩球
|
||||
func (g *Game) aiTargets(seat int) []*Ball {
|
||||
var list []*Ball
|
||||
grp := g.Groups[seat]
|
||||
if grp != GroupNone && g.GroupCleared(seat) {
|
||||
if g.Balls[8].On {
|
||||
return []*Ball{g.Balls[8]}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, b := range g.Balls {
|
||||
if !b.On || b.ID == 0 || b.ID == 8 {
|
||||
continue
|
||||
}
|
||||
if grp == GroupNone || groupOf(b.ID) == grp {
|
||||
list = append(list, b)
|
||||
}
|
||||
}
|
||||
if len(list) == 0 && g.Balls[8].On {
|
||||
list = append(list, g.Balls[8])
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// pathBlockPenalty 白球到幽灵球点的直线路径上有其他球则加罚分
|
||||
func (g *Game) pathBlockPenalty(x1, y1, x2, y2 float64, targetID int) float64 {
|
||||
dx, dy := x2-x1, y2-y1
|
||||
length := math.Hypot(dx, dy)
|
||||
if length < 1 {
|
||||
return 0
|
||||
}
|
||||
penalty := 0.0
|
||||
for _, b := range g.Balls {
|
||||
if !b.On || b.ID == 0 || b.ID == targetID {
|
||||
continue
|
||||
}
|
||||
// 球心到线段的距离
|
||||
t := ((b.X-x1)*dx + (b.Y-y1)*dy) / (length * length)
|
||||
if t < 0 || t > 1 {
|
||||
continue
|
||||
}
|
||||
px, py := x1+dx*t, y1+dy*t
|
||||
if math.Hypot(b.X-px, b.Y-py) < BallR*2.2 {
|
||||
penalty += 800
|
||||
}
|
||||
}
|
||||
return penalty
|
||||
}
|
||||
127
internal/gamecore/billiards/billiards_test.go
Normal file
127
internal/gamecore/billiards/billiards_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// 台球核心自测:物理稳定性 + AI 自对弈能正常打完
|
||||
package billiards
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPhysicsStable 大力开球后所有球必须停下且不出界
|
||||
func TestPhysicsStable(t *testing.T) {
|
||||
g := NewGame()
|
||||
res, err := g.Shoot(0, 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("击球失败: %v", err)
|
||||
}
|
||||
if len(res.Frames) < 2 {
|
||||
t.Fatal("模拟帧数过少")
|
||||
}
|
||||
for _, b := range g.Balls {
|
||||
if !b.On {
|
||||
continue
|
||||
}
|
||||
if b.X < BallR-1 || b.X > TableW-BallR+1 || b.Y < BallR-1 || b.Y > TableH-BallR+1 {
|
||||
t.Fatalf("球 %d 停在界外: (%.1f, %.1f)", b.ID, b.X, b.Y)
|
||||
}
|
||||
if math.Hypot(b.vx, b.vy) > 0.01 {
|
||||
t.Fatalf("球 %d 模拟结束仍在运动", b.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAIGame 两个 AI 互打 20 盘:每盘必须在 500 杆内分出胜负
|
||||
func TestAIGame(t *testing.T) {
|
||||
for round := 0; round < 20; round++ {
|
||||
g := NewGame()
|
||||
for shot := 0; shot < 500; shot++ {
|
||||
if g.Phase == PhaseOver {
|
||||
break
|
||||
}
|
||||
seat := g.Turn
|
||||
angle, power := AIShot(g, seat, "hard")
|
||||
if _, err := g.Shoot(seat, angle, power); err != nil {
|
||||
t.Fatalf("第 %d 盘第 %d 杆失败: %v", round, shot, err)
|
||||
}
|
||||
}
|
||||
if g.Phase != PhaseOver {
|
||||
// 统计剩余球数辅助定位
|
||||
left := 0
|
||||
for _, b := range g.Balls {
|
||||
if b.On && b.ID != 0 {
|
||||
left++
|
||||
}
|
||||
}
|
||||
t.Fatalf("第 %d 盘 500 杆仍未结束,桌面剩 %d 球", round, left)
|
||||
}
|
||||
if g.Winner != 0 && g.Winner != 1 {
|
||||
t.Fatalf("第 %d 盘获胜座位非法: %d", round, g.Winner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpinFollowDraw 高低杆:正对目标球满击后,高杆跟进、低杆回拉,停点应明显分化
|
||||
// 场景:低力度短距离正碰,目标球滚动距离不足以撞库弹回,避免二次碰撞干扰停点
|
||||
func TestSpinFollowDraw(t *testing.T) {
|
||||
mk := func(spinY float64) float64 {
|
||||
g := NewGame()
|
||||
for _, b := range g.Balls {
|
||||
if b.ID != 0 && b.ID != 1 {
|
||||
b.On = false
|
||||
}
|
||||
}
|
||||
g.Balls[0].X, g.Balls[0].Y = 60, 200
|
||||
g.Balls[1].X, g.Balls[1].Y = 160, 200
|
||||
if _, err := g.ShootSpin(0, 0, 30, 0, spinY); err != nil {
|
||||
t.Fatalf("击球失败: %v", err)
|
||||
}
|
||||
return g.Balls[0].X
|
||||
}
|
||||
center := mk(0)
|
||||
follow := mk(1)
|
||||
draw := mk(-1)
|
||||
if !(follow > center+30 && draw < center-30) {
|
||||
t.Fatalf("高低杆效果异常: 中杆停点 %.1f 高杆 %.1f 低杆 %.1f", center, follow, draw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpinEnglish 左右塞:白球正打右库,右塞反弹后向下偏、左塞向上偏
|
||||
func TestSpinEnglish(t *testing.T) {
|
||||
mk := func(spinX float64) float64 {
|
||||
g := NewGame()
|
||||
for _, b := range g.Balls {
|
||||
if b.ID != 0 {
|
||||
b.On = false
|
||||
}
|
||||
}
|
||||
g.Balls[0].X, g.Balls[0].Y = 400, 200
|
||||
if _, err := g.ShootSpin(0, 0, 50, spinX, 0); err != nil {
|
||||
t.Fatalf("击球失败: %v", err)
|
||||
}
|
||||
return g.Balls[0].Y
|
||||
}
|
||||
right := mk(1)
|
||||
left := mk(-1)
|
||||
if !(right < 195 && left > 205) {
|
||||
t.Fatalf("左右塞效果异常: 右塞停点 Y=%.1f 左塞停点 Y=%.1f(期望右塞<195 左塞>205)", right, left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFoulRespot 白球落袋后重置回开球点附近且不与其他球重叠
|
||||
func TestFoulRespot(t *testing.T) {
|
||||
g := NewGame()
|
||||
// 构造直接把白球打进左上角袋的场景
|
||||
g.Balls[0].X, g.Balls[0].Y = 60, 60
|
||||
res, err := g.Shoot(0, math.Atan2(-60, -60), 80)
|
||||
if err != nil {
|
||||
t.Fatalf("击球失败: %v", err)
|
||||
}
|
||||
if !g.Balls[0].On {
|
||||
t.Fatal("白球落袋后应被重置回桌面")
|
||||
}
|
||||
if !res.Foul {
|
||||
t.Fatal("白球落袋应判犯规")
|
||||
}
|
||||
if g.Turn != 1 {
|
||||
t.Fatal("犯规后击球权应交给对方")
|
||||
}
|
||||
}
|
||||
113
internal/gamecore/ddz/cards.go
Normal file
113
internal/gamecore/ddz/cards.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package ddz 实现斗地主的完整规则:发牌、牌型判定、比较、候选出牌生成与对局状态机
|
||||
package ddz
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 牌的编码约定:
|
||||
// 0-51 普通牌:点数 rank = 编号/4 + 3(3,4,...,10,J=11,Q=12,K=13,A=14,2=15),花色 = 编号%4
|
||||
// 52 小王(rank=16)
|
||||
// 53 大王(rank=17)
|
||||
const (
|
||||
SmallJoker = 52 // 小王编号
|
||||
BigJoker = 53 // 大王编号
|
||||
)
|
||||
|
||||
// Rank 返回牌的点数(3..15,小王16,大王17)
|
||||
func Rank(card int) int {
|
||||
if card == SmallJoker {
|
||||
return 16
|
||||
}
|
||||
if card == BigJoker {
|
||||
return 17
|
||||
}
|
||||
return card/4 + 3
|
||||
}
|
||||
|
||||
// rankNames 点数到显示名的映射(下标 = rank)
|
||||
var rankNames = map[int]string{
|
||||
3: "3", 4: "4", 5: "5", 6: "6", 7: "7", 8: "8", 9: "9", 10: "10",
|
||||
11: "J", 12: "Q", 13: "K", 14: "A", 15: "2", 16: "小王", 17: "大王",
|
||||
}
|
||||
|
||||
// RankName 点数的显示名称
|
||||
func RankName(rank int) string { return rankNames[rank] }
|
||||
|
||||
// CardName 牌的显示名称(忽略花色,斗地主只关心点数)
|
||||
func CardName(card int) string { return RankName(Rank(card)) }
|
||||
|
||||
// CardsName 一组牌的显示名称(如 "33 55",按点数降序)
|
||||
func CardsName(cards []int) string {
|
||||
sorted := append([]int{}, cards...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return Rank(sorted[i]) > Rank(sorted[j]) })
|
||||
s := ""
|
||||
for i, c := range sorted {
|
||||
if i > 0 {
|
||||
s += " "
|
||||
}
|
||||
s += CardName(c)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SortDesc 手牌按点数降序排序(前端展示与出牌习惯)
|
||||
func SortDesc(cards []int) {
|
||||
sort.Slice(cards, func(i, j int) bool {
|
||||
ri, rj := Rank(cards[i]), Rank(cards[j])
|
||||
if ri != rj {
|
||||
return ri > rj
|
||||
}
|
||||
return cards[i] > cards[j]
|
||||
})
|
||||
}
|
||||
|
||||
// Deal 洗牌并发牌:返回三家手牌(各17张)与3张底牌
|
||||
func Deal() (hands [3][]int, bottom []int) {
|
||||
deck := make([]int, 54)
|
||||
for i := range deck {
|
||||
deck[i] = i
|
||||
}
|
||||
rand.Shuffle(54, func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
|
||||
for i := 0; i < 3; i++ {
|
||||
hands[i] = append([]int{}, deck[i*17:(i+1)*17]...)
|
||||
SortDesc(hands[i])
|
||||
}
|
||||
bottom = append([]int{}, deck[51:]...)
|
||||
return hands, bottom
|
||||
}
|
||||
|
||||
// rankCount 统计一组牌各点数的张数
|
||||
func rankCount(cards []int) map[int]int {
|
||||
m := map[int]int{}
|
||||
for _, c := range cards {
|
||||
m[Rank(c)]++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// removeCards 从手牌中移除指定牌,返回新手牌(不修改原切片);牌不存在返回 nil
|
||||
func removeCards(hand, cards []int) []int {
|
||||
used := map[int]bool{}
|
||||
for _, c := range cards {
|
||||
used[c] = true
|
||||
}
|
||||
// 校验 cards 都在手牌中
|
||||
inHand := map[int]bool{}
|
||||
for _, c := range hand {
|
||||
inHand[c] = true
|
||||
}
|
||||
for _, c := range cards {
|
||||
if !inHand[c] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
rest := make([]int, 0, len(hand)-len(cards))
|
||||
for _, c := range hand {
|
||||
if !used[c] {
|
||||
rest = append(rest, c)
|
||||
}
|
||||
}
|
||||
return rest
|
||||
}
|
||||
180
internal/gamecore/ddz/combo.go
Normal file
180
internal/gamecore/ddz/combo.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package ddz
|
||||
|
||||
import "sort"
|
||||
|
||||
// ComboType 牌型枚举
|
||||
type ComboType int
|
||||
|
||||
// 全部合法牌型
|
||||
const (
|
||||
ComboInvalid ComboType = iota // 非法牌型
|
||||
ComboSingle // 单张
|
||||
ComboPair // 对子
|
||||
ComboTriple // 三张(不带)
|
||||
ComboTripleOne // 三带一
|
||||
ComboTriplePair // 三带二(一对)
|
||||
ComboStraight // 顺子(>=5张连续单牌,最大到A)
|
||||
ComboPairStraight // 连对(>=3对连续,最大到A)
|
||||
ComboPlane // 飞机不带(>=2组连续三张)
|
||||
ComboPlaneSingle // 飞机带单(每组三张带一单)
|
||||
ComboPlanePair // 飞机带对(每组三张带一对)
|
||||
ComboFourTwo // 四带二(两张单牌)
|
||||
ComboFourTwoPair // 四带两对
|
||||
ComboBomb // 炸弹(四张同点)
|
||||
ComboRocket // 王炸(双王)
|
||||
)
|
||||
|
||||
// comboNames 牌型中文名(结算展示与 AI 提示词用)
|
||||
var comboNames = map[ComboType]string{
|
||||
ComboSingle: "单张", ComboPair: "对子", ComboTriple: "三张",
|
||||
ComboTripleOne: "三带一", ComboTriplePair: "三带二",
|
||||
ComboStraight: "顺子", ComboPairStraight: "连对",
|
||||
ComboPlane: "飞机", ComboPlaneSingle: "飞机带单", ComboPlanePair: "飞机带对",
|
||||
ComboFourTwo: "四带二", ComboFourTwoPair: "四带两对",
|
||||
ComboBomb: "炸弹", ComboRocket: "王炸",
|
||||
}
|
||||
|
||||
// Combo 一手牌的解析结果
|
||||
type Combo struct {
|
||||
Type ComboType `json:"type"` // 牌型
|
||||
MainRank int `json:"main_rank"` // 主牌点数(同型比较依据)
|
||||
Length int `json:"length"` // 连续组数(顺子张数/连对对数/飞机组数,非连续型为1)
|
||||
Cards []int `json:"cards"` // 原始牌
|
||||
}
|
||||
|
||||
// TypeName 牌型中文名
|
||||
func (c *Combo) TypeName() string { return comboNames[c.Type] }
|
||||
|
||||
// Desc 牌面描述(如 "三带一 888+4"简化为牌名列表)
|
||||
func (c *Combo) Desc() string { return c.TypeName() + " " + CardsName(c.Cards) }
|
||||
|
||||
// Parse 解析一组牌的牌型,非法返回 nil
|
||||
func Parse(cards []int) *Combo {
|
||||
n := len(cards)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
cnt := rankCount(cards)
|
||||
// 王炸:恰好双王
|
||||
if n == 2 && cnt[16] == 1 && cnt[17] == 1 {
|
||||
return &Combo{Type: ComboRocket, MainRank: 17, Length: 1, Cards: cards}
|
||||
}
|
||||
// 按张数分桶:ranksOf[k] = 恰好出现 k 次的点数列表(升序)
|
||||
ranksOf := map[int][]int{}
|
||||
for r, k := range cnt {
|
||||
ranksOf[k] = append(ranksOf[k], r)
|
||||
}
|
||||
for k := range ranksOf {
|
||||
sort.Ints(ranksOf[k])
|
||||
}
|
||||
switch {
|
||||
case n == 1:
|
||||
return &Combo{Type: ComboSingle, MainRank: Rank(cards[0]), Length: 1, Cards: cards}
|
||||
case n == 2 && len(ranksOf[2]) == 1:
|
||||
return &Combo{Type: ComboPair, MainRank: ranksOf[2][0], Length: 1, Cards: cards}
|
||||
case n == 3 && len(ranksOf[3]) == 1:
|
||||
return &Combo{Type: ComboTriple, MainRank: ranksOf[3][0], Length: 1, Cards: cards}
|
||||
case n == 4 && len(ranksOf[4]) == 1:
|
||||
return &Combo{Type: ComboBomb, MainRank: ranksOf[4][0], Length: 1, Cards: cards}
|
||||
case n == 4 && len(ranksOf[3]) == 1:
|
||||
// 三带一:3+1
|
||||
return &Combo{Type: ComboTripleOne, MainRank: ranksOf[3][0], Length: 1, Cards: cards}
|
||||
case n == 5 && len(ranksOf[3]) == 1 && len(ranksOf[2]) == 1:
|
||||
// 三带二:3+2
|
||||
return &Combo{Type: ComboTriplePair, MainRank: ranksOf[3][0], Length: 1, Cards: cards}
|
||||
case n == 6 && len(ranksOf[4]) == 1 && (len(ranksOf[1]) == 2 || len(ranksOf[2]) == 1):
|
||||
// 四带二:4+1+1 或 4+2(带一对也按四带二算)
|
||||
return &Combo{Type: ComboFourTwo, MainRank: ranksOf[4][0], Length: 1, Cards: cards}
|
||||
case n == 8 && len(ranksOf[4]) == 1 && len(ranksOf[2]) == 2:
|
||||
// 四带两对:4+2+2
|
||||
return &Combo{Type: ComboFourTwoPair, MainRank: ranksOf[4][0], Length: 1, Cards: cards}
|
||||
}
|
||||
// 顺子:>=5 张互不相同且连续,不能含 2 与王(最大到 A=14)
|
||||
if n >= 5 && len(ranksOf[1]) == n && isConsecutive(ranksOf[1]) && ranksOf[1][n-1] <= 14 {
|
||||
return &Combo{Type: ComboStraight, MainRank: ranksOf[1][n-1], Length: n, Cards: cards}
|
||||
}
|
||||
// 连对:>=3 对连续,不能含 2 与王
|
||||
if n >= 6 && n%2 == 0 && len(ranksOf[2]) == n/2 && isConsecutive(ranksOf[2]) && ranksOf[2][n/2-1] <= 14 {
|
||||
return &Combo{Type: ComboPairStraight, MainRank: ranksOf[2][n/2-1], Length: n / 2, Cards: cards}
|
||||
}
|
||||
// 飞机系列:找出连续的三张组
|
||||
triples := ranksOf[3]
|
||||
if len(triples) >= 2 {
|
||||
// 取最长的连续三张段(要求 <= A)
|
||||
seq := longestConsecutive(triples, 14)
|
||||
if len(seq) >= 2 {
|
||||
k := len(seq)
|
||||
switch n {
|
||||
case 3 * k:
|
||||
// 飞机不带
|
||||
if len(ranksOf[3]) == k {
|
||||
return &Combo{Type: ComboPlane, MainRank: seq[k-1], Length: k, Cards: cards}
|
||||
}
|
||||
case 4 * k:
|
||||
// 飞机带单:3k + k 张单(其余牌任意,不校验细节组合)
|
||||
return &Combo{Type: ComboPlaneSingle, MainRank: seq[k-1], Length: k, Cards: cards}
|
||||
case 5 * k:
|
||||
// 飞机带对:3k + k 对
|
||||
if len(ranksOf[2]) == k {
|
||||
return &Combo{Type: ComboPlanePair, MainRank: seq[k-1], Length: k, Cards: cards}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isConsecutive 判断升序点数列表是否连续
|
||||
func isConsecutive(ranks []int) bool {
|
||||
for i := 1; i < len(ranks); i++ {
|
||||
if ranks[i] != ranks[i-1]+1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(ranks) > 0
|
||||
}
|
||||
|
||||
// longestConsecutive 从升序列表中取最长连续段(元素需 <= maxRank),返回该段
|
||||
func longestConsecutive(ranks []int, maxRank int) []int {
|
||||
best, cur := []int{}, []int{}
|
||||
for _, r := range ranks {
|
||||
if r > maxRank {
|
||||
break
|
||||
}
|
||||
if len(cur) == 0 || r == cur[len(cur)-1]+1 {
|
||||
cur = append(cur, r)
|
||||
} else {
|
||||
cur = []int{r}
|
||||
}
|
||||
if len(cur) > len(best) {
|
||||
best = append([]int{}, cur...)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Beats 判断 c 是否能压过 other(other 为 nil 表示自由出牌,恒可出)
|
||||
func (c *Combo) Beats(other *Combo) bool {
|
||||
if other == nil {
|
||||
return true
|
||||
}
|
||||
// 王炸压一切
|
||||
if c.Type == ComboRocket {
|
||||
return true
|
||||
}
|
||||
if other.Type == ComboRocket {
|
||||
return false
|
||||
}
|
||||
// 炸弹压所有非炸弹;炸弹之间比点数
|
||||
if c.Type == ComboBomb && other.Type != ComboBomb {
|
||||
return true
|
||||
}
|
||||
if c.Type != ComboBomb && other.Type == ComboBomb {
|
||||
return false
|
||||
}
|
||||
// 同型同长才可比较,比主牌点数
|
||||
if c.Type != other.Type || c.Length != other.Length {
|
||||
return false
|
||||
}
|
||||
return c.MainRank > other.MainRank
|
||||
}
|
||||
240
internal/gamecore/ddz/game.go
Normal file
240
internal/gamecore/ddz/game.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package ddz
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// Phase 对局阶段
|
||||
type Phase string
|
||||
|
||||
// 四个阶段:叫地主 → 抢地主 → 出牌 → 结束
|
||||
const (
|
||||
PhaseBidding Phase = "bidding" // 叫地主阶段
|
||||
PhaseRobbing Phase = "robbing" // 抢地主阶段(有人叫地主后,其余两家各有一次抢的机会,每抢一次倍数×2)
|
||||
PhasePlaying Phase = "playing" // 出牌阶段
|
||||
PhaseOver Phase = "over" // 对局结束
|
||||
)
|
||||
|
||||
// PlayEvent 一次出牌事件(广播与回放用)
|
||||
type PlayEvent struct {
|
||||
Seat int `json:"seat"` // 出牌座位
|
||||
Cards []int `json:"cards"` // 出的牌(空=过)
|
||||
Desc string `json:"desc"` // 牌型描述("过" 或 "对子 55")
|
||||
}
|
||||
|
||||
// Game 斗地主一局的完整状态(由房间持有,加锁访问)
|
||||
type Game struct {
|
||||
Hands [3][]int // 三家手牌(按点数降序)
|
||||
Bottom []int // 3张底牌(地主确定后并入地主手牌)
|
||||
Landlord int // 地主座位(-1=未确定;抢地主阶段为当前地主候选)
|
||||
Phase Phase // 当前阶段
|
||||
Turn int // 当前行动座位
|
||||
LastCombo *Combo // 需要压的牌(nil=自由出牌)
|
||||
LastSeat int // 出 LastCombo 的座位
|
||||
PassCount int // 连续过牌次数(两家都过则重新自由出牌)
|
||||
BidStart int // 叫地主起始座位
|
||||
BidCount int // 叫地主已表态人数
|
||||
FirstCall int // 最先叫地主的座位(抢地主阶段其不再表态)
|
||||
RobActed int // 抢地主阶段已表态人数
|
||||
Multiplier int // 叫抢倍数(叫地主=1,每被抢一次×2;炸弹与春天在结算时另乘)
|
||||
Winner int // 获胜座位(地主赢=地主座位;农民赢=先出完的农民座位;-1=未结束)
|
||||
Bombs int // 本局已出炸弹数(每个炸弹结算倍数×2)
|
||||
History []PlayEvent // 出牌历史(最近的在末尾)
|
||||
}
|
||||
|
||||
// NewGame 开新局:发牌并随机确定叫地主起始座位
|
||||
func NewGame() *Game {
|
||||
hands, bottom := Deal()
|
||||
start := rand.Intn(3)
|
||||
return &Game{
|
||||
Hands: hands, Bottom: bottom,
|
||||
Landlord: -1, Phase: PhaseBidding,
|
||||
Turn: start, BidStart: start,
|
||||
FirstCall: -1, Multiplier: 1,
|
||||
LastSeat: -1, Winner: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// Bid 叫地主表态:call=true 表示叫地主,随后进入抢地主阶段
|
||||
// 返回 redeal=true 表示三家都不叫需要重新发牌
|
||||
func (g *Game) Bid(seat int, call bool) (redeal bool, err error) {
|
||||
if g.Phase != PhaseBidding {
|
||||
return false, errors.New("当前不在叫地主阶段")
|
||||
}
|
||||
if seat != g.Turn {
|
||||
return false, errors.New("还没轮到你表态")
|
||||
}
|
||||
g.BidCount++
|
||||
if call {
|
||||
// 叫地主:成为地主候选,进入抢地主阶段(其余两家按顺序各有一次抢的机会)
|
||||
g.Landlord = seat
|
||||
g.FirstCall = seat
|
||||
g.Phase = PhaseRobbing
|
||||
g.RobActed = 0
|
||||
g.Turn = (seat + 1) % 3
|
||||
return false, nil
|
||||
}
|
||||
// 不叫:轮到下家;三家都不叫则重新发牌
|
||||
if g.BidCount >= 3 {
|
||||
return true, nil
|
||||
}
|
||||
g.Turn = (g.Turn + 1) % 3
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Rob 抢地主表态:rob=true 时该座位成为新的地主候选且倍数×2
|
||||
// 两位非首叫玩家依次表态完后确定地主进入出牌阶段(简化规则:首叫者无反抢)
|
||||
func (g *Game) Rob(seat int, rob bool) error {
|
||||
if g.Phase != PhaseRobbing {
|
||||
return errors.New("当前不在抢地主阶段")
|
||||
}
|
||||
if seat != g.Turn {
|
||||
return errors.New("还没轮到你表态")
|
||||
}
|
||||
g.RobActed++
|
||||
if rob {
|
||||
g.Landlord = seat
|
||||
g.Multiplier *= 2
|
||||
}
|
||||
if g.RobActed >= 2 {
|
||||
// 两家都已表态:地主确定,底牌并入手牌,地主先出
|
||||
g.Hands[g.Landlord] = append(g.Hands[g.Landlord], g.Bottom...)
|
||||
SortDesc(g.Hands[g.Landlord])
|
||||
g.Phase = PhasePlaying
|
||||
g.Turn = g.Landlord
|
||||
g.LastCombo = nil
|
||||
g.LastSeat = -1
|
||||
return nil
|
||||
}
|
||||
// 轮到另一位非首叫玩家表态
|
||||
next := (g.Turn + 1) % 3
|
||||
if next == g.FirstCall {
|
||||
next = (next + 1) % 3
|
||||
}
|
||||
g.Turn = next
|
||||
return nil
|
||||
}
|
||||
|
||||
// Play 出牌:校验牌在手、牌型合法、能压上家,然后推进回合
|
||||
func (g *Game) Play(seat int, cards []int) (*Combo, error) {
|
||||
if g.Phase != PhasePlaying {
|
||||
return nil, errors.New("当前不在出牌阶段")
|
||||
}
|
||||
if seat != g.Turn {
|
||||
return nil, errors.New("还没轮到你出牌")
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return nil, errors.New("请选择要出的牌")
|
||||
}
|
||||
combo := Parse(cards)
|
||||
if combo == nil {
|
||||
return nil, errors.New("不是合法牌型")
|
||||
}
|
||||
if !combo.Beats(g.LastCombo) {
|
||||
return nil, errors.New("压不过上家的牌")
|
||||
}
|
||||
rest := removeCards(g.Hands[seat], cards)
|
||||
if rest == nil {
|
||||
return nil, errors.New("出的牌不在手牌中")
|
||||
}
|
||||
g.Hands[seat] = rest
|
||||
g.LastCombo = combo
|
||||
g.LastSeat = seat
|
||||
g.PassCount = 0
|
||||
if combo.Type == ComboBomb || combo.Type == ComboRocket {
|
||||
g.Bombs++
|
||||
}
|
||||
g.History = append(g.History, PlayEvent{Seat: seat, Cards: cards, Desc: combo.Desc()})
|
||||
// 出完牌即获胜
|
||||
if len(rest) == 0 {
|
||||
g.Phase = PhaseOver
|
||||
g.Winner = seat
|
||||
return combo, nil
|
||||
}
|
||||
g.Turn = (g.Turn + 1) % 3
|
||||
return combo, nil
|
||||
}
|
||||
|
||||
// Pass 过牌:自由出牌时不允许过;两家连过后回到最后出牌者自由出牌
|
||||
func (g *Game) Pass(seat int) error {
|
||||
if g.Phase != PhasePlaying {
|
||||
return errors.New("当前不在出牌阶段")
|
||||
}
|
||||
if seat != g.Turn {
|
||||
return errors.New("还没轮到你")
|
||||
}
|
||||
if g.LastCombo == nil || g.LastSeat == seat {
|
||||
return errors.New("轮到你自由出牌,不能过")
|
||||
}
|
||||
g.PassCount++
|
||||
g.History = append(g.History, PlayEvent{Seat: seat, Cards: nil, Desc: "过"})
|
||||
g.Turn = (g.Turn + 1) % 3
|
||||
// 两家都过:回到最后出牌者,重新自由出牌
|
||||
if g.Turn == g.LastSeat {
|
||||
g.LastCombo = nil
|
||||
g.PassCount = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LandlordWon 结算辅助:地主是否获胜(Phase 为 Over 时有效)
|
||||
func (g *Game) LandlordWon() bool {
|
||||
return g.Winner == g.Landlord
|
||||
}
|
||||
|
||||
// IsFarmer 判断座位是否为农民
|
||||
func (g *Game) IsFarmer(seat int) bool {
|
||||
return g.Landlord >= 0 && seat != g.Landlord
|
||||
}
|
||||
|
||||
// SpringMultiplier 春天倍数(对局结束后调用):
|
||||
// 地主获胜且两位农民一张牌都没出过 = 春天 ×2;
|
||||
// 农民获胜且地主只出过开局第一手 = 反春 ×2;其余返回 1
|
||||
func (g *Game) SpringMultiplier() int {
|
||||
if g.Phase != PhaseOver || g.Landlord < 0 {
|
||||
return 1
|
||||
}
|
||||
lordPlays, farmerPlays := 0, 0
|
||||
for _, e := range g.History {
|
||||
if len(e.Cards) == 0 {
|
||||
continue
|
||||
}
|
||||
if e.Seat == g.Landlord {
|
||||
lordPlays++
|
||||
} else {
|
||||
farmerPlays++
|
||||
}
|
||||
}
|
||||
if g.LandlordWon() && farmerPlays == 0 {
|
||||
return 2
|
||||
}
|
||||
if !g.LandlordWon() && lordPlays <= 1 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// CurrentMultiplier 当前展示倍数 = 叫抢倍数 × 2^炸弹数(上限 64 防积分爆炸)
|
||||
func (g *Game) CurrentMultiplier() int {
|
||||
m := g.Multiplier
|
||||
if m < 1 {
|
||||
m = 1
|
||||
}
|
||||
for i := 0; i < g.Bombs && m < 64; i++ {
|
||||
m *= 2
|
||||
}
|
||||
if m > 64 {
|
||||
m = 64
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TotalMultiplier 结算总倍数 = 叫抢倍数 × 2^炸弹数 × 春天倍数(上限 64)
|
||||
func (g *Game) TotalMultiplier() int {
|
||||
m := g.CurrentMultiplier() * g.SpringMultiplier()
|
||||
if m > 64 {
|
||||
m = 64
|
||||
}
|
||||
return m
|
||||
}
|
||||
116
internal/gamecore/ddz/game_test.go
Normal file
116
internal/gamecore/ddz/game_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package ddz
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestBidRobFlow 叫地主 → 抢地主 → 确定地主的完整流程与倍数计算
|
||||
func TestBidRobFlow(t *testing.T) {
|
||||
g := NewGame()
|
||||
g.Turn = 0
|
||||
g.BidStart = 0
|
||||
// 座位 0 叫地主 → 进入抢地主,轮到座位 1
|
||||
if _, err := g.Bid(0, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Phase != PhaseRobbing || g.Turn != 1 || g.Landlord != 0 || g.Multiplier != 1 {
|
||||
t.Fatalf("叫地主后状态异常:phase=%s turn=%d landlord=%d mult=%d", g.Phase, g.Turn, g.Landlord, g.Multiplier)
|
||||
}
|
||||
// 座位 1 抢地主 → 地主候选换成 1,倍数×2,轮到座位 2
|
||||
if err := g.Rob(1, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Landlord != 1 || g.Multiplier != 2 || g.Turn != 2 {
|
||||
t.Fatalf("抢地主后状态异常:landlord=%d mult=%d turn=%d", g.Landlord, g.Multiplier, g.Turn)
|
||||
}
|
||||
// 座位 2 不抢 → 地主确定为 1,底牌并入(20张),进入出牌阶段
|
||||
if err := g.Rob(2, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Phase != PhasePlaying || g.Landlord != 1 || g.Turn != 1 {
|
||||
t.Fatalf("抢地主结束后状态异常:phase=%s landlord=%d turn=%d", g.Phase, g.Landlord, g.Turn)
|
||||
}
|
||||
if len(g.Hands[1]) != 20 {
|
||||
t.Fatalf("地主应有 20 张手牌,实际 %d", len(g.Hands[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobNonePass 两家都不抢:首叫者当地主,倍数不变
|
||||
func TestRobNonePass(t *testing.T) {
|
||||
g := NewGame()
|
||||
g.Turn = 2
|
||||
g.BidStart = 2
|
||||
g.Bid(2, true)
|
||||
if g.Turn != 0 {
|
||||
t.Fatalf("抢地主应从下家开始,实际 turn=%d", g.Turn)
|
||||
}
|
||||
g.Rob(0, false)
|
||||
if g.Turn != 1 {
|
||||
t.Fatalf("第二位抢地主表态者应为座位 1,实际 %d", g.Turn)
|
||||
}
|
||||
g.Rob(1, false)
|
||||
if g.Phase != PhasePlaying || g.Landlord != 2 || g.Multiplier != 1 {
|
||||
t.Fatalf("两家不抢后状态异常:phase=%s landlord=%d mult=%d", g.Phase, g.Landlord, g.Multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedealWhenAllPass 三家都不叫应重新发牌
|
||||
func TestRedealWhenAllPass(t *testing.T) {
|
||||
g := NewGame()
|
||||
start := g.Turn
|
||||
for i := 0; i < 2; i++ {
|
||||
redeal, err := g.Bid(g.Turn, false)
|
||||
if err != nil || redeal {
|
||||
t.Fatalf("前两家不叫不应触发重发:redeal=%v err=%v", redeal, err)
|
||||
}
|
||||
}
|
||||
redeal, err := g.Bid(g.Turn, false)
|
||||
if err != nil || !redeal {
|
||||
t.Fatalf("三家都不叫应返回重发:redeal=%v err=%v(start=%d)", redeal, err, start)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpringMultiplier 春天与反春的判定
|
||||
func TestSpringMultiplier(t *testing.T) {
|
||||
// 春天:地主赢且农民从未出过牌
|
||||
g := &Game{Landlord: 0, Winner: 0, Phase: PhaseOver, Multiplier: 1}
|
||||
g.History = []PlayEvent{
|
||||
{Seat: 0, Cards: []int{1}}, {Seat: 1}, {Seat: 2},
|
||||
{Seat: 0, Cards: []int{2}}, {Seat: 1}, {Seat: 2},
|
||||
}
|
||||
if g.SpringMultiplier() != 2 {
|
||||
t.Fatalf("地主全程压制应判春天 ×2,实际 %d", g.SpringMultiplier())
|
||||
}
|
||||
// 反春:农民赢且地主只出过第一手
|
||||
g2 := &Game{Landlord: 0, Winner: 1, Phase: PhaseOver, Multiplier: 1}
|
||||
g2.History = []PlayEvent{
|
||||
{Seat: 0, Cards: []int{1}},
|
||||
{Seat: 1, Cards: []int{2}}, {Seat: 2},
|
||||
{Seat: 1, Cards: []int{3}},
|
||||
}
|
||||
if g2.SpringMultiplier() != 2 {
|
||||
t.Fatalf("地主只出一手被打完应判反春 ×2,实际 %d", g2.SpringMultiplier())
|
||||
}
|
||||
// 普通局:双方都出过多手
|
||||
g3 := &Game{Landlord: 0, Winner: 1, Phase: PhaseOver, Multiplier: 1}
|
||||
g3.History = []PlayEvent{
|
||||
{Seat: 0, Cards: []int{1}}, {Seat: 1, Cards: []int{2}},
|
||||
{Seat: 0, Cards: []int{3}}, {Seat: 1, Cards: []int{4}},
|
||||
}
|
||||
if g3.SpringMultiplier() != 1 {
|
||||
t.Fatalf("普通局春天倍数应为 1,实际 %d", g3.SpringMultiplier())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTotalMultiplier 结算总倍数 = 叫抢 × 炸弹 × 春天(含上限)
|
||||
func TestTotalMultiplier(t *testing.T) {
|
||||
g := &Game{Landlord: 0, Winner: 0, Phase: PhaseOver, Multiplier: 2, Bombs: 2}
|
||||
g.History = []PlayEvent{{Seat: 0, Cards: []int{1}}, {Seat: 1, Cards: []int{2}}}
|
||||
// 2(抢1次)× 4(2个炸弹)× 1(无春天)= 8
|
||||
if got := g.TotalMultiplier(); got != 8 {
|
||||
t.Fatalf("总倍数应为 8,实际 %d", got)
|
||||
}
|
||||
// 上限保护:巨量炸弹也不超过 64
|
||||
g.Bombs = 10
|
||||
if got := g.TotalMultiplier(); got != 64 {
|
||||
t.Fatalf("总倍数应封顶 64,实际 %d", got)
|
||||
}
|
||||
}
|
||||
244
internal/gamecore/ddz/moves.go
Normal file
244
internal/gamecore/ddz/moves.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package ddz
|
||||
|
||||
import "sort"
|
||||
|
||||
// cardsByRank 把手牌按点数分组(点数 → 该点数的牌列表)
|
||||
func cardsByRank(hand []int) map[int][]int {
|
||||
m := map[int][]int{}
|
||||
for _, c := range hand {
|
||||
r := Rank(c)
|
||||
m[r] = append(m[r], c)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// sortedRanks 返回分组后升序的点数列表
|
||||
func sortedRanks(byRank map[int][]int) []int {
|
||||
ranks := make([]int, 0, len(byRank))
|
||||
for r := range byRank {
|
||||
ranks = append(ranks, r)
|
||||
}
|
||||
sort.Ints(ranks)
|
||||
return ranks
|
||||
}
|
||||
|
||||
// GenMoves 生成手牌中所有能压过 last 的候选出牌(last 为 nil 时生成所有可首出牌型)
|
||||
// 返回结果按"牌力从小到大"排序,方便规则 AI 优先出小牌
|
||||
func GenMoves(hand []int, last *Combo) [][]int {
|
||||
byRank := cardsByRank(hand)
|
||||
ranks := sortedRanks(byRank)
|
||||
var moves [][]int
|
||||
// add 尝试把一组牌解析并校验能否压过 last,可以则加入候选
|
||||
add := func(cards []int) {
|
||||
if combo := Parse(cards); combo != nil && combo.Beats(last) {
|
||||
moves = append(moves, cards)
|
||||
}
|
||||
}
|
||||
// 单张:每个点数取一张
|
||||
for _, r := range ranks {
|
||||
add([]int{byRank[r][0]})
|
||||
}
|
||||
// 对子
|
||||
for _, r := range ranks {
|
||||
if len(byRank[r]) >= 2 {
|
||||
add(byRank[r][:2])
|
||||
}
|
||||
}
|
||||
// 三张 / 三带一 / 三带二
|
||||
for _, r := range ranks {
|
||||
if len(byRank[r]) < 3 {
|
||||
continue
|
||||
}
|
||||
triple := byRank[r][:3]
|
||||
add(triple)
|
||||
// 三带一:附一张最小的其他单牌
|
||||
for _, r2 := range ranks {
|
||||
if r2 != r {
|
||||
add(append(append([]int{}, triple...), byRank[r2][0]))
|
||||
break
|
||||
}
|
||||
}
|
||||
// 三带二:附一对最小的其他对子
|
||||
for _, r2 := range ranks {
|
||||
if r2 != r && len(byRank[r2]) >= 2 {
|
||||
add(append(append([]int{}, triple...), byRank[r2][:2]...))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 顺子:从每个起点尝试每种长度(5张起,点数<=A)
|
||||
for length := 5; length <= 12; length++ {
|
||||
for start := 3; start+length-1 <= 14; start++ {
|
||||
cards := make([]int, 0, length)
|
||||
ok := true
|
||||
for r := start; r < start+length; r++ {
|
||||
if len(byRank[r]) == 0 {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
cards = append(cards, byRank[r][0])
|
||||
}
|
||||
if ok {
|
||||
add(cards)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 连对:3对起(点数<=A)
|
||||
for pairs := 3; pairs <= 10; pairs++ {
|
||||
for start := 3; start+pairs-1 <= 14; start++ {
|
||||
cards := make([]int, 0, pairs*2)
|
||||
ok := true
|
||||
for r := start; r < start+pairs; r++ {
|
||||
if len(byRank[r]) < 2 {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
cards = append(cards, byRank[r][:2]...)
|
||||
}
|
||||
if ok {
|
||||
add(cards)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 飞机(2组连续三张,带单/带对各生成一种最小附牌方案)
|
||||
for groups := 2; groups <= 5; groups++ {
|
||||
for start := 3; start+groups-1 <= 14; start++ {
|
||||
main := make([]int, 0, groups*3)
|
||||
mainRanks := map[int]bool{}
|
||||
ok := true
|
||||
for r := start; r < start+groups; r++ {
|
||||
if len(byRank[r]) < 3 {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
main = append(main, byRank[r][:3]...)
|
||||
mainRanks[r] = true
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 飞机不带
|
||||
add(main)
|
||||
// 飞机带单:取最小的 groups 张非主牌单牌
|
||||
singles := []int{}
|
||||
for _, r := range ranks {
|
||||
if mainRanks[r] {
|
||||
continue
|
||||
}
|
||||
for _, c := range byRank[r] {
|
||||
singles = append(singles, c)
|
||||
if len(singles) == groups {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(singles) == groups {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(singles) == groups {
|
||||
add(append(append([]int{}, main...), singles...))
|
||||
}
|
||||
// 飞机带对:取最小的 groups 对非主牌对子
|
||||
pairs := []int{}
|
||||
pairCnt := 0
|
||||
for _, r := range ranks {
|
||||
if mainRanks[r] || len(byRank[r]) < 2 {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, byRank[r][:2]...)
|
||||
pairCnt++
|
||||
if pairCnt == groups {
|
||||
break
|
||||
}
|
||||
}
|
||||
if pairCnt == groups {
|
||||
add(append(append([]int{}, main...), pairs...))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 四带二 / 四带两对
|
||||
for _, r := range ranks {
|
||||
if len(byRank[r]) < 4 {
|
||||
continue
|
||||
}
|
||||
four := byRank[r][:4]
|
||||
// 两张最小单牌
|
||||
singles := []int{}
|
||||
for _, r2 := range ranks {
|
||||
if r2 == r {
|
||||
continue
|
||||
}
|
||||
for _, c := range byRank[r2] {
|
||||
singles = append(singles, c)
|
||||
if len(singles) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(singles) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(singles) == 2 {
|
||||
add(append(append([]int{}, four...), singles...))
|
||||
}
|
||||
// 两对最小对子
|
||||
pairs := []int{}
|
||||
cnt := 0
|
||||
for _, r2 := range ranks {
|
||||
if r2 == r || len(byRank[r2]) < 2 {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, byRank[r2][:2]...)
|
||||
cnt++
|
||||
if cnt == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if cnt == 2 {
|
||||
add(append(append([]int{}, four...), pairs...))
|
||||
}
|
||||
}
|
||||
// 炸弹与王炸
|
||||
for _, r := range ranks {
|
||||
if len(byRank[r]) >= 4 {
|
||||
add(byRank[r][:4])
|
||||
}
|
||||
}
|
||||
if len(byRank[16]) == 1 && len(byRank[17]) == 1 {
|
||||
add([]int{byRank[16][0], byRank[17][0]})
|
||||
}
|
||||
// 排序:非炸弹在前(按主牌点数与张数),炸弹王炸垫后(规则 AI 不轻易放炸弹)
|
||||
sort.SliceStable(moves, func(i, j int) bool {
|
||||
ci, cj := Parse(moves[i]), Parse(moves[j])
|
||||
bi := ci.Type == ComboBomb || ci.Type == ComboRocket
|
||||
bj := cj.Type == ComboBomb || cj.Type == ComboRocket
|
||||
if bi != bj {
|
||||
return !bi
|
||||
}
|
||||
if ci.MainRank != cj.MainRank {
|
||||
return ci.MainRank < cj.MainRank
|
||||
}
|
||||
return len(moves[i]) < len(moves[j])
|
||||
})
|
||||
return moves
|
||||
}
|
||||
|
||||
// HandStrength 粗略评估手牌强度(叫地主用):大王4分、小王3分、2各2分、A各1分、炸弹3分
|
||||
func HandStrength(hand []int) int {
|
||||
score := 0
|
||||
cnt := rankCount(hand)
|
||||
if cnt[17] > 0 {
|
||||
score += 4
|
||||
}
|
||||
if cnt[16] > 0 {
|
||||
score += 3
|
||||
}
|
||||
score += cnt[15] * 2
|
||||
score += cnt[14]
|
||||
for r, k := range cnt {
|
||||
if k == 4 && r <= 15 {
|
||||
score += 3
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
452
internal/gamecore/ludo/ludo.go
Normal file
452
internal/gamecore/ludo/ludo.go
Normal file
@@ -0,0 +1,452 @@
|
||||
// Package ludo 飞行棋核心规则:52 格环形主航道 + 各家 6 格终点跑道
|
||||
// 掷 6 可起飞并奖励再掷一次;落在对方棋子上将其击落回机库;落在己方颜色格向前跳 4 格
|
||||
// 棋子步数编码:-1=机库,0..49=主航道(相对自家起点),50..55=终点跑道,56=到达终点
|
||||
package ludo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// 阶段
|
||||
const (
|
||||
PhaseRoll = "roll" // 等待当前玩家掷骰
|
||||
PhaseMove = "move" // 等待当前玩家选择要动的棋子
|
||||
PhaseOver = "over" // 对局结束
|
||||
)
|
||||
|
||||
// 棋盘常量
|
||||
const (
|
||||
TrackLen = 52 // 主航道格数
|
||||
MainSteps = 50 // 主航道上要走的步数(之后进入终点跑道)
|
||||
DoneStep = 56 // 到达终点的步数值
|
||||
PlaneCount = 4 // 每人棋子数
|
||||
SeatGap = 13 // 相邻玩家起点间隔格数
|
||||
MaxRolls = 400 // 总掷骰次数上限(防止无限对局)
|
||||
FlyFrom = 20 // 飞行格:踩中己方相对第 20 步的色格沿虚线航道飞跃
|
||||
FlyTo = 32 // 飞跃落点:相对第 32 步(+12 格,落点仍是己方色格可衔接跳跃)
|
||||
)
|
||||
|
||||
// Event 结构化动画事件:随状态广播下发,前端据此按顺序播放骰子/飞行/击落等动效
|
||||
// Kind 取值:
|
||||
// roll 掷骰(Die 为点数)
|
||||
// nomove 无棋可动,轮到下家
|
||||
// takeoff 起飞:机库 → 自家起点格
|
||||
// move 沿航道逐步前进(From→To 为步数域;含终点反弹,配合 Die 还原路径)
|
||||
// jump 踩中己方颜色格向前跳 4 格(From→To)
|
||||
// fly 踩中飞行格沿虚线航道飞跃(From→To,+12 格)
|
||||
// capture 击落:Victim 座位的 VPlane 号棋子从 VFrom 步被送回机库
|
||||
// arrive 棋子到达终点
|
||||
// bonus 掷出 6 点奖励再掷一次
|
||||
// sixpenalty 连掷三个 6:Plane 号棋子从 VFrom 步遣返机库(Plane=-1 表示无棋可罚)
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // 事件类型
|
||||
Seat int `json:"seat"` // 动作主体座位
|
||||
Plane int `json:"plane"` // 主体棋子编号
|
||||
Die int `json:"die"` // roll/move:骰子点数
|
||||
From int `json:"from"` // move/jump:起始步数
|
||||
To int `json:"to"` // move/jump:结束步数
|
||||
Victim int `json:"victim"` // capture:被击落座位
|
||||
VPlane int `json:"vplane"` // capture:被击落棋子编号
|
||||
VFrom int `json:"vfrom"` // capture:被击落时所在步数(按其自家步数域)
|
||||
}
|
||||
|
||||
// Game 一局飞行棋(固定 4 座位)
|
||||
type Game struct {
|
||||
Planes [4][PlaneCount]int // 每个座位 4 架飞机的步数
|
||||
Turn int // 当前行动座位
|
||||
Phase string // roll / move / over
|
||||
Die int // 当前骰子点数(0 表示尚未掷)
|
||||
Winner int // 获胜座位(-1 未定)
|
||||
Active [4]bool // 座位是否仍在对局中(中途退出置 false)
|
||||
Rolls int // 累计掷骰次数
|
||||
Sixes [4]int // 各座位连续掷出 6 的次数(第三个连 6 触发遣返惩罚)
|
||||
LastMv [2]int // 最近移动 [座位, 棋子](前端高亮,-1 表示无)
|
||||
Log []string // 动作描述(房间取走后广播)
|
||||
Events []Event // 最近动作的动画事件(房间取走后随状态下发)
|
||||
}
|
||||
|
||||
// NewGame 创建对局:全部飞机在机库,0 号座位先手
|
||||
func NewGame() *Game {
|
||||
g := &Game{Turn: 0, Phase: PhaseRoll, Winner: -1, LastMv: [2]int{-1, -1}}
|
||||
for s := 0; s < 4; s++ {
|
||||
g.Active[s] = true
|
||||
for i := 0; i < PlaneCount; i++ {
|
||||
g.Planes[s][i] = -1
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// logf 追加一条动作描述
|
||||
func (g *Game) logf(format string, args ...any) {
|
||||
g.Log = append(g.Log, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// DrainLog 取走并清空累计的动作描述
|
||||
func (g *Game) DrainLog() []string {
|
||||
out := g.Log
|
||||
g.Log = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// ev 追加一条动画事件
|
||||
func (g *Game) ev(e Event) {
|
||||
g.Events = append(g.Events, e)
|
||||
}
|
||||
|
||||
// DrainEvents 取走并清空累计的动画事件
|
||||
func (g *Game) DrainEvents() []Event {
|
||||
out := g.Events
|
||||
g.Events = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// cellOf 步数换算主航道绝对格号(仅 0..49 有效)
|
||||
func cellOf(seat, steps int) int {
|
||||
return (seat*SeatGap + steps) % TrackLen
|
||||
}
|
||||
|
||||
// Movable 当前骰子下可移动的棋子下标列表
|
||||
// 掷出 6 时机库棋子可起飞;场上棋子(未到终点)总是可动(超出终点按反弹处理)
|
||||
func (g *Game) Movable(seat int) []int {
|
||||
if g.Die == 0 {
|
||||
return nil
|
||||
}
|
||||
var list []int
|
||||
for i, s := range g.Planes[seat] {
|
||||
if s == -1 {
|
||||
if g.Die == 6 {
|
||||
list = append(list, i)
|
||||
}
|
||||
} else if s < DoneStep {
|
||||
list = append(list, i)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Roll 当前玩家掷骰:无棋可动直接轮到下家;唯一可动棋子自动移动
|
||||
func (g *Game) Roll(seat int) error {
|
||||
if g.Phase != PhaseRoll {
|
||||
return fmt.Errorf("当前不在掷骰阶段")
|
||||
}
|
||||
if g.Turn != seat {
|
||||
return fmt.Errorf("还没轮到你掷骰")
|
||||
}
|
||||
g.Die = rand.Intn(6) + 1
|
||||
g.Rolls++
|
||||
// 连续 6 点计数:掷出非 6 立即清零
|
||||
if g.Die == 6 {
|
||||
g.Sixes[seat]++
|
||||
} else {
|
||||
g.Sixes[seat] = 0
|
||||
}
|
||||
movable := g.Movable(seat)
|
||||
g.ev(Event{Kind: "roll", Seat: seat, Die: g.Die})
|
||||
g.logf("P%d 掷出了 %d 点", seat, g.Die)
|
||||
if g.Rolls >= MaxRolls {
|
||||
g.finishByProgress()
|
||||
return nil
|
||||
}
|
||||
// 连掷三个 6:触发遣返惩罚,本次不能行动
|
||||
if g.Sixes[seat] >= 3 {
|
||||
g.Sixes[seat] = 0
|
||||
g.punishTripleSix(seat)
|
||||
return nil
|
||||
}
|
||||
if len(movable) == 0 {
|
||||
g.ev(Event{Kind: "nomove", Seat: seat})
|
||||
g.logf("P%d 无棋可动,轮到下家", seat)
|
||||
g.advanceTurn()
|
||||
return nil
|
||||
}
|
||||
if len(movable) == 1 {
|
||||
// 只有一种选择:自动执行,省一次点击
|
||||
g.applyMove(seat, movable[0])
|
||||
return nil
|
||||
}
|
||||
g.Phase = PhaseMove
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move 当前玩家选择棋子移动
|
||||
func (g *Game) Move(seat, plane int) error {
|
||||
if g.Phase != PhaseMove {
|
||||
return fmt.Errorf("当前不在选棋阶段")
|
||||
}
|
||||
if g.Turn != seat {
|
||||
return fmt.Errorf("还没轮到你行动")
|
||||
}
|
||||
if plane < 0 || plane >= PlaneCount {
|
||||
return fmt.Errorf("棋子编号不合法")
|
||||
}
|
||||
legal := false
|
||||
for _, i := range g.Movable(seat) {
|
||||
if i == plane {
|
||||
legal = true
|
||||
}
|
||||
}
|
||||
if !legal {
|
||||
return fmt.Errorf("这架飞机现在不能移动")
|
||||
}
|
||||
g.applyMove(seat, plane)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMove 执行移动:起飞/前进/反弹/击落/同色跳跃/到达判定,然后决定是否连掷
|
||||
func (g *Game) applyMove(seat, plane int) {
|
||||
steps := g.Planes[seat][plane]
|
||||
if steps == -1 {
|
||||
// 起飞:落到自家起点格
|
||||
g.Planes[seat][plane] = 0
|
||||
g.ev(Event{Kind: "takeoff", Seat: seat, Plane: plane})
|
||||
g.logf("P%d 的 %d 号飞机起飞!", seat, plane+1)
|
||||
g.captureAt(seat, 0)
|
||||
} else {
|
||||
next := steps + g.Die
|
||||
if next > DoneStep {
|
||||
// 终点跑道冲过头:按多出的步数反弹
|
||||
next = 2*DoneStep - next
|
||||
}
|
||||
g.Planes[seat][plane] = next
|
||||
g.ev(Event{Kind: "move", Seat: seat, Plane: plane, Die: g.Die, From: steps, To: next})
|
||||
if next == DoneStep {
|
||||
g.ev(Event{Kind: "arrive", Seat: seat, Plane: plane})
|
||||
g.logf("P%d 的 %d 号飞机到达终点!", seat, plane+1)
|
||||
} else if next >= MainSteps {
|
||||
g.logf("P%d 的 %d 号飞机进入终点跑道", seat, plane+1)
|
||||
} else {
|
||||
g.captureAt(seat, next)
|
||||
// 第一段:己方色格跳跃 +4(不连锁;直接落在飞行格时交给下面的飞跃处理)
|
||||
if next != FlyFrom && cellOf(seat, next)%4 == seat && next+4 < MainSteps {
|
||||
g.Planes[seat][plane] = next + 4
|
||||
g.ev(Event{Kind: "jump", Seat: seat, Plane: plane, From: next, To: next + 4})
|
||||
g.logf("P%d 的 %d 号飞机踩中己方颜色格,向前跳 4 格", seat, plane+1)
|
||||
next += 4
|
||||
g.captureAt(seat, next)
|
||||
}
|
||||
// 第二段:飞行格飞跃 +12(直接落上或跳跃衔接到飞行格都会触发)
|
||||
if next == FlyFrom {
|
||||
g.Planes[seat][plane] = FlyTo
|
||||
g.ev(Event{Kind: "fly", Seat: seat, Plane: plane, From: FlyFrom, To: FlyTo})
|
||||
g.logf("P%d 的 %d 号飞机踩中飞行格,沿虚线航道飞跃 12 格!", seat, plane+1)
|
||||
next = FlyTo
|
||||
g.captureAt(seat, next)
|
||||
// 第三段:飞跃落点仍是己方色格,衔接一次跳跃收尾(经典连锁)
|
||||
if cellOf(seat, next)%4 == seat && next+4 < MainSteps {
|
||||
g.Planes[seat][plane] = next + 4
|
||||
g.ev(Event{Kind: "jump", Seat: seat, Plane: plane, From: next, To: next + 4})
|
||||
g.logf("P%d 的 %d 号飞机飞跃落点又是己方色格,再跳 4 格!", seat, plane+1)
|
||||
next += 4
|
||||
g.captureAt(seat, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g.LastMv = [2]int{seat, plane}
|
||||
// 全部到达 → 获胜
|
||||
done := 0
|
||||
for _, s := range g.Planes[seat] {
|
||||
if s == DoneStep {
|
||||
done++
|
||||
}
|
||||
}
|
||||
if done == PlaneCount {
|
||||
g.Winner = seat
|
||||
g.Phase = PhaseOver
|
||||
g.logf("P%d 四架飞机全部到达,获得胜利!", seat)
|
||||
return
|
||||
}
|
||||
// 掷出 6 奖励再掷一次,否则轮到下家
|
||||
if g.Die == 6 {
|
||||
g.Die = 0
|
||||
g.Phase = PhaseRoll
|
||||
g.ev(Event{Kind: "bonus", Seat: seat})
|
||||
g.logf("P%d 掷出 6 点,奖励再掷一次", seat)
|
||||
return
|
||||
}
|
||||
g.advanceTurn()
|
||||
}
|
||||
|
||||
// punishTripleSix 连掷三个 6 的惩罚:优先遣返最近移动的主航道棋子,
|
||||
// 其次遣返最靠前的主航道棋子;无棋可罚则只作废本回合。惩罚后轮到下家(不再奖励连掷)
|
||||
func (g *Game) punishTripleSix(seat int) {
|
||||
victim := -1
|
||||
if g.LastMv[0] == seat && g.LastMv[1] >= 0 {
|
||||
if s := g.Planes[seat][g.LastMv[1]]; s >= 0 && s < MainSteps {
|
||||
victim = g.LastMv[1]
|
||||
}
|
||||
}
|
||||
if victim == -1 {
|
||||
best := -1
|
||||
for i, s := range g.Planes[seat] {
|
||||
if s >= 0 && s < MainSteps && s > best {
|
||||
best, victim = s, i
|
||||
}
|
||||
}
|
||||
}
|
||||
if victim >= 0 {
|
||||
g.ev(Event{Kind: "sixpenalty", Seat: seat, Plane: victim, VFrom: g.Planes[seat][victim]})
|
||||
g.Planes[seat][victim] = -1
|
||||
g.logf("P%d 连掷三个 6 触发惩罚,%d 号飞机被遣返回机库!", seat, victim+1)
|
||||
} else {
|
||||
g.ev(Event{Kind: "sixpenalty", Seat: seat, Plane: -1})
|
||||
g.logf("P%d 连掷三个 6 触发惩罚,本回合作废", seat)
|
||||
}
|
||||
g.advanceTurn()
|
||||
}
|
||||
|
||||
// captureAt 击落:把停在指定步数对应主航道格上的所有他人棋子送回机库
|
||||
func (g *Game) captureAt(seat, steps int) {
|
||||
if steps < 0 || steps >= MainSteps {
|
||||
return
|
||||
}
|
||||
cell := cellOf(seat, steps)
|
||||
for other := 0; other < 4; other++ {
|
||||
if other == seat || !g.Active[other] {
|
||||
continue
|
||||
}
|
||||
for i, s := range g.Planes[other] {
|
||||
if s >= 0 && s < MainSteps && cellOf(other, s) == cell {
|
||||
g.Planes[other][i] = -1
|
||||
g.ev(Event{Kind: "capture", Seat: seat, Victim: other, VPlane: i, VFrom: s})
|
||||
g.logf("P%d 击落了 P%d 的 %d 号飞机!", seat, other, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// advanceTurn 轮到下一个仍在对局中的座位
|
||||
func (g *Game) advanceTurn() {
|
||||
g.Die = 0
|
||||
g.Phase = PhaseRoll
|
||||
for i := 0; i < 4; i++ {
|
||||
g.Turn = (g.Turn + 1) % 4
|
||||
if g.Active[g.Turn] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// progressOf 进度评分:到达 +100,场上棋子按步数累计
|
||||
func (g *Game) progressOf(seat int) int {
|
||||
total := 0
|
||||
for _, s := range g.Planes[seat] {
|
||||
if s == DoneStep {
|
||||
total += 100
|
||||
} else if s >= 0 {
|
||||
total += s
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// finishByProgress 掷骰次数打满:按进度最高者获胜
|
||||
func (g *Game) finishByProgress() {
|
||||
best, bestSeat := -1, 0
|
||||
for s := 0; s < 4; s++ {
|
||||
if !g.Active[s] {
|
||||
continue
|
||||
}
|
||||
if p := g.progressOf(s); p > best {
|
||||
best, bestSeat = p, s
|
||||
}
|
||||
}
|
||||
g.Winner = bestSeat
|
||||
g.Phase = PhaseOver
|
||||
g.logf("回合数已达上限,按进度判定 P%d 获胜!", bestSeat)
|
||||
}
|
||||
|
||||
// ForceFinish 提前按进度结算(房间层在真人全部退出时调用,避免纯 AI 空跑)
|
||||
func (g *Game) ForceFinish() {
|
||||
if g.Phase != PhaseOver {
|
||||
g.finishByProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminate 玩家中途退出:棋子全部收回,不再参与轮转
|
||||
func (g *Game) Eliminate(seat int) {
|
||||
if !g.Active[seat] {
|
||||
return
|
||||
}
|
||||
g.Active[seat] = false
|
||||
for i := range g.Planes[seat] {
|
||||
g.Planes[seat][i] = -1
|
||||
}
|
||||
if g.Phase != PhaseOver && g.Turn == seat {
|
||||
g.advanceTurn()
|
||||
}
|
||||
}
|
||||
|
||||
// AIChoosePlane 规则 AI 选棋:按「击落 > 到达 > 进跑道 > 起飞 > 领先棋子推进」估值
|
||||
// easy 随机选;medium 大概率选最优;hard 永远选最优
|
||||
func AIChoosePlane(g *Game, seat int, difficulty string) int {
|
||||
movable := g.Movable(seat)
|
||||
if len(movable) == 0 {
|
||||
return -1
|
||||
}
|
||||
if difficulty == "easy" || (difficulty == "medium" && rand.Float64() < 0.25) {
|
||||
return movable[rand.Intn(len(movable))]
|
||||
}
|
||||
bestPlane, bestScore := movable[0], -1<<30
|
||||
for _, i := range movable {
|
||||
score := 0
|
||||
steps := g.Planes[seat][i]
|
||||
if steps == -1 {
|
||||
// 起飞:机库棋子越多越值得起飞
|
||||
hangar := 0
|
||||
for _, s := range g.Planes[seat] {
|
||||
if s == -1 {
|
||||
hangar++
|
||||
}
|
||||
}
|
||||
score = 30 + hangar*5
|
||||
if g.enemyOnCell(seat, cellOf(seat, 0)) {
|
||||
score += 100
|
||||
}
|
||||
} else {
|
||||
next := steps + g.Die
|
||||
if next > DoneStep {
|
||||
next = 2*DoneStep - next
|
||||
}
|
||||
switch {
|
||||
case next == DoneStep:
|
||||
score = 80
|
||||
case next >= MainSteps:
|
||||
score = 40 + next
|
||||
default:
|
||||
score = next
|
||||
if g.enemyOnCell(seat, cellOf(seat, next)) {
|
||||
score += 100
|
||||
}
|
||||
if cellOf(seat, next)%4 == seat {
|
||||
score += 15
|
||||
}
|
||||
// 落点是飞行格:飞跃 +12 再衔接跳跃,收益极高
|
||||
if next == FlyFrom {
|
||||
score += 45
|
||||
}
|
||||
}
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore, bestPlane = score, i
|
||||
}
|
||||
}
|
||||
return bestPlane
|
||||
}
|
||||
|
||||
// enemyOnCell 指定主航道格上是否有他人棋子(击落估值用)
|
||||
func (g *Game) enemyOnCell(seat, cell int) bool {
|
||||
for other := 0; other < 4; other++ {
|
||||
if other == seat || !g.Active[other] {
|
||||
continue
|
||||
}
|
||||
for _, s := range g.Planes[other] {
|
||||
if s >= 0 && s < MainSteps && cellOf(other, s) == cell {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
176
internal/gamecore/ludo/ludo_test.go
Normal file
176
internal/gamecore/ludo/ludo_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// 飞行棋核心自测:AI 自对弈跑完整局,验证规则与终局收敛
|
||||
package ludo
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestFullGame 四个规则 AI 对局 50 盘,必须都能正常结束
|
||||
func TestFullGame(t *testing.T) {
|
||||
for round := 0; round < 50; round++ {
|
||||
g := NewGame()
|
||||
for actions := 0; actions < 20000; actions++ {
|
||||
if g.Phase == PhaseOver {
|
||||
break
|
||||
}
|
||||
seat := g.Turn
|
||||
if g.Phase == PhaseRoll {
|
||||
if err := g.Roll(seat); err != nil {
|
||||
t.Fatalf("第 %d 盘掷骰失败: %v", round, err)
|
||||
}
|
||||
} else if g.Phase == PhaseMove {
|
||||
plane := AIChoosePlane(g, seat, "hard")
|
||||
if plane < 0 {
|
||||
t.Fatalf("第 %d 盘 move 阶段却无可动棋子", round)
|
||||
}
|
||||
if err := g.Move(seat, plane); err != nil {
|
||||
t.Fatalf("第 %d 盘移动失败: %v", round, err)
|
||||
}
|
||||
}
|
||||
g.DrainLog()
|
||||
}
|
||||
if g.Phase != PhaseOver {
|
||||
t.Fatalf("第 %d 盘 20000 步仍未结束(Rolls=%d)", round, g.Rolls)
|
||||
}
|
||||
if g.Winner < 0 || g.Winner > 3 {
|
||||
t.Fatalf("第 %d 盘获胜座位非法: %d", round, g.Winner)
|
||||
}
|
||||
// 校验:所有棋子步数都在合法范围
|
||||
for s := 0; s < 4; s++ {
|
||||
for i, st := range g.Planes[s] {
|
||||
if st < -1 || st > DoneStep {
|
||||
t.Fatalf("第 %d 盘 P%d 棋子 %d 步数越界: %d", round, s, i, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapture 击落规则:对手棋子被踩后回机库,并产生 capture 动画事件
|
||||
func TestCapture(t *testing.T) {
|
||||
g := NewGame()
|
||||
// 座位1 的棋子放在座位0 起点前方第 6 格(绝对格 6)
|
||||
// 座位1 起点为 13,绝对格6 = (13+steps)%52 → steps = 45
|
||||
g.Planes[1][0] = 45
|
||||
// 座位0 掷 6 起飞一架,再走 6 步(手动模拟到第 6 格)
|
||||
g.Planes[0][0] = 0
|
||||
g.Die = 6
|
||||
g.Turn = 0
|
||||
g.Phase = PhaseMove
|
||||
if err := g.Move(0, 0); err != nil {
|
||||
t.Fatalf("移动失败: %v", err)
|
||||
}
|
||||
if g.Planes[1][0] != -1 {
|
||||
t.Fatalf("对手棋子应被击落回机库,实际步数 %d", g.Planes[1][0])
|
||||
}
|
||||
// 事件流校验:应包含 move 与 capture,capture 记录被击落者信息
|
||||
var hasMove, hasCapture bool
|
||||
for _, e := range g.DrainEvents() {
|
||||
if e.Kind == "move" && e.Seat == 0 && e.From == 0 && e.To == 6 {
|
||||
hasMove = true
|
||||
}
|
||||
if e.Kind == "capture" && e.Victim == 1 && e.VPlane == 0 && e.VFrom == 45 {
|
||||
hasCapture = true
|
||||
}
|
||||
}
|
||||
if !hasMove || !hasCapture {
|
||||
t.Fatalf("事件流缺失 move/capture: move=%v capture=%v", hasMove, hasCapture)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlyChain 飞跃航线:落在相对 20 步的飞行格 → 飞到 32 → 衔接跳跃到 36
|
||||
func TestFlyChain(t *testing.T) {
|
||||
g := NewGame()
|
||||
g.Planes[0][0] = 15
|
||||
g.Die = 5 // 15+5=20 恰好踩中飞行格
|
||||
g.Turn = 0
|
||||
g.Phase = PhaseMove
|
||||
if err := g.Move(0, 0); err != nil {
|
||||
t.Fatalf("移动失败: %v", err)
|
||||
}
|
||||
if g.Planes[0][0] != 36 {
|
||||
t.Fatalf("飞跃连锁应停在 36,实际 %d", g.Planes[0][0])
|
||||
}
|
||||
var hasFly, hasJump bool
|
||||
for _, e := range g.DrainEvents() {
|
||||
if e.Kind == "fly" && e.From == FlyFrom && e.To == FlyTo {
|
||||
hasFly = true
|
||||
}
|
||||
if e.Kind == "jump" && e.From == FlyTo && e.To == FlyTo+4 {
|
||||
hasJump = true
|
||||
}
|
||||
}
|
||||
if !hasFly || !hasJump {
|
||||
t.Fatalf("事件流缺失 fly/jump: fly=%v jump=%v", hasFly, hasJump)
|
||||
}
|
||||
// 跳跃衔接:落在 16(色格)→ 跳到 20(飞行格)→ 飞到 32 → 再跳到 36
|
||||
g2 := NewGame()
|
||||
g2.Planes[1][0] = 12
|
||||
g2.Die = 4
|
||||
g2.Turn = 1
|
||||
g2.Phase = PhaseMove
|
||||
if err := g2.Move(1, 0); err != nil {
|
||||
t.Fatalf("移动失败: %v", err)
|
||||
}
|
||||
if g2.Planes[1][0] != 36 {
|
||||
t.Fatalf("跳跃衔接飞跃应停在 36,实际 %d", g2.Planes[1][0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTripleSix 连掷三个 6:最近移动的主航道棋子被遣返,轮到下家
|
||||
func TestTripleSix(t *testing.T) {
|
||||
g := NewGame()
|
||||
g.Planes[0][2] = 10
|
||||
g.LastMv = [2]int{0, 2}
|
||||
g.Turn = 0
|
||||
g.punishTripleSix(0)
|
||||
if g.Planes[0][2] != -1 {
|
||||
t.Fatalf("棋子应被遣返机库,实际 %d", g.Planes[0][2])
|
||||
}
|
||||
if g.Turn == 0 {
|
||||
t.Fatal("惩罚后应轮到下家")
|
||||
}
|
||||
found := false
|
||||
for _, e := range g.DrainEvents() {
|
||||
if e.Kind == "sixpenalty" && e.Seat == 0 && e.Plane == 2 && e.VFrom == 10 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("sixpenalty 事件缺失或字段错误")
|
||||
}
|
||||
// 无主航道棋子可罚:只作废回合
|
||||
g2 := NewGame()
|
||||
g2.Planes[0][0] = 52 // 终点跑道内不受罚
|
||||
g2.Turn = 0
|
||||
g2.punishTripleSix(0)
|
||||
if g2.Planes[0][0] != 52 {
|
||||
t.Fatal("终点跑道棋子不应被遣返")
|
||||
}
|
||||
if g2.Turn == 0 {
|
||||
t.Fatal("无棋可罚也应轮到下家")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoveEvents 掷骰必产生 roll 事件;反弹时 move 事件的 Die 可还原路径
|
||||
func TestMoveEvents(t *testing.T) {
|
||||
g := NewGame()
|
||||
// 棋子在 54 步,掷 5 → 54+5=59 > 56,反弹到 53
|
||||
g.Planes[0][0] = 54
|
||||
g.Die = 5
|
||||
g.Turn = 0
|
||||
g.Phase = PhaseMove
|
||||
if err := g.Move(0, 0); err != nil {
|
||||
t.Fatalf("移动失败: %v", err)
|
||||
}
|
||||
if g.Planes[0][0] != 53 {
|
||||
t.Fatalf("反弹计算错误,期望 53 实际 %d", g.Planes[0][0])
|
||||
}
|
||||
found := false
|
||||
for _, e := range g.DrainEvents() {
|
||||
if e.Kind == "move" && e.From == 54 && e.To == 53 && e.Die == 5 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("反弹 move 事件缺失或字段错误")
|
||||
}
|
||||
}
|
||||
573
internal/gamecore/monopoly/monopoly.go
Normal file
573
internal/gamecore/monopoly/monopoly.go
Normal file
@@ -0,0 +1,573 @@
|
||||
// Package monopoly 大富翁核心规则:24 格环形棋盘、掷骰移动、买地收租、机会卡与破产判定
|
||||
// 只负责纯游戏逻辑,房间调度 / 网络推送由 room 包完成
|
||||
package monopoly
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// 地块类型
|
||||
const (
|
||||
TileGo = "go" // 起点:经过或停留 +200
|
||||
TileProp = "prop" // 可购买地产
|
||||
TileChance = "chance" // 机会卡
|
||||
TileTax = "tax" // 缴税
|
||||
TileJail = "jail" // 监狱(路过无事,被送进来休息一回合)
|
||||
TileGoToJail = "gotojail" // 前往监狱
|
||||
TilePark = "park" // 免费停车(无事发生)
|
||||
)
|
||||
|
||||
// 阶段
|
||||
const (
|
||||
PhaseRoll = "roll" // 等待当前玩家掷骰
|
||||
PhaseBuy = "buy" // 等待当前玩家决定是否购买脚下地产
|
||||
PhaseUpgrade = "upgrade" // 等待当前玩家决定是否升级自己脚下的地产
|
||||
PhaseOver = "over" // 对局结束
|
||||
)
|
||||
|
||||
// 数值配置
|
||||
const (
|
||||
StartMoney = 1500 // 开局资金
|
||||
GoBonus = 200 // 经过/停留起点奖励
|
||||
MaxRounds = 25 // 轮数上限:到达后按总资产分胜负
|
||||
JailIndex = 6 // 监狱所在格
|
||||
MaxLevel = 3 // 地产最高等级(1 小屋 / 2 楼房 / 3 酒店)
|
||||
)
|
||||
|
||||
// Tile 一个地块
|
||||
type Tile struct {
|
||||
Idx int `json:"idx"` // 格子序号(0-23 顺时针)
|
||||
Type string `json:"type"` // 地块类型
|
||||
Name string `json:"name"` // 显示名称
|
||||
Price int `json:"price"` // 地价(非地产为 0)
|
||||
Group int `json:"group"` // 颜色组编号(-1 表示非地产)
|
||||
Owner int `json:"owner"` // 归属座位(-1 无主)
|
||||
Level int `json:"level"` // 建筑等级(0 空地 / 1 小屋 / 2 楼房 / 3 酒店,租金按等级翻倍)
|
||||
}
|
||||
|
||||
// Player 一名玩家的资产状态
|
||||
type Player struct {
|
||||
Seat int `json:"seat"` // 座位号
|
||||
Money int `json:"money"` // 现金
|
||||
Pos int `json:"pos"` // 当前位置
|
||||
Out bool `json:"out"` // 是否已破产出局
|
||||
Skip int `json:"skip"` // 还需休息的回合数(监狱/机会卡)
|
||||
}
|
||||
|
||||
// Event 结构化动画事件:随状态广播下发,前端据此按顺序播放骰子/走格/金钱等动效
|
||||
// Kind 取值:
|
||||
// roll 掷骰(D1/D2 为两颗骰子点数)
|
||||
// move 沿棋盘顺时针走格(From→To,可能跨过起点)
|
||||
// teleport 直达传送(入狱 / 机会卡直达起点,From→To 播放飞行动画)
|
||||
// cash 金钱变动(Amount 正收负支,Text 为原因)
|
||||
// chance 抽到机会卡(Text 为卡面文案,前端翻牌展示)
|
||||
// buy 买下地产(Tile 为地块序号)
|
||||
// bankrupt 破产出局
|
||||
// rest 休息中跳过回合
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // 事件类型
|
||||
Seat int `json:"seat"` // 动作主体座位
|
||||
D1 int `json:"d1"` // roll:骰子一点数
|
||||
D2 int `json:"d2"` // roll:骰子二点数
|
||||
From int `json:"from"` // move/teleport:起始格
|
||||
To int `json:"to"` // move/teleport:结束格
|
||||
Amount int `json:"amount"` // cash:变动金额(正收负支)
|
||||
Tile int `json:"tile"` // buy:地块序号
|
||||
Text string `json:"text"` // cash/chance:文案(P%d 占位符由房间层替换昵称)
|
||||
}
|
||||
|
||||
// Game 一局大富翁
|
||||
type Game struct {
|
||||
Tiles []*Tile // 24 个地块
|
||||
Players []*Player // 全部玩家(按座位)
|
||||
Turn int // 当前行动座位
|
||||
Phase string // roll / buy / over
|
||||
Dice [2]int // 最近一次骰子
|
||||
Round int // 当前轮数(回到 0 号玩家算新一轮)
|
||||
Pending int // 待购买的地块序号(buy 阶段有效)
|
||||
Winners []int // 结束后的获胜座位
|
||||
Log []string // 最近动作的文字描述(房间取走后广播)
|
||||
Events []Event // 最近动作的动画事件(房间取走后随状态下发)
|
||||
}
|
||||
|
||||
// buildTiles 构建 24 格棋盘:4 角 + 14 块地产(7 组×2)+ 4 机会 + 2 税
|
||||
func buildTiles() []*Tile {
|
||||
type def struct {
|
||||
typ string
|
||||
name string
|
||||
price int
|
||||
group int
|
||||
}
|
||||
defs := []def{
|
||||
{TileGo, "起点", 0, -1}, // 0
|
||||
{TileProp, "北京", 350, 0}, // 1
|
||||
{TileProp, "上海", 340, 0}, // 2
|
||||
{TileChance, "机会", 0, -1}, // 3
|
||||
{TileTax, "所得税·150", 0, -1}, // 4
|
||||
{TileProp, "广州", 300, 1}, // 5
|
||||
{TileJail, "监狱", 0, -1}, // 6
|
||||
{TileProp, "深圳", 300, 1}, // 7
|
||||
{TileProp, "杭州", 260, 2}, // 8
|
||||
{TileChance, "机会", 0, -1}, // 9
|
||||
{TileProp, "成都", 250, 2}, // 10
|
||||
{TileProp, "重庆", 220, 3}, // 11
|
||||
{TilePark, "免费停车", 0, -1}, // 12
|
||||
{TileProp, "武汉", 210, 3}, // 13
|
||||
{TileProp, "西安", 180, 4}, // 14
|
||||
{TileChance, "机会", 0, -1}, // 15
|
||||
{TileTax, "养路费·100", 0, -1}, // 16
|
||||
{TileProp, "南京", 170, 4}, // 17
|
||||
{TileGoToJail, "入狱", 0, -1}, // 18
|
||||
{TileProp, "苏州", 140, 5}, // 19
|
||||
{TileProp, "天津", 130, 5}, // 20
|
||||
{TileChance, "机会", 0, -1}, // 21
|
||||
{TileProp, "长沙", 100, 6}, // 22
|
||||
{TileProp, "青岛", 90, 6}, // 23
|
||||
}
|
||||
tiles := make([]*Tile, len(defs))
|
||||
for i, d := range defs {
|
||||
tiles[i] = &Tile{Idx: i, Type: d.typ, Name: d.name, Price: d.price, Group: d.group, Owner: -1}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
// NewGame 创建 n 人对局(座位 0..n-1),0 号先手
|
||||
func NewGame(n int) *Game {
|
||||
players := make([]*Player, n)
|
||||
for i := range players {
|
||||
players[i] = &Player{Seat: i, Money: StartMoney}
|
||||
}
|
||||
return &Game{
|
||||
Tiles: buildTiles(), Players: players,
|
||||
Turn: 0, Phase: PhaseRoll, Round: 1, Pending: -1, Winners: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// logf 追加一条动作描述
|
||||
func (g *Game) logf(format string, args ...any) {
|
||||
g.Log = append(g.Log, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// DrainLog 取走并清空累计的动作描述
|
||||
func (g *Game) DrainLog() []string {
|
||||
out := g.Log
|
||||
g.Log = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// ev 追加一条动画事件
|
||||
func (g *Game) ev(e Event) {
|
||||
g.Events = append(g.Events, e)
|
||||
}
|
||||
|
||||
// DrainEvents 取走并清空累计的动画事件
|
||||
func (g *Game) DrainEvents() []Event {
|
||||
out := g.Events
|
||||
g.Events = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// UpgradeCost 升一级建筑的费用:地价的一半
|
||||
func UpgradeCost(t *Tile) int {
|
||||
return t.Price / 2
|
||||
}
|
||||
|
||||
// rentOf 地产租金:地价 30% 为基础,每级建筑翻一倍,同组全部集齐再翻倍
|
||||
func (g *Game) rentOf(t *Tile) int {
|
||||
rent := (t.Price * 30 / 100) << t.Level
|
||||
if g.ownsFullGroup(t.Owner, t.Group) {
|
||||
rent *= 2
|
||||
}
|
||||
return rent
|
||||
}
|
||||
|
||||
// ownsFullGroup 某座位是否集齐指定颜色组
|
||||
func (g *Game) ownsFullGroup(seat, group int) bool {
|
||||
if group < 0 || seat < 0 {
|
||||
return false
|
||||
}
|
||||
for _, t := range g.Tiles {
|
||||
if t.Group == group && t.Owner != seat {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AssetsOf 总资产 = 现金 + 名下地产地价 + 建筑投入
|
||||
func (g *Game) AssetsOf(seat int) int {
|
||||
total := g.Players[seat].Money
|
||||
for _, t := range g.Tiles {
|
||||
if t.Owner == seat {
|
||||
total += t.Price + t.Level*UpgradeCost(t)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// activeCount 未出局玩家数
|
||||
func (g *Game) activeCount() int {
|
||||
n := 0
|
||||
for _, p := range g.Players {
|
||||
if !p.Out {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Roll 当前玩家掷骰并解算落点;可能进入 buy 阶段或直接轮到下家
|
||||
func (g *Game) Roll(seat int) error {
|
||||
if g.Phase != PhaseRoll {
|
||||
return fmt.Errorf("当前不在掷骰阶段")
|
||||
}
|
||||
if g.Turn != seat {
|
||||
return fmt.Errorf("还没轮到你掷骰")
|
||||
}
|
||||
p := g.Players[seat]
|
||||
g.Dice[0] = rand.Intn(6) + 1
|
||||
g.Dice[1] = rand.Intn(6) + 1
|
||||
steps := g.Dice[0] + g.Dice[1]
|
||||
oldPos := p.Pos
|
||||
p.Pos = (p.Pos + steps) % len(g.Tiles)
|
||||
g.ev(Event{Kind: "roll", Seat: seat, D1: g.Dice[0], D2: g.Dice[1]})
|
||||
g.ev(Event{Kind: "move", Seat: seat, From: oldPos, To: p.Pos})
|
||||
// 经过或落在起点:领取奖励
|
||||
if p.Pos < oldPos || p.Pos == 0 {
|
||||
p.Money += GoBonus
|
||||
g.ev(Event{Kind: "cash", Seat: seat, Amount: GoBonus, Text: "经过起点奖励"})
|
||||
g.logf("P%d 掷出 %d+%d,经过起点领取 %d 元", seat, g.Dice[0], g.Dice[1], GoBonus)
|
||||
} else {
|
||||
g.logf("P%d 掷出 %d+%d,前进 %d 格", seat, g.Dice[0], g.Dice[1], steps)
|
||||
}
|
||||
g.resolveTile(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTile 解算玩家落点效果(可能置为 buy 阶段 / 破产 / 轮到下家)
|
||||
func (g *Game) resolveTile(p *Player) {
|
||||
t := g.Tiles[p.Pos]
|
||||
switch t.Type {
|
||||
case TileProp:
|
||||
if t.Owner == -1 {
|
||||
// 无主地产:买得起就进入购买决策,否则直接过
|
||||
if p.Money >= t.Price {
|
||||
g.Phase = PhaseBuy
|
||||
g.Pending = t.Idx
|
||||
g.logf("P%d 来到无主地产「%s」(%d 元),可以选择买下", p.Seat, t.Name, t.Price)
|
||||
return
|
||||
}
|
||||
g.logf("P%d 来到「%s」但现金不足 %d 元,只能路过", p.Seat, t.Name, t.Price)
|
||||
} else if t.Owner != p.Seat {
|
||||
// 他人地产:付租金
|
||||
rent := g.rentOf(t)
|
||||
g.payTo(p, g.Players[t.Owner], rent, fmt.Sprintf("落在 P%d 的「%s」支付租金", t.Owner, t.Name))
|
||||
} else if t.Level < MaxLevel && p.Money >= UpgradeCost(t) {
|
||||
// 自己的地产且可加盖:进入升级决策
|
||||
g.Phase = PhaseUpgrade
|
||||
g.Pending = t.Idx
|
||||
g.logf("P%d 回到自己的「%s」,可以花 %d 元升级建筑", p.Seat, t.Name, UpgradeCost(t))
|
||||
return
|
||||
} else {
|
||||
g.logf("P%d 回到自己的地盘「%s」,休息一下", p.Seat, t.Name)
|
||||
}
|
||||
case TileChance:
|
||||
g.applyChance(p)
|
||||
case TileTax:
|
||||
tax := 150
|
||||
if t.Idx == 16 {
|
||||
tax = 100
|
||||
}
|
||||
g.payTo(p, nil, tax, fmt.Sprintf("落在「%s」缴税", t.Name))
|
||||
case TileGoToJail:
|
||||
g.ev(Event{Kind: "teleport", Seat: p.Seat, From: p.Pos, To: JailIndex, Text: "被押送进监狱"})
|
||||
p.Pos = JailIndex
|
||||
p.Skip = 1
|
||||
g.logf("P%d 被押送进监狱,休息 1 回合", p.Seat)
|
||||
case TileJail, TilePark, TileGo:
|
||||
g.logf("P%d 在「%s」稍作停留", p.Seat, t.Name)
|
||||
}
|
||||
g.endTurn()
|
||||
}
|
||||
|
||||
// applyChance 机会卡:六种随机事件
|
||||
func (g *Game) applyChance(p *Player) {
|
||||
switch rand.Intn(6) {
|
||||
case 0:
|
||||
g.ev(Event{Kind: "chance", Seat: p.Seat, Text: "路边捡到红包 +150 元"})
|
||||
p.Money += 150
|
||||
g.ev(Event{Kind: "cash", Seat: p.Seat, Amount: 150, Text: "机会卡红包"})
|
||||
g.logf("P%d 抽到机会卡:路边捡到红包 +150 元", p.Seat)
|
||||
case 1:
|
||||
g.ev(Event{Kind: "chance", Seat: p.Seat, Text: "违章停车,罚款 100 元"})
|
||||
g.payTo(p, nil, 100, "抽到机会卡:违章停车罚款")
|
||||
case 2:
|
||||
g.ev(Event{Kind: "chance", Seat: p.Seat, Text: "银行发放利息 +100 元"})
|
||||
p.Money += 100
|
||||
g.ev(Event{Kind: "cash", Seat: p.Seat, Amount: 100, Text: "银行利息"})
|
||||
g.logf("P%d 抽到机会卡:银行发放利息 +100 元", p.Seat)
|
||||
case 3:
|
||||
g.ev(Event{Kind: "chance", Seat: p.Seat, Text: "乘坐地铁直达起点,领取 200 元"})
|
||||
g.ev(Event{Kind: "teleport", Seat: p.Seat, From: p.Pos, To: 0, Text: "直达起点"})
|
||||
p.Pos = 0
|
||||
p.Money += GoBonus
|
||||
g.ev(Event{Kind: "cash", Seat: p.Seat, Amount: GoBonus, Text: "起点奖励"})
|
||||
g.logf("P%d 抽到机会卡:直达起点并领取 %d 元", p.Seat, GoBonus)
|
||||
case 4:
|
||||
g.ev(Event{Kind: "chance", Seat: p.Seat, Text: "感冒卧床,休息 1 回合"})
|
||||
p.Skip = 1
|
||||
g.logf("P%d 抽到机会卡:感冒卧床,休息 1 回合", p.Seat)
|
||||
default:
|
||||
old := p.Pos
|
||||
p.Pos = (p.Pos + 3) % len(g.Tiles)
|
||||
if p.Pos < old {
|
||||
p.Money += GoBonus
|
||||
}
|
||||
t := g.Tiles[p.Pos]
|
||||
g.ev(Event{Kind: "chance", Seat: p.Seat, Text: fmt.Sprintf("搭上顺风车,前进 3 格到「%s」", t.Name)})
|
||||
g.ev(Event{Kind: "move", Seat: p.Seat, From: old, To: p.Pos})
|
||||
if p.Pos < old {
|
||||
g.ev(Event{Kind: "cash", Seat: p.Seat, Amount: GoBonus, Text: "经过起点奖励"})
|
||||
}
|
||||
g.logf("P%d 抽到机会卡:顺风车前进 3 格到「%s」", p.Seat, t.Name)
|
||||
// 简化:顺风车落点只结算租金/税,不再连环触发购买与机会
|
||||
if t.Type == TileProp && t.Owner != -1 && t.Owner != p.Seat {
|
||||
g.payTo(p, g.Players[t.Owner], g.rentOf(t), fmt.Sprintf("落在 P%d 的「%s」支付租金", t.Owner, t.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// payTo 支付:to 为 nil 表示交给银行;付不起即破产(剩余现金全部给债主)
|
||||
func (g *Game) payTo(from, to *Player, amount int, why string) {
|
||||
if amount > from.Money {
|
||||
// 破产:现金清给债主,名下地产全部释放
|
||||
if to != nil && from.Money > 0 {
|
||||
to.Money += from.Money
|
||||
g.ev(Event{Kind: "cash", Seat: to.Seat, Amount: from.Money, Text: "收到对方全部现金"})
|
||||
}
|
||||
from.Money = 0
|
||||
from.Out = true
|
||||
for _, t := range g.Tiles {
|
||||
if t.Owner == from.Seat {
|
||||
t.Owner = -1
|
||||
t.Level = 0
|
||||
}
|
||||
}
|
||||
g.ev(Event{Kind: "bankrupt", Seat: from.Seat, Amount: amount, Text: why})
|
||||
g.logf("P%d %s %d 元但现金不足,破产出局!名下地产全部拍卖", from.Seat, why, amount)
|
||||
g.checkLastStanding()
|
||||
return
|
||||
}
|
||||
from.Money -= amount
|
||||
g.ev(Event{Kind: "cash", Seat: from.Seat, Amount: -amount, Text: why})
|
||||
if to != nil {
|
||||
to.Money += amount
|
||||
g.ev(Event{Kind: "cash", Seat: to.Seat, Amount: amount, Text: "收到租金"})
|
||||
}
|
||||
g.logf("P%d %s %d 元", from.Seat, why, amount)
|
||||
}
|
||||
|
||||
// Buy 购买决策:buy=false 表示放弃
|
||||
func (g *Game) Buy(seat int, buy bool) error {
|
||||
if g.Phase != PhaseBuy {
|
||||
return fmt.Errorf("当前不在购买阶段")
|
||||
}
|
||||
if g.Turn != seat {
|
||||
return fmt.Errorf("还没轮到你决策")
|
||||
}
|
||||
t := g.Tiles[g.Pending]
|
||||
p := g.Players[seat]
|
||||
if buy && p.Money >= t.Price {
|
||||
p.Money -= t.Price
|
||||
t.Owner = seat
|
||||
g.ev(Event{Kind: "buy", Seat: seat, Tile: t.Idx, Text: t.Name})
|
||||
g.ev(Event{Kind: "cash", Seat: seat, Amount: -t.Price, Text: "购地支出"})
|
||||
if g.ownsFullGroup(seat, t.Group) {
|
||||
g.logf("P%d 花 %d 元买下「%s」,集齐同色地段租金翻倍!", seat, t.Price, t.Name)
|
||||
} else {
|
||||
g.logf("P%d 花 %d 元买下「%s」", seat, t.Price, t.Name)
|
||||
}
|
||||
} else {
|
||||
g.logf("P%d 放弃购买「%s」", seat, t.Name)
|
||||
}
|
||||
g.Pending = -1
|
||||
g.endTurn()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upgrade 建筑升级决策:up=false 表示放弃
|
||||
func (g *Game) Upgrade(seat int, up bool) error {
|
||||
if g.Phase != PhaseUpgrade {
|
||||
return fmt.Errorf("当前不在升级阶段")
|
||||
}
|
||||
if g.Turn != seat {
|
||||
return fmt.Errorf("还没轮到你决策")
|
||||
}
|
||||
t := g.Tiles[g.Pending]
|
||||
p := g.Players[seat]
|
||||
cost := UpgradeCost(t)
|
||||
if up && t.Level < MaxLevel && p.Money >= cost {
|
||||
p.Money -= cost
|
||||
t.Level++
|
||||
g.ev(Event{Kind: "upgrade", Seat: seat, Tile: t.Idx, Amount: t.Level, Text: t.Name})
|
||||
g.ev(Event{Kind: "cash", Seat: seat, Amount: -cost, Text: "建筑升级支出"})
|
||||
names := []string{"", "小屋", "楼房", "酒店"}
|
||||
g.logf("P%d 花 %d 元把「%s」升级为%s(租金翻倍)", seat, cost, t.Name, names[t.Level])
|
||||
} else {
|
||||
g.logf("P%d 暂不升级「%s」", seat, t.Name)
|
||||
}
|
||||
g.Pending = -1
|
||||
g.endTurn()
|
||||
return nil
|
||||
}
|
||||
|
||||
// endTurn 回合收尾:对局未结束时把行动权交给下一个未出局玩家
|
||||
// 注意:进入购买/升级阶段的路径在 resolveTile 中提前返回,不会走到这里
|
||||
func (g *Game) endTurn() {
|
||||
if g.Phase == PhaseOver {
|
||||
return
|
||||
}
|
||||
g.Phase = PhaseRoll
|
||||
g.advanceTurn()
|
||||
}
|
||||
|
||||
// advanceTurn 找到下一个可行动的玩家;处理休息回合与轮数上限
|
||||
func (g *Game) advanceTurn() {
|
||||
if g.checkLastStanding() {
|
||||
return
|
||||
}
|
||||
for i := 0; i < len(g.Players)*2; i++ {
|
||||
g.Turn = (g.Turn + 1) % len(g.Players)
|
||||
if g.Turn == 0 {
|
||||
g.Round++
|
||||
if g.Round > MaxRounds {
|
||||
g.finishByAssets()
|
||||
return
|
||||
}
|
||||
}
|
||||
p := g.Players[g.Turn]
|
||||
if p.Out {
|
||||
continue
|
||||
}
|
||||
if p.Skip > 0 {
|
||||
p.Skip--
|
||||
g.ev(Event{Kind: "rest", Seat: p.Seat})
|
||||
g.logf("P%d 在休息中,跳过本回合", p.Seat)
|
||||
continue
|
||||
}
|
||||
g.Phase = PhaseRoll
|
||||
return
|
||||
}
|
||||
// 理论上不可达(存在未出局玩家时必能找到),兜底按资产结算
|
||||
g.finishByAssets()
|
||||
}
|
||||
|
||||
// checkLastStanding 只剩一名玩家时立即结束
|
||||
func (g *Game) checkLastStanding() bool {
|
||||
if g.activeCount() > 1 {
|
||||
return false
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
if !p.Out {
|
||||
g.Winners = []int{p.Seat}
|
||||
}
|
||||
}
|
||||
g.Phase = PhaseOver
|
||||
g.logf("其余玩家全部破产,对局结束!")
|
||||
return true
|
||||
}
|
||||
|
||||
// finishByAssets 轮数打满:按总资产最高者获胜(并列全算赢)
|
||||
func (g *Game) finishByAssets() {
|
||||
best := -1
|
||||
for _, p := range g.Players {
|
||||
if p.Out {
|
||||
continue
|
||||
}
|
||||
if a := g.AssetsOf(p.Seat); a > best {
|
||||
best = a
|
||||
}
|
||||
}
|
||||
g.Winners = nil
|
||||
for _, p := range g.Players {
|
||||
if !p.Out && g.AssetsOf(p.Seat) == best {
|
||||
g.Winners = append(g.Winners, p.Seat)
|
||||
}
|
||||
}
|
||||
g.Phase = PhaseOver
|
||||
g.logf("%d 轮已满,按总资产结算胜负!", MaxRounds)
|
||||
}
|
||||
|
||||
// ForceFinish 提前按总资产结算(房间层在真人全部出局时调用,避免纯 AI 空跑)
|
||||
func (g *Game) ForceFinish() {
|
||||
if g.Phase != PhaseOver {
|
||||
g.finishByAssets()
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminate 玩家中途退出:视为破产出局,地产释放
|
||||
func (g *Game) Eliminate(seat int) {
|
||||
p := g.Players[seat]
|
||||
if p.Out {
|
||||
return
|
||||
}
|
||||
p.Out = true
|
||||
p.Money = 0
|
||||
for _, t := range g.Tiles {
|
||||
if t.Owner == seat {
|
||||
t.Owner = -1
|
||||
t.Level = 0
|
||||
}
|
||||
}
|
||||
// 正轮到退出者时把行动权交给下家
|
||||
if g.Phase != PhaseOver && g.Turn == seat {
|
||||
g.Pending = -1
|
||||
g.advanceTurn()
|
||||
} else {
|
||||
g.checkLastStanding()
|
||||
}
|
||||
}
|
||||
|
||||
// AIShouldUpgrade 规则 AI 升级决策:手里越宽裕越愿意加盖,高手集齐同色组必升
|
||||
func AIShouldUpgrade(g *Game, seat int, difficulty string) bool {
|
||||
t := g.Tiles[g.Pending]
|
||||
rest := g.Players[seat].Money - UpgradeCost(t)
|
||||
switch difficulty {
|
||||
case "easy":
|
||||
return rest >= 500 && rand.Float64() < 0.6
|
||||
case "hard":
|
||||
if g.ownsFullGroup(seat, t.Group) && rest >= 100 {
|
||||
return true
|
||||
}
|
||||
return rest >= 250
|
||||
default:
|
||||
return rest >= 350
|
||||
}
|
||||
}
|
||||
|
||||
// AIShouldBuy 规则 AI 购买决策:难度越高越会算账
|
||||
func AIShouldBuy(g *Game, seat int, difficulty string) bool {
|
||||
t := g.Tiles[g.Pending]
|
||||
p := g.Players[seat]
|
||||
rest := p.Money - t.Price
|
||||
switch difficulty {
|
||||
case "easy":
|
||||
// 新手:手里宽裕才买,还带点随缘
|
||||
return rest >= 400 && rand.Float64() < 0.7
|
||||
case "hard":
|
||||
// 高手:能凑齐同色组必买,普通地块留 150 备用金即可
|
||||
otherOwned := false
|
||||
for _, ot := range g.Tiles {
|
||||
if ot.Group == t.Group && ot.Idx != t.Idx && ot.Owner == seat {
|
||||
otherOwned = true
|
||||
}
|
||||
}
|
||||
if otherOwned && rest >= 0 {
|
||||
return true
|
||||
}
|
||||
return rest >= 150
|
||||
default:
|
||||
return rest >= 250
|
||||
}
|
||||
}
|
||||
137
internal/gamecore/monopoly/monopoly_test.go
Normal file
137
internal/gamecore/monopoly/monopoly_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// 大富翁核心自测:AI 自对弈跑完整局,验证流程收敛与状态一致性
|
||||
package monopoly
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestFullGame 四个规则 AI 互相对局 50 盘,必须都能正常结束
|
||||
func TestFullGame(t *testing.T) {
|
||||
for round := 0; round < 50; round++ {
|
||||
g := NewGame(4)
|
||||
for actions := 0; actions < 5000; actions++ {
|
||||
if g.Phase == PhaseOver {
|
||||
break
|
||||
}
|
||||
seat := g.Turn
|
||||
switch g.Phase {
|
||||
case PhaseRoll:
|
||||
if err := g.Roll(seat); err != nil {
|
||||
t.Fatalf("第 %d 盘掷骰失败: %v", round, err)
|
||||
}
|
||||
case PhaseBuy:
|
||||
if err := g.Buy(seat, AIShouldBuy(g, seat, "hard")); err != nil {
|
||||
t.Fatalf("第 %d 盘购买失败: %v", round, err)
|
||||
}
|
||||
case PhaseUpgrade:
|
||||
if err := g.Upgrade(seat, AIShouldUpgrade(g, seat, "hard")); err != nil {
|
||||
t.Fatalf("第 %d 盘升级失败: %v", round, err)
|
||||
}
|
||||
}
|
||||
g.DrainLog()
|
||||
}
|
||||
if g.Phase != PhaseOver {
|
||||
t.Fatalf("第 %d 盘 5000 步仍未结束(Round=%d)", round, g.Round)
|
||||
}
|
||||
if len(g.Winners) == 0 {
|
||||
t.Fatalf("第 %d 盘结束但没有获胜者", round)
|
||||
}
|
||||
// 校验:出局玩家不应持有地产
|
||||
for _, tile := range g.Tiles {
|
||||
if tile.Owner >= 0 && g.Players[tile.Owner].Out {
|
||||
t.Fatalf("第 %d 盘出局玩家仍持有地产 %s", round, tile.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvents 动画事件流:每次掷骰必产生 roll+move 事件且序列合法
|
||||
func TestEvents(t *testing.T) {
|
||||
g := NewGame(4)
|
||||
for actions := 0; actions < 200 && g.Phase != PhaseOver; actions++ {
|
||||
seat := g.Turn
|
||||
switch g.Phase {
|
||||
case PhaseRoll:
|
||||
g.Roll(seat)
|
||||
evs := g.DrainEvents()
|
||||
if len(evs) < 2 || evs[0].Kind != "roll" || evs[1].Kind != "move" {
|
||||
t.Fatalf("掷骰后事件序列非法: %+v", evs)
|
||||
}
|
||||
if evs[0].D1 < 1 || evs[0].D1 > 6 || evs[0].D2 < 1 || evs[0].D2 > 6 {
|
||||
t.Fatalf("roll 事件点数非法: %+v", evs[0])
|
||||
}
|
||||
// move 事件的 From/To 必须在棋盘范围内
|
||||
if evs[1].From < 0 || evs[1].From >= len(g.Tiles) || evs[1].To < 0 || evs[1].To >= len(g.Tiles) {
|
||||
t.Fatalf("move 事件坐标越界: %+v", evs[1])
|
||||
}
|
||||
case PhaseBuy:
|
||||
g.Buy(seat, true)
|
||||
evs := g.DrainEvents()
|
||||
if len(evs) < 2 || evs[0].Kind != "buy" || evs[1].Kind != "cash" || evs[1].Amount >= 0 {
|
||||
t.Fatalf("购买后事件序列非法: %+v", evs)
|
||||
}
|
||||
case PhaseUpgrade:
|
||||
g.Upgrade(seat, true)
|
||||
evs := g.DrainEvents()
|
||||
if len(evs) < 2 || evs[0].Kind != "upgrade" || evs[1].Kind != "cash" {
|
||||
t.Fatalf("升级后事件序列非法: %+v", evs)
|
||||
}
|
||||
}
|
||||
g.DrainLog()
|
||||
}
|
||||
// DrainEvents 之后必须清空
|
||||
if len(g.Events) != 0 {
|
||||
t.Fatalf("事件未被取走清空: %d 条", len(g.Events))
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade 建筑升级:扣费、等级提升、租金按等级翻倍
|
||||
func TestUpgrade(t *testing.T) {
|
||||
g := NewGame(2)
|
||||
tl := g.Tiles[1] // 北京 350
|
||||
tl.Owner = 0
|
||||
g.Phase = PhaseUpgrade
|
||||
g.Pending = 1
|
||||
g.Turn = 0
|
||||
moneyBefore := g.Players[0].Money
|
||||
if err := g.Upgrade(0, true); err != nil {
|
||||
t.Fatalf("升级失败: %v", err)
|
||||
}
|
||||
if tl.Level != 1 {
|
||||
t.Fatalf("等级应为 1,实际 %d", tl.Level)
|
||||
}
|
||||
if g.Players[0].Money != moneyBefore-UpgradeCost(tl) {
|
||||
t.Fatalf("升级扣费错误:%d → %d", moneyBefore, g.Players[0].Money)
|
||||
}
|
||||
// 租金 = 地价30% × 2^等级:350*30% = 105 → 1级 210
|
||||
if rent := g.rentOf(tl); rent != 210 {
|
||||
t.Fatalf("1 级租金应为 210,实际 %d", rent)
|
||||
}
|
||||
// 升满 3 级后不能再进入升级阶段
|
||||
tl.Level = MaxLevel
|
||||
if rent := g.rentOf(tl); rent != 105<<MaxLevel {
|
||||
t.Fatalf("满级租金应为 %d,实际 %d", 105<<MaxLevel, rent)
|
||||
}
|
||||
// 破产释放地产时等级清零
|
||||
g.Tiles[2].Owner = 0
|
||||
g.Tiles[2].Level = 2
|
||||
g.payTo(g.Players[0], nil, 999999, "测试破产")
|
||||
if g.Tiles[2].Owner != -1 || g.Tiles[2].Level != 0 {
|
||||
t.Fatalf("破产后地产应释放且等级清零:owner=%d level=%d", g.Tiles[2].Owner, g.Tiles[2].Level)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEliminate 中途退出后行动权正常流转
|
||||
func TestEliminate(t *testing.T) {
|
||||
g := NewGame(4)
|
||||
g.Eliminate(0)
|
||||
if g.Turn == 0 {
|
||||
t.Fatal("退出者仍持有行动权")
|
||||
}
|
||||
g.Eliminate(1)
|
||||
g.Eliminate(2)
|
||||
if g.Phase != PhaseOver {
|
||||
t.Fatal("只剩一人时对局应立即结束")
|
||||
}
|
||||
if len(g.Winners) != 1 || g.Winners[0] != 3 {
|
||||
t.Fatalf("获胜者应为 3 号,实际 %v", g.Winners)
|
||||
}
|
||||
}
|
||||
137
internal/gamecore/xiangqi/ai.go
Normal file
137
internal/gamecore/xiangqi/ai.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package xiangqi
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// pieceValues 兵种基础分值(评估函数用)
|
||||
var pieceValues = map[int8]int{
|
||||
King: 100000, Advisor: 200, Bishop: 200,
|
||||
Knight: 420, Rook: 900, Cannon: 460, Pawn: 100,
|
||||
}
|
||||
|
||||
// Eval 评估当前局面(红方视角:正数红优,负数黑优)
|
||||
// 物质分 + 少量位置分(兵过河、子力靠近中路)
|
||||
func (b *Board) Eval() int {
|
||||
score := 0
|
||||
for r := 0; r < 10; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
p := b[r][c]
|
||||
if p == 0 {
|
||||
continue
|
||||
}
|
||||
v := pieceValues[abs8(p)]
|
||||
// 过河兵大幅升值
|
||||
if abs8(p) == Pawn && crossedRiver(r, side(p)) {
|
||||
v += 120
|
||||
// 越深入敌阵越值钱
|
||||
if side(p) == Red {
|
||||
v += (4 - r) * 20
|
||||
} else {
|
||||
v += (r - 5) * 20
|
||||
}
|
||||
}
|
||||
// 车马炮靠近中路略有加分(简易位置评估)
|
||||
if abs8(p) == Rook || abs8(p) == Knight || abs8(p) == Cannon {
|
||||
center := c
|
||||
if center > 4 {
|
||||
center = 8 - center
|
||||
}
|
||||
v += center * 4
|
||||
}
|
||||
if p > 0 {
|
||||
score += v
|
||||
} else {
|
||||
score -= v
|
||||
}
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// orderMoves 着法排序:吃子优先(提高剪枝效率)
|
||||
func (b *Board) orderMoves(moves []Move) {
|
||||
sort.SliceStable(moves, func(i, j int) bool {
|
||||
vi := pieceValues[abs8(b[moves[i].ToR][moves[i].ToC])]
|
||||
vj := pieceValues[abs8(b[moves[j].ToR][moves[j].ToC])]
|
||||
return vi > vj
|
||||
})
|
||||
}
|
||||
|
||||
// alphaBeta 极大极小搜索 + Alpha-Beta 剪枝,返回 s 方视角的最优分
|
||||
func (b *Board) alphaBeta(s, depth, alpha, beta int) int {
|
||||
if depth == 0 {
|
||||
return b.Eval() * s // 转成当前方视角
|
||||
}
|
||||
moves := b.LegalMoves(s)
|
||||
if len(moves) == 0 {
|
||||
return -90000 - depth // 无子可动=被将死,越早被将死分越低
|
||||
}
|
||||
b.orderMoves(moves)
|
||||
for _, m := range moves {
|
||||
captured := b.Apply(m)
|
||||
score := -b.alphaBeta(-s, depth-1, -beta, -alpha)
|
||||
b.Undo(m, captured)
|
||||
if score >= beta {
|
||||
return beta
|
||||
}
|
||||
if score > alpha {
|
||||
alpha = score
|
||||
}
|
||||
}
|
||||
return alpha
|
||||
}
|
||||
|
||||
// BestMove 搜索指定深度的最优着法(depth 1-3,越深越强)
|
||||
// 返回着法与该着法的评估分
|
||||
func (b *Board) BestMove(s, depth int) (Move, int) {
|
||||
moves := b.LegalMoves(s)
|
||||
if len(moves) == 0 {
|
||||
return Move{}, -100000
|
||||
}
|
||||
b.orderMoves(moves)
|
||||
best := moves[0]
|
||||
bestScore := -1000000
|
||||
for _, m := range moves {
|
||||
captured := b.Apply(m)
|
||||
score := -b.alphaBeta(-s, depth-1, -1000000, 1000000)
|
||||
b.Undo(m, captured)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = m
|
||||
}
|
||||
}
|
||||
return best, bestScore
|
||||
}
|
||||
|
||||
// RankedMoves 按评估分从高到低返回全部合法着法(LLM 候选着法用)
|
||||
func (b *Board) RankedMoves(s, depth int) []Move {
|
||||
moves := b.LegalMoves(s)
|
||||
type scored struct {
|
||||
m Move
|
||||
v int
|
||||
}
|
||||
list := make([]scored, 0, len(moves))
|
||||
for _, m := range moves {
|
||||
captured := b.Apply(m)
|
||||
v := -b.alphaBeta(-s, depth-1, -1000000, 1000000)
|
||||
b.Undo(m, captured)
|
||||
list = append(list, scored{m, v})
|
||||
}
|
||||
sort.SliceStable(list, func(i, j int) bool { return list[i].v > list[j].v })
|
||||
result := make([]Move, len(list))
|
||||
for i, it := range list {
|
||||
result[i] = it.m
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RandomMove 随机合法着法(简单难度的扰动用)
|
||||
func (b *Board) RandomMove(s int) (Move, bool) {
|
||||
moves := b.LegalMoves(s)
|
||||
if len(moves) == 0 {
|
||||
return Move{}, false
|
||||
}
|
||||
return moves[rand.Intn(len(moves))], true
|
||||
}
|
||||
152
internal/gamecore/xiangqi/board.go
Normal file
152
internal/gamecore/xiangqi/board.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Package xiangqi 实现中国象棋的完整规则:走子合法性、将军/将死判定与极大极小搜索 AI
|
||||
package xiangqi
|
||||
|
||||
// 棋盘约定:10 行 x 9 列,row 0 为黑方底线(上方),row 9 为红方底线(下方)
|
||||
// 棋子编码 int8:正数=红方,负数=黑方,绝对值代表兵种:
|
||||
// 1=帅/将 2=仕/士 3=相/象 4=马 5=车 6=炮 7=兵/卒
|
||||
const (
|
||||
King = 1 // 帅/将
|
||||
Advisor = 2 // 仕/士
|
||||
Bishop = 3 // 相/象
|
||||
Knight = 4 // 马
|
||||
Rook = 5 // 车
|
||||
Cannon = 6 // 炮
|
||||
Pawn = 7 // 兵/卒
|
||||
)
|
||||
|
||||
// 红黑双方标识
|
||||
const (
|
||||
Red = 1 // 红方(下方,先手)
|
||||
Black = -1 // 黑方(上方)
|
||||
)
|
||||
|
||||
// Board 棋盘:[行][列],0 表示空位
|
||||
type Board [10][9]int8
|
||||
|
||||
// pieceNames 棋子中文名:[兵种][0]=红名 [1]=黑名
|
||||
var pieceNames = map[int][2]string{
|
||||
King: {"帅", "将"}, Advisor: {"仕", "士"}, Bishop: {"相", "象"},
|
||||
Knight: {"马", "马"}, Rook: {"车", "车"}, Cannon: {"炮", "炮"}, Pawn: {"兵", "卒"},
|
||||
}
|
||||
|
||||
// PieceName 棋子中文名(按红黑区分)
|
||||
func PieceName(p int8) string {
|
||||
if p == 0 {
|
||||
return ""
|
||||
}
|
||||
kind := int(p)
|
||||
idx := 0
|
||||
if p < 0 {
|
||||
kind = -kind
|
||||
idx = 1
|
||||
}
|
||||
return pieceNames[kind][idx]
|
||||
}
|
||||
|
||||
// Initial 初始棋盘布局(红下黑上)
|
||||
func Initial() Board {
|
||||
var b Board
|
||||
// 黑方(上方,负数)
|
||||
back := []int8{Rook, Knight, Bishop, Advisor, King, Advisor, Bishop, Knight, Rook}
|
||||
for c, p := range back {
|
||||
b[0][c] = -p
|
||||
}
|
||||
b[2][1], b[2][7] = -Cannon, -Cannon
|
||||
for c := 0; c < 9; c += 2 {
|
||||
b[3][c] = -Pawn
|
||||
}
|
||||
// 红方(下方,正数)
|
||||
for c, p := range back {
|
||||
b[9][c] = p
|
||||
}
|
||||
b[7][1], b[7][7] = Cannon, Cannon
|
||||
for c := 0; c < 9; c += 2 {
|
||||
b[6][c] = Pawn
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Move 一步着法:从 (FromR,FromC) 到 (ToR,ToC)
|
||||
type Move struct {
|
||||
FromR int `json:"from_r"` // 起点行
|
||||
FromC int `json:"from_c"` // 起点列
|
||||
ToR int `json:"to_r"` // 终点行
|
||||
ToC int `json:"to_c"` // 终点列
|
||||
}
|
||||
|
||||
// side 返回棋子所属方(1红 -1黑 0空)
|
||||
func side(p int8) int {
|
||||
if p > 0 {
|
||||
return Red
|
||||
}
|
||||
if p < 0 {
|
||||
return Black
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Apply 执行着法并返回被吃的棋子(调用方需自行保证合法性)
|
||||
func (b *Board) Apply(m Move) int8 {
|
||||
captured := b[m.ToR][m.ToC]
|
||||
b[m.ToR][m.ToC] = b[m.FromR][m.FromC]
|
||||
b[m.FromR][m.FromC] = 0
|
||||
return captured
|
||||
}
|
||||
|
||||
// Undo 撤销着法(配合 Apply 的返回值恢复现场,搜索用)
|
||||
func (b *Board) Undo(m Move, captured int8) {
|
||||
b[m.FromR][m.FromC] = b[m.ToR][m.ToC]
|
||||
b[m.ToR][m.ToC] = captured
|
||||
}
|
||||
|
||||
// findKing 找到指定方的帅/将位置
|
||||
func (b *Board) findKing(s int) (int, int) {
|
||||
for r := 0; r < 10; r++ {
|
||||
for c := 3; c <= 5; c++ {
|
||||
if int(b[r][c])*s > 0 && abs8(b[r][c]) == King {
|
||||
return r, c
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// abs8 int8 绝对值
|
||||
func abs8(v int8) int8 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// inBoard 坐标是否在棋盘内
|
||||
func inBoard(r, c int) bool { return r >= 0 && r < 10 && c >= 0 && c < 9 }
|
||||
|
||||
// inPalace 坐标是否在指定方的九宫内
|
||||
func inPalace(r, c, s int) bool {
|
||||
if c < 3 || c > 5 {
|
||||
return false
|
||||
}
|
||||
if s == Red {
|
||||
return r >= 7 && r <= 9
|
||||
}
|
||||
return r >= 0 && r <= 2
|
||||
}
|
||||
|
||||
// crossedRiver 兵卒是否已过河
|
||||
func crossedRiver(r, s int) bool {
|
||||
if s == Red {
|
||||
return r <= 4
|
||||
}
|
||||
return r >= 5
|
||||
}
|
||||
|
||||
// MoveDesc 着法的中文描述(如 "红车 (9,0)→(5,0)",用于聊天与记录展示)
|
||||
func (b *Board) MoveDesc(m Move) string {
|
||||
p := b[m.FromR][m.FromC]
|
||||
color := "红"
|
||||
if p < 0 {
|
||||
color = "黑"
|
||||
}
|
||||
return color + PieceName(p)
|
||||
}
|
||||
185
internal/gamecore/xiangqi/rules.go
Normal file
185
internal/gamecore/xiangqi/rules.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package xiangqi
|
||||
|
||||
// pseudoMoves 生成指定方的伪合法着法(不考虑送将与对脸,由 LegalMoves 过滤)
|
||||
func (b *Board) pseudoMoves(s int) []Move {
|
||||
moves := make([]Move, 0, 48)
|
||||
// tryAdd 目标格为空或敌子时加入候选
|
||||
tryAdd := func(fr, fc, tr, tc int) {
|
||||
if !inBoard(tr, tc) {
|
||||
return
|
||||
}
|
||||
if side(b[tr][tc]) == s {
|
||||
return
|
||||
}
|
||||
moves = append(moves, Move{fr, fc, tr, tc})
|
||||
}
|
||||
for r := 0; r < 10; r++ {
|
||||
for c := 0; c < 9; c++ {
|
||||
p := b[r][c]
|
||||
if side(p) != s {
|
||||
continue
|
||||
}
|
||||
switch abs8(p) {
|
||||
case King:
|
||||
// 帅/将:九宫内横竖走一步
|
||||
for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
|
||||
tr, tc := r+d[0], c+d[1]
|
||||
if inPalace(tr, tc, s) {
|
||||
tryAdd(r, c, tr, tc)
|
||||
}
|
||||
}
|
||||
case Advisor:
|
||||
// 仕/士:九宫内斜走一步
|
||||
for _, d := range [][2]int{{1, 1}, {1, -1}, {-1, 1}, {-1, -1}} {
|
||||
tr, tc := r+d[0], c+d[1]
|
||||
if inPalace(tr, tc, s) {
|
||||
tryAdd(r, c, tr, tc)
|
||||
}
|
||||
}
|
||||
case Bishop:
|
||||
// 相/象:田字走,塞象眼判定,不能过河
|
||||
for _, d := range [][2]int{{2, 2}, {2, -2}, {-2, 2}, {-2, -2}} {
|
||||
tr, tc := r+d[0], c+d[1]
|
||||
er, ec := r+d[0]/2, c+d[1]/2
|
||||
if !inBoard(tr, tc) || b[er][ec] != 0 {
|
||||
continue
|
||||
}
|
||||
// 不能过河:红象只能在 5-9 行,黑象只能在 0-4 行
|
||||
if (s == Red && tr < 5) || (s == Black && tr > 4) {
|
||||
continue
|
||||
}
|
||||
tryAdd(r, c, tr, tc)
|
||||
}
|
||||
case Knight:
|
||||
// 马:日字走,蹩马腿判定(马腿在直线相邻位置)
|
||||
knightSteps := [][4]int{
|
||||
{-2, -1, -1, 0}, {-2, 1, -1, 0}, {2, -1, 1, 0}, {2, 1, 1, 0},
|
||||
{-1, -2, 0, -1}, {1, -2, 0, -1}, {-1, 2, 0, 1}, {1, 2, 0, 1},
|
||||
}
|
||||
for _, st := range knightSteps {
|
||||
tr, tc := r+st[0], c+st[1]
|
||||
lr, lc := r+st[2], c+st[3]
|
||||
if inBoard(tr, tc) && inBoard(lr, lc) && b[lr][lc] == 0 {
|
||||
tryAdd(r, c, tr, tc)
|
||||
}
|
||||
}
|
||||
case Rook:
|
||||
// 车:直线滑行,遇子停止(敌子可吃)
|
||||
for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
|
||||
tr, tc := r+d[0], c+d[1]
|
||||
for inBoard(tr, tc) {
|
||||
if b[tr][tc] == 0 {
|
||||
tryAdd(r, c, tr, tc)
|
||||
} else {
|
||||
tryAdd(r, c, tr, tc)
|
||||
break
|
||||
}
|
||||
tr += d[0]
|
||||
tc += d[1]
|
||||
}
|
||||
}
|
||||
case Cannon:
|
||||
// 炮:平移同车(不吃子),吃子需隔一个炮架
|
||||
for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
|
||||
tr, tc := r+d[0], c+d[1]
|
||||
jumped := false
|
||||
for inBoard(tr, tc) {
|
||||
if !jumped {
|
||||
if b[tr][tc] == 0 {
|
||||
tryAdd(r, c, tr, tc)
|
||||
} else {
|
||||
jumped = true // 遇到炮架,之后找吃子目标
|
||||
}
|
||||
} else {
|
||||
if b[tr][tc] != 0 {
|
||||
if side(b[tr][tc]) != s {
|
||||
tryAdd(r, c, tr, tc)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
tr += d[0]
|
||||
tc += d[1]
|
||||
}
|
||||
}
|
||||
case Pawn:
|
||||
// 兵/卒:向前一步,过河后可左右
|
||||
forward := -1
|
||||
if s == Black {
|
||||
forward = 1
|
||||
}
|
||||
tryAdd(r, c, r+forward, c)
|
||||
if crossedRiver(r, s) {
|
||||
tryAdd(r, c, r, c-1)
|
||||
tryAdd(r, c, r, c+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
// kingsFacing 判断双方帅将是否对脸(同列且中间无子,规则禁止)
|
||||
func (b *Board) kingsFacing() bool {
|
||||
rr, rc := b.findKing(Red)
|
||||
br, bc := b.findKing(Black)
|
||||
if rr < 0 || br < 0 || rc != bc {
|
||||
return false
|
||||
}
|
||||
for r := br + 1; r < rr; r++ {
|
||||
if b[r][rc] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// InCheck 判断指定方是否被将军(敌方任一伪着法可吃到帅/将)
|
||||
func (b *Board) InCheck(s int) bool {
|
||||
kr, kc := b.findKing(s)
|
||||
if kr < 0 {
|
||||
return true // 帅已不在(理论不会发生)视为被将
|
||||
}
|
||||
for _, m := range b.pseudoMoves(-s) {
|
||||
if m.ToR == kr && m.ToC == kc {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LegalMoves 生成指定方的所有合法着法(过滤走后被将军与帅将对脸)
|
||||
func (b *Board) LegalMoves(s int) []Move {
|
||||
pseudo := b.pseudoMoves(s)
|
||||
legal := make([]Move, 0, len(pseudo))
|
||||
for _, m := range pseudo {
|
||||
captured := b.Apply(m)
|
||||
ok := !b.InCheck(s) && !b.kingsFacing()
|
||||
b.Undo(m, captured)
|
||||
if ok {
|
||||
legal = append(legal, m)
|
||||
}
|
||||
}
|
||||
return legal
|
||||
}
|
||||
|
||||
// IsLegal 校验一步着法是否合法(联机走子校验入口)
|
||||
func (b *Board) IsLegal(m Move, s int) bool {
|
||||
if !inBoard(m.FromR, m.FromC) || !inBoard(m.ToR, m.ToC) {
|
||||
return false
|
||||
}
|
||||
if side(b[m.FromR][m.FromC]) != s {
|
||||
return false
|
||||
}
|
||||
for _, lm := range b.LegalMoves(s) {
|
||||
if lm == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GameOver 判断走子方是否已无合法着法(被将死或困毙,即负)
|
||||
func (b *Board) GameOver(sideToMove int) bool {
|
||||
return len(b.LegalMoves(sideToMove)) == 0
|
||||
}
|
||||
372
internal/handler/admin.go
Normal file
372
internal/handler/admin.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// AdminStats 数据概览:用户/游戏/订单总量与今日动态
|
||||
func AdminStats(c *gin.Context) {
|
||||
var userCount, gameCount, orderCount, todayPlays, todaySigns int64
|
||||
var todayPoints int
|
||||
todayStart := time.Now().Truncate(24 * time.Hour).Unix()
|
||||
// 各类总量统计
|
||||
database.DB.Model(&model.User{}).Count(&userCount)
|
||||
database.DB.Model(&model.Game{}).Where("status = 1").Count(&gameCount)
|
||||
database.DB.Model(&model.Order{}).Count(&orderCount)
|
||||
database.DB.Model(&model.GameRecord{}).Where("created_at >= ?", todayStart).Count(&todayPlays)
|
||||
database.DB.Model(&model.SignIn{}).Where("created_at >= ?", todayStart).Count(&todaySigns)
|
||||
// 今日发放积分(只统计正向变动)
|
||||
database.DB.Model(&model.PointRecord{}).
|
||||
Where("created_at >= ? AND change_points > 0", todayStart).
|
||||
Select("COALESCE(SUM(change_points),0)").Scan(&todayPoints)
|
||||
resp.OK(c, gin.H{
|
||||
"user_count": userCount, // 用户总数
|
||||
"game_count": gameCount, // 上架游戏数
|
||||
"order_count": orderCount, // 订单总数
|
||||
"today_plays": todayPlays, // 今日游玩次数
|
||||
"today_signs": todaySigns, // 今日签到人数
|
||||
"today_points": todayPoints, // 今日发放积分
|
||||
})
|
||||
}
|
||||
|
||||
// AdminThemes 主题列表 + 当前启用主题
|
||||
func AdminThemes(c *gin.Context) {
|
||||
var themes []model.Theme
|
||||
database.DB.Where("status = 1").Order("sort").Find(&themes)
|
||||
resp.OK(c, gin.H{
|
||||
"list": themes,
|
||||
"active": service.GetConfig(model.ConfKeyActiveTheme, "arcade"),
|
||||
})
|
||||
}
|
||||
|
||||
// switchThemeReq 切换主题请求
|
||||
type switchThemeReq struct {
|
||||
Code string `json:"code" binding:"required"` // 目标主题编码
|
||||
}
|
||||
|
||||
// AdminSwitchTheme 切换全站主题(写 site_configs.active_theme,前台下次拉取生效)
|
||||
func AdminSwitchTheme(c *gin.Context) {
|
||||
var req switchThemeReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
var theme model.Theme
|
||||
if err := database.DB.Where("code = ? AND status = 1", req.Code).First(&theme).Error; err != nil {
|
||||
resp.Fail(c, "主题不存在")
|
||||
return
|
||||
}
|
||||
if err := service.SetConfig(model.ConfKeyActiveTheme, theme.Code); err != nil {
|
||||
resp.Fail(c, "切换失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"active": theme.Code})
|
||||
}
|
||||
|
||||
// updateSiteReq 站点信息更新请求
|
||||
type updateSiteReq struct {
|
||||
SiteName string `json:"site_name"` // 站点名称(空=不修改)
|
||||
Announcement string `json:"announcement"` // 公告内容(空=不修改)
|
||||
CarouselStyle string `json:"carousel_style"` // 大厅轮播效果:slide/fade/coverflow/cards(空=不修改)
|
||||
CarouselInterval int `json:"carousel_interval"` // 自动轮播间隔秒(0=不修改,限 2~30)
|
||||
}
|
||||
|
||||
// 允许的轮播效果集合(与前端 GameCarousel 组件实现一一对应)
|
||||
var carouselStyles = map[string]bool{"slide": true, "fade": true, "coverflow": true, "cards": true}
|
||||
|
||||
// AdminUpdateSite 更新站点配置
|
||||
func AdminUpdateSite(c *gin.Context) {
|
||||
var req updateSiteReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if req.SiteName != "" {
|
||||
service.SetConfig(model.ConfKeySiteName, req.SiteName)
|
||||
}
|
||||
if req.Announcement != "" {
|
||||
service.SetConfig(model.ConfKeyAnnouncement, req.Announcement)
|
||||
}
|
||||
if req.CarouselStyle != "" {
|
||||
if !carouselStyles[req.CarouselStyle] {
|
||||
resp.Fail(c, "不支持的轮播效果")
|
||||
return
|
||||
}
|
||||
service.SetConfig(model.ConfKeyCarouselStyle, req.CarouselStyle)
|
||||
}
|
||||
if req.CarouselInterval > 0 {
|
||||
if req.CarouselInterval < 2 || req.CarouselInterval > 30 {
|
||||
resp.Fail(c, "轮播间隔需在 2~30 秒之间")
|
||||
return
|
||||
}
|
||||
service.SetConfig(model.ConfKeyCarouselInterval, strconv.Itoa(req.CarouselInterval))
|
||||
}
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// AdminUsers 用户列表(支持用户名/昵称关键词搜索、账号状态筛选,分页)
|
||||
func AdminUsers(c *gin.Context) {
|
||||
page, size, offset := pageParams(c)
|
||||
keyword := c.Query("keyword")
|
||||
status, _ := strconv.Atoi(c.Query("status")) // 0=全部 1=正常 2=封禁
|
||||
query := database.DB.Model(&model.User{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("username LIKE ? OR nickname LIKE ?", like, like)
|
||||
}
|
||||
if status == model.UserStatusNormal || status == model.UserStatusBanned {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
list := make([]model.User, 0)
|
||||
query.Count(&total)
|
||||
query.Order("id DESC").Limit(size).Offset(offset).Find(&list)
|
||||
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// AdminUserPoints 指定用户的积分流水(后台调积分弹窗展示,分页)
|
||||
func AdminUserPoints(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
page, size, offset := pageParams(c)
|
||||
var total int64
|
||||
list := make([]model.PointRecord, 0)
|
||||
database.DB.Model(&model.PointRecord{}).Where("user_id = ?", id).Count(&total)
|
||||
database.DB.Where("user_id = ?", id).Order("id DESC").Limit(size).Offset(offset).Find(&list)
|
||||
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// adminUpdateUserReq 用户更新请求
|
||||
type adminUpdateUserReq struct {
|
||||
Status int `json:"status"` // 账号状态:1正常 2封禁(0=不修改)
|
||||
PointsOp string `json:"points_op"` // 积分操作:recharge充值 / deduct扣除 / set调整为(空=不操作)
|
||||
PointsValue int `json:"points_value"` // 操作数值:充值/扣除为变动量(>0),调整为目标余额(>=0)
|
||||
PointsAdjust int `json:"points_adjust"` // 兼容旧版:正加负减(0=不操作,新版前端不再使用)
|
||||
Remark string `json:"remark"` // 流水备注
|
||||
VipLevel *int `json:"vip_level"` // 目标VIP等级(0=取消VIP,1~5;nil=不修改)
|
||||
VipAddDays int `json:"vip_add_days"` // VIP有效期增减天数(0=不修改)
|
||||
}
|
||||
|
||||
// AdminUpdateUser 封禁/解封、积分充值/扣除/调整、调整VIP
|
||||
func AdminUpdateUser(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var req adminUpdateUserReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
resp.Fail(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
// 修改账号状态
|
||||
if req.Status == model.UserStatusNormal || req.Status == model.UserStatusBanned {
|
||||
if user.Role == model.RoleAdmin && req.Status == model.UserStatusBanned {
|
||||
resp.Fail(c, "不能封禁管理员")
|
||||
return
|
||||
}
|
||||
database.DB.Model(&user).Update("status", req.Status)
|
||||
}
|
||||
// 调整VIP等级/有效期
|
||||
if req.VipLevel != nil || req.VipAddDays != 0 {
|
||||
now := time.Now().Unix()
|
||||
newLevel := user.VipLevel
|
||||
newExpire := user.VipExpire
|
||||
if req.VipLevel != nil {
|
||||
if *req.VipLevel < 0 || *req.VipLevel > 5 {
|
||||
resp.Fail(c, "VIP等级需在 0~5 之间")
|
||||
return
|
||||
}
|
||||
newLevel = *req.VipLevel
|
||||
if newLevel == 0 {
|
||||
// 取消VIP:到期时间一并清零
|
||||
newExpire = 0
|
||||
} else if newExpire <= now {
|
||||
// 从无到有或已过期:默认给 30 天
|
||||
newExpire = now + 30*86400
|
||||
}
|
||||
}
|
||||
if req.VipAddDays != 0 {
|
||||
if newLevel == 0 {
|
||||
resp.Fail(c, "请先设置VIP等级再调整天数")
|
||||
return
|
||||
}
|
||||
base := newExpire
|
||||
if base < now {
|
||||
base = now
|
||||
}
|
||||
newExpire = base + int64(req.VipAddDays)*86400
|
||||
if newExpire <= now {
|
||||
// 扣到过期以前:直接取消VIP
|
||||
newLevel, newExpire = 0, 0
|
||||
}
|
||||
}
|
||||
database.DB.Model(&user).Updates(map[string]any{"vip_level": newLevel, "vip_expire": newExpire})
|
||||
}
|
||||
// 积分操作:充值 / 扣除 / 调整为目标值(走统一积分服务,自动写流水)
|
||||
if req.PointsOp != "" {
|
||||
change := 0
|
||||
defRemark := ""
|
||||
switch req.PointsOp {
|
||||
case "recharge": // 充值:加分
|
||||
if req.PointsValue <= 0 {
|
||||
resp.Fail(c, "充值数量需大于 0")
|
||||
return
|
||||
}
|
||||
change = req.PointsValue
|
||||
defRemark = "后台充值"
|
||||
case "deduct": // 扣除:减分(余额不足会被积分服务拦下)
|
||||
if req.PointsValue <= 0 {
|
||||
resp.Fail(c, "扣除数量需大于 0")
|
||||
return
|
||||
}
|
||||
change = -req.PointsValue
|
||||
defRemark = "后台扣除"
|
||||
case "set": // 调整为:直接设定目标余额,按差额记流水
|
||||
if req.PointsValue < 0 {
|
||||
resp.Fail(c, "目标积分不能为负数")
|
||||
return
|
||||
}
|
||||
change = req.PointsValue - user.Points
|
||||
defRemark = fmt.Sprintf("后台调整为 %d", req.PointsValue)
|
||||
default:
|
||||
resp.Fail(c, "未知的积分操作类型")
|
||||
return
|
||||
}
|
||||
if change != 0 {
|
||||
remark := req.Remark
|
||||
if remark == "" {
|
||||
remark = defRemark
|
||||
}
|
||||
if _, err := service.ChangePoints(nil, user.ID, change, model.PointTypeAdmin, 0, remark); err != nil {
|
||||
resp.Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if req.PointsAdjust != 0 {
|
||||
// 兼容旧版协议:正加负减
|
||||
remark := req.Remark
|
||||
if remark == "" {
|
||||
remark = "后台手动调整"
|
||||
}
|
||||
if _, err := service.ChangePoints(nil, user.ID, req.PointsAdjust, model.PointTypeAdmin, 0, remark); err != nil {
|
||||
resp.Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// AdminGames 游戏列表(含下架游戏)
|
||||
func AdminGames(c *gin.Context) {
|
||||
list := make([]model.Game, 0)
|
||||
database.DB.Order("sort").Find(&list)
|
||||
resp.OK(c, list)
|
||||
}
|
||||
|
||||
// adminUpdateGameReq 游戏更新请求
|
||||
type adminUpdateGameReq struct {
|
||||
Price *int `json:"price"` // 价格(nil=不修改)
|
||||
Status *int `json:"status"` // 状态:1上架 2下架(nil=不修改)
|
||||
Sort *int `json:"sort"` // 排序值(nil=不修改)
|
||||
ScorePerPoint *int `json:"score_per_point"` // 得分换算:多少分换1积分(0=不产积分;nil=不修改)
|
||||
MaxPointsPerPlay *int `json:"max_points_per_play"` // 单局最多可得积分(nil=不修改)
|
||||
Carousel *int `json:"carousel"` // 大厅轮播:0=不参与,>0=参与并按值升序(nil=不修改)
|
||||
}
|
||||
|
||||
// AdminUpdateGame 修改游戏价格/上下架/排序/积分规则
|
||||
func AdminUpdateGame(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var req adminUpdateGameReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.Price != nil && *req.Price >= 0 {
|
||||
updates["price"] = *req.Price
|
||||
}
|
||||
if req.Status != nil && (*req.Status == 1 || *req.Status == 2) {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if req.Sort != nil {
|
||||
updates["sort"] = *req.Sort
|
||||
}
|
||||
if req.ScorePerPoint != nil && *req.ScorePerPoint >= 0 {
|
||||
updates["score_per_point"] = *req.ScorePerPoint
|
||||
}
|
||||
if req.MaxPointsPerPlay != nil && *req.MaxPointsPerPlay >= 0 {
|
||||
updates["max_points_per_play"] = *req.MaxPointsPerPlay
|
||||
}
|
||||
if req.Carousel != nil && *req.Carousel >= 0 {
|
||||
updates["carousel"] = *req.Carousel
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
resp.Fail(c, "没有可更新的字段")
|
||||
return
|
||||
}
|
||||
if err := database.DB.Model(&model.Game{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
resp.Fail(c, "保存失败,请重试")
|
||||
return
|
||||
}
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// adminOrderItem 订单列表项(附下单用户信息)
|
||||
type adminOrderItem struct {
|
||||
model.Order
|
||||
Username string `json:"username"` // 用户账号
|
||||
Nickname string `json:"nickname"` // 用户昵称
|
||||
}
|
||||
|
||||
// adminOrderQuery 构造订单查询(列表、计数与合计共用同一组筛选条件)
|
||||
// rangeKey 时间范围:today=今天 / 7d=近7天 / 30d=近30天 / 空=全部
|
||||
func adminOrderQuery(orderNo, userKw, rangeKey string) *gorm.DB {
|
||||
q := database.DB.Table("orders o").
|
||||
Joins("LEFT JOIN users u ON u.id = o.user_id").
|
||||
Where("o.deleted_at = 0")
|
||||
if orderNo != "" {
|
||||
q = q.Where("o.order_no LIKE ?", "%"+orderNo+"%")
|
||||
}
|
||||
if userKw != "" {
|
||||
q = q.Where("u.username LIKE ?", "%"+userKw+"%")
|
||||
}
|
||||
now := time.Now()
|
||||
switch rangeKey {
|
||||
case "today":
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
q = q.Where("o.created_at >= ?", day.Unix())
|
||||
case "7d":
|
||||
q = q.Where("o.created_at >= ?", now.AddDate(0, 0, -7).Unix())
|
||||
case "30d":
|
||||
q = q.Where("o.created_at >= ?", now.AddDate(0, 0, -30).Unix())
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// AdminOrders 全站订单列表(支持按订单号、用户账号搜索与时间范围筛选,分页,返回筛选结果的积分合计)
|
||||
func AdminOrders(c *gin.Context) {
|
||||
page, size, offset := pageParams(c)
|
||||
orderNo := c.Query("order_no")
|
||||
userKw := c.Query("user")
|
||||
rangeKey := c.Query("range")
|
||||
var total int64
|
||||
var sum int64
|
||||
list := make([]adminOrderItem, 0)
|
||||
adminOrderQuery(orderNo, userKw, rangeKey).Count(&total)
|
||||
adminOrderQuery(orderNo, userKw, rangeKey).Select("COALESCE(SUM(o.total_points),0)").Scan(&sum)
|
||||
adminOrderQuery(orderNo, userKw, rangeKey).
|
||||
Select("o.*, u.username, u.nickname").
|
||||
Order("o.id DESC").Limit(size).Offset(offset).Scan(&list)
|
||||
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size, "sum_points": sum})
|
||||
}
|
||||
147
internal/handler/aiconf.go
Normal file
147
internal/handler/aiconf.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/ai"
|
||||
"nl-game-api-gin/internal/config"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// aiProviderConf 单个提供方的当前生效配置(后台展示用)
|
||||
type aiProviderConf struct {
|
||||
APIKey string `json:"api_key"` // 生效中的 API Key(仅超管可见)
|
||||
BaseURL string `json:"base_url"` // 生效中的接口地址
|
||||
Model string `json:"model"` // 生效中的模型名
|
||||
KeySource string `json:"key_source"` // Key 来源:后台配置/配置文件/未配置
|
||||
}
|
||||
|
||||
// effectiveAIConf 计算某提供方的生效配置:数据库非空项覆盖 config.yaml
|
||||
func effectiveAIConf(provider string) aiProviderConf {
|
||||
var base config.LLMConf
|
||||
var kKey, kBase, kModel string
|
||||
if provider == ai.ProviderSpark {
|
||||
base, kKey, kBase, kModel = config.C.AI.Spark, model.ConfKeyAISparkKey, model.ConfKeyAISparkBase, model.ConfKeyAISparkModel
|
||||
} else {
|
||||
base, kKey, kBase, kModel = config.C.AI.DeepSeek, model.ConfKeyAIDeepSeekKey, model.ConfKeyAIDeepSeekBase, model.ConfKeyAIDeepSeekModel
|
||||
}
|
||||
out := aiProviderConf{APIKey: base.APIKey, BaseURL: base.BaseURL, Model: base.Model, KeySource: "配置文件"}
|
||||
if v := service.GetConfig(kKey, ""); v != "" {
|
||||
out.APIKey, out.KeySource = v, "后台配置"
|
||||
}
|
||||
if v := service.GetConfig(kBase, ""); v != "" {
|
||||
out.BaseURL = v
|
||||
}
|
||||
if v := service.GetConfig(kModel, ""); v != "" {
|
||||
out.Model = v
|
||||
}
|
||||
if out.APIKey == "" {
|
||||
out.KeySource = "未配置"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminAIConfig 后台:查看当前全站AI模型选择、两家大模型的生效配置与获取指引
|
||||
func AdminAIConfig(c *gin.Context) {
|
||||
resp.OK(c, gin.H{
|
||||
"provider": ai.GlobalProvider(), // 全站统一的对战AI提供方(玩家前台只选难度)
|
||||
"spark": effectiveAIConf(ai.ProviderSpark),
|
||||
"deepseek": effectiveAIConf(ai.ProviderDeepSeek),
|
||||
// 申请指引(前端展示)
|
||||
"guide": gin.H{
|
||||
"spark": "讯飞开放平台 console.xfyun.cn 创建应用后,在「Spark Lite → HTTP 服务接口认证信息」复制 APIPassword 填入即可,Lite 模型免费",
|
||||
"deepseek": "DeepSeek 开放平台 platform.deepseek.com 注册后创建 API Key(sk- 开头),按 token 计费",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// aiSaveReq 保存AI配置请求体(保存后立即生效,无需重启)
|
||||
type aiSaveReq struct {
|
||||
Provider *string `json:"provider"` // 全站AI提供方:rule/spark/deepseek(nil=不修改)
|
||||
Spark *struct {
|
||||
APIKey *string `json:"api_key"` // 留空=清除后台配置回退到配置文件
|
||||
BaseURL *string `json:"base_url"` // 接口地址
|
||||
Model *string `json:"model"` // 模型名
|
||||
} `json:"spark"`
|
||||
DeepSeek *struct {
|
||||
APIKey *string `json:"api_key"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
} `json:"deepseek"`
|
||||
}
|
||||
|
||||
// AdminSaveAIConfig 后台:保存大模型 API Key 等配置到数据库(覆盖 config.yaml)
|
||||
func AdminSaveAIConfig(c *gin.Context) {
|
||||
var req aiSaveReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
// 切换全站AI模型(新建房间立即生效,已开局房间不受影响)
|
||||
if req.Provider != nil {
|
||||
p := *req.Provider
|
||||
if p != ai.ProviderRule && p != ai.ProviderSpark && p != ai.ProviderDeepSeek {
|
||||
resp.Fail(c, "AI提供方只能是 rule / spark / deepseek")
|
||||
return
|
||||
}
|
||||
service.SetConfig(model.ConfKeyAIProvider, p)
|
||||
}
|
||||
if req.Spark != nil {
|
||||
saveIfSet(model.ConfKeyAISparkKey, req.Spark.APIKey)
|
||||
saveIfSet(model.ConfKeyAISparkBase, req.Spark.BaseURL)
|
||||
saveIfSet(model.ConfKeyAISparkModel, req.Spark.Model)
|
||||
}
|
||||
if req.DeepSeek != nil {
|
||||
saveIfSet(model.ConfKeyAIDeepSeekKey, req.DeepSeek.APIKey)
|
||||
saveIfSet(model.ConfKeyAIDeepSeekBase, req.DeepSeek.BaseURL)
|
||||
saveIfSet(model.ConfKeyAIDeepSeekModel, req.DeepSeek.Model)
|
||||
}
|
||||
resp.OK(c, gin.H{
|
||||
"provider": ai.GlobalProvider(),
|
||||
"spark": effectiveAIConf(ai.ProviderSpark),
|
||||
"deepseek": effectiveAIConf(ai.ProviderDeepSeek),
|
||||
})
|
||||
}
|
||||
|
||||
// saveIfSet 请求里带了该字段才写库(nil=不动,空串=清除让其回退配置文件)
|
||||
func saveIfSet(key string, val *string) {
|
||||
if val != nil {
|
||||
service.SetConfig(key, *val)
|
||||
}
|
||||
}
|
||||
|
||||
// aiTestReq 连通性测试请求体
|
||||
type aiTestReq struct {
|
||||
Provider string `json:"provider" binding:"required,oneof=spark deepseek"` // 要测试的提供方
|
||||
}
|
||||
|
||||
// AdminTestAI 后台:向大模型发一条测试消息,验证 Key 是否可用并测量延迟
|
||||
func AdminTestAI(c *gin.Context) {
|
||||
var req aiTestReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
client := ai.ClientForTest(req.Provider)
|
||||
if client == nil {
|
||||
resp.Fail(c, "该提供方还没有配置 API Key")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
reply, err := client.Chat(ctx, "你是连通性测试助手。", "请只回复两个字:在线", 0.1)
|
||||
if err != nil {
|
||||
resp.Fail(c, "连接失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{
|
||||
"reply": reply, // 模型回复内容
|
||||
"latency_ms": time.Since(start).Milliseconds(), // 往返延迟(毫秒)
|
||||
})
|
||||
}
|
||||
129
internal/handler/analytics.go
Normal file
129
internal/handler/analytics.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/room"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// dayStart 返回某时刻所在自然日的零点时间戳(本地时区)
|
||||
func dayStart(t time.Time) int64 {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
|
||||
}
|
||||
|
||||
// AdminAnalytics 运营数据分析:日活趋势、游玩时长、平均时长、最火游戏、当前在线
|
||||
// 日活口径:当天有登录 / 游玩 / 签到 / 对战任一行为的去重用户数
|
||||
func AdminAnalytics(c *gin.Context) {
|
||||
now := time.Now()
|
||||
todayStart := dayStart(now)
|
||||
const trendDays = 14
|
||||
since := todayStart - int64(trendDays-1)*86400
|
||||
// ---- 日活趋势:四张行为表按天去重合并 ----
|
||||
type dayRow struct {
|
||||
D string `gorm:"column:d"`
|
||||
N int `gorm:"column:n"`
|
||||
}
|
||||
var rows []dayRow
|
||||
database.DB.Raw(`SELECT d, COUNT(DISTINCT uid) AS n FROM (
|
||||
SELECT user_id AS uid, FROM_UNIXTIME(created_at, '%Y-%m-%d') AS d FROM login_logs WHERE created_at >= ?
|
||||
UNION ALL SELECT user_id, FROM_UNIXTIME(created_at, '%Y-%m-%d') FROM game_records WHERE created_at >= ?
|
||||
UNION ALL SELECT user_id, FROM_UNIXTIME(created_at, '%Y-%m-%d') FROM sign_ins WHERE created_at >= ?
|
||||
UNION ALL SELECT user_id, FROM_UNIXTIME(created_at, '%Y-%m-%d') FROM battle_records WHERE created_at >= ?
|
||||
) t GROUP BY d ORDER BY d`, since, since, since, since).Scan(&rows)
|
||||
dauMap := map[string]int{}
|
||||
for _, r := range rows {
|
||||
dauMap[r.D] = r.N
|
||||
}
|
||||
// 补齐没有数据的日期(趋势图连续)
|
||||
trend := make([]gin.H, 0, trendDays)
|
||||
for i := 0; i < trendDays; i++ {
|
||||
day := time.Unix(todayStart-int64(trendDays-1-i)*86400, 0)
|
||||
key := day.Format("2006-01-02")
|
||||
trend = append(trend, gin.H{"date": day.Format("01-02"), "full": key, "count": dauMap[key]})
|
||||
}
|
||||
dauToday := dauMap[now.Format("2006-01-02")]
|
||||
dauYesterday := dauMap[time.Unix(todayStart-86400, 0).Format("2006-01-02")]
|
||||
// ---- 游玩时长与局数(单机 + 联机对战合并) ----
|
||||
type sumRow struct {
|
||||
Dur int64 `gorm:"column:dur"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
sumOf := func(table string, from int64) sumRow {
|
||||
var r sumRow
|
||||
database.DB.Raw(fmt.Sprintf(
|
||||
`SELECT COALESCE(SUM(duration),0) AS dur, COUNT(*) AS cnt FROM %s WHERE created_at >= ?`, table), from).Scan(&r)
|
||||
return r
|
||||
}
|
||||
gToday, gTotal := sumOf("game_records", todayStart), sumOf("game_records", 0)
|
||||
bToday, bTotal := sumOf("battle_records", todayStart), sumOf("battle_records", 0)
|
||||
secToday := gToday.Dur + bToday.Dur
|
||||
secTotal := gTotal.Dur + bTotal.Dur
|
||||
playsToday := gToday.Cnt + bToday.Cnt
|
||||
playsTotal := gTotal.Cnt + bTotal.Cnt
|
||||
avgSec := int64(0)
|
||||
if playsTotal > 0 {
|
||||
avgSec = secTotal / playsTotal
|
||||
}
|
||||
avgSecToday := int64(0)
|
||||
if playsToday > 0 {
|
||||
avgSecToday = secToday / playsToday
|
||||
}
|
||||
// ---- 最火游戏:近 7 天局数排序(单机+对战合并),附平均时长与历史总局数 ----
|
||||
weekAgo := now.Unix() - 7*86400
|
||||
type hotRow struct {
|
||||
GameID int `gorm:"column:game_id"`
|
||||
Plays int `gorm:"column:plays"`
|
||||
Dur int64 `gorm:"column:dur"`
|
||||
}
|
||||
var hot []hotRow
|
||||
database.DB.Raw(`SELECT game_id, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS dur FROM (
|
||||
SELECT game_id, duration FROM game_records WHERE created_at >= ?
|
||||
UNION ALL SELECT game_id, duration FROM battle_records WHERE created_at >= ?
|
||||
) t GROUP BY game_id ORDER BY plays DESC LIMIT 8`, weekAgo, weekAgo).Scan(&hot)
|
||||
ids := make([]int, 0, len(hot))
|
||||
for _, h := range hot {
|
||||
ids = append(ids, h.GameID)
|
||||
}
|
||||
gameMap := map[int]model.Game{}
|
||||
if len(ids) > 0 {
|
||||
var games []model.Game
|
||||
database.DB.Where("id IN ?", ids).Find(&games)
|
||||
for _, g := range games {
|
||||
gameMap[g.ID] = g
|
||||
}
|
||||
}
|
||||
hotGames := make([]gin.H, 0, len(hot))
|
||||
for _, h := range hot {
|
||||
g, ok := gameMap[h.GameID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
avg := int64(0)
|
||||
if h.Plays > 0 {
|
||||
avg = h.Dur / int64(h.Plays)
|
||||
}
|
||||
hotGames = append(hotGames, gin.H{
|
||||
"game_id": g.ID, "code": g.Code, "name": g.Name, "icon": g.Icon, "category": g.Category,
|
||||
"plays_7d": h.Plays, "avg_seconds": avg, "play_count": g.PlayCount,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{
|
||||
"dau_today": dauToday, // 今日日活
|
||||
"dau_yesterday": dauYesterday, // 昨日日活(对比)
|
||||
"dau_trend": trend, // 近 14 天日活趋势
|
||||
"online_now": room.OnlineCount(), // 当前在线人数(WebSocket 连接数)
|
||||
"play_seconds_today": secToday, // 今日游玩总时长(秒)
|
||||
"play_seconds_total": secTotal, // 累计游玩总时长(秒)
|
||||
"plays_today": playsToday, // 今日总局数
|
||||
"plays_total": playsTotal, // 累计总局数
|
||||
"avg_seconds": avgSec, // 平均单局时长(秒,历史)
|
||||
"avg_seconds_today": avgSecToday, // 平均单局时长(秒,今日)
|
||||
"hot_games": hotGames, // 近 7 天最火游戏 Top8
|
||||
})
|
||||
}
|
||||
107
internal/handler/auth.go
Normal file
107
internal/handler/auth.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package handler 存放全部 HTTP 接口处理函数
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/jwtutil"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// registerReq 注册请求体
|
||||
type registerReq struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=20"` // 用户名:3-20位
|
||||
Password string `json:"password" binding:"required,min=6,max=32"` // 密码:6-32位
|
||||
Nickname string `json:"nickname" binding:"max=20"` // 昵称:可选,默认同用户名
|
||||
Avatar string `json:"avatar"` // emoji 头像:可选
|
||||
}
|
||||
|
||||
// Register 用户注册:校验重名、加密密码、发注册礼积分
|
||||
func Register(c *gin.Context) {
|
||||
var req registerReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误:用户名3-20位、密码6-32位")
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
// 用户名查重
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
|
||||
if count > 0 {
|
||||
resp.Fail(c, "用户名已被占用")
|
||||
return
|
||||
}
|
||||
// bcrypt 加密密码
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
resp.Fail(c, "密码加密失败")
|
||||
return
|
||||
}
|
||||
if req.Nickname == "" {
|
||||
req.Nickname = req.Username
|
||||
}
|
||||
if req.Avatar == "" {
|
||||
req.Avatar = "🙂"
|
||||
}
|
||||
user := model.User{
|
||||
Username: req.Username, Password: string(hash),
|
||||
Nickname: req.Nickname, Avatar: req.Avatar, Role: model.RoleNormal,
|
||||
}
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
resp.Fail(c, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
// 注册礼积分(金额可在后台配置)
|
||||
gift := service.GetConfigInt(model.ConfKeyRegisterGift, 200)
|
||||
if gift > 0 {
|
||||
service.ChangePoints(nil, user.ID, gift, model.PointTypeRegister, 0, "新用户注册礼")
|
||||
user.Points = gift
|
||||
}
|
||||
token, _ := jwtutil.Generate(user.ID, user.Role)
|
||||
resp.OK(c, gin.H{"token": token, "user": user})
|
||||
}
|
||||
|
||||
// loginReq 登录请求体
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"` // 用户名
|
||||
Password string `json:"password" binding:"required"` // 密码
|
||||
}
|
||||
|
||||
// Login 用户登录:校验密码、记录登录日志、签发 Token
|
||||
func Login(c *gin.Context) {
|
||||
var req loginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "请输入用户名和密码")
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
||||
resp.Fail(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
// bcrypt 校验密码
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) != nil {
|
||||
resp.Fail(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
if user.Status == model.UserStatusBanned {
|
||||
resp.Fail(c, "账号已被封禁,请联系管理员")
|
||||
return
|
||||
}
|
||||
// 写登录日志(IP + 浏览器UA)
|
||||
database.DB.Create(&model.LoginLog{
|
||||
UserID: user.ID, IP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"),
|
||||
})
|
||||
token, err := jwtutil.Generate(user.ID, user.Role)
|
||||
if err != nil {
|
||||
resp.Fail(c, "登录凭证生成失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"token": token, "user": user})
|
||||
}
|
||||
181
internal/handler/chat.go
Normal file
181
internal/handler/chat.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/room"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// ChatConversations 会话列表:每个聊过天的好友一条,带最后一条消息与未读数
|
||||
func ChatConversations(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
// 每个对端的最后一条消息ID
|
||||
type peerRow struct {
|
||||
Peer int `gorm:"column:peer"`
|
||||
LastID int `gorm:"column:last_id"`
|
||||
}
|
||||
var peers []peerRow
|
||||
database.DB.Raw(`SELECT IF(from_id = ?, to_id, from_id) AS peer, MAX(id) AS last_id
|
||||
FROM chat_messages WHERE from_id = ? OR to_id = ?
|
||||
GROUP BY peer ORDER BY last_id DESC LIMIT 100`, uid, uid, uid).Scan(&peers)
|
||||
if len(peers) == 0 {
|
||||
resp.OK(c, gin.H{"list": []any{}, "total_unread": 0})
|
||||
return
|
||||
}
|
||||
lastIDs := make([]int, 0, len(peers))
|
||||
peerIDs := make([]int, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
lastIDs = append(lastIDs, p.LastID)
|
||||
peerIDs = append(peerIDs, p.Peer)
|
||||
}
|
||||
// 最后一条消息内容
|
||||
var lastMsgs []model.ChatMessage
|
||||
database.DB.Where("id IN ?", lastIDs).Find(&lastMsgs)
|
||||
msgMap := map[int]model.ChatMessage{}
|
||||
for _, m := range lastMsgs {
|
||||
msgMap[m.ID] = m
|
||||
}
|
||||
// 各对端发给我的未读数
|
||||
type unreadRow struct {
|
||||
FromID int `gorm:"column:from_id"`
|
||||
Cnt int `gorm:"column:cnt"`
|
||||
}
|
||||
var unreads []unreadRow
|
||||
database.DB.Raw(`SELECT from_id, COUNT(*) AS cnt FROM chat_messages
|
||||
WHERE to_id = ? AND is_read = 0 GROUP BY from_id`, uid).Scan(&unreads)
|
||||
unreadMap := map[int]int{}
|
||||
total := 0
|
||||
for _, u := range unreads {
|
||||
unreadMap[u.FromID] = u.Cnt
|
||||
total += u.Cnt
|
||||
}
|
||||
briefs := loadBriefs(peerIDs)
|
||||
type convItem struct {
|
||||
Peer userBrief `json:"peer"` // 对方信息
|
||||
LastMsg string `json:"last_msg"` // 最后一条消息内容
|
||||
LastAt int64 `json:"last_at"` // 最后一条消息时间
|
||||
FromMe bool `json:"from_me"` // 最后一条是否我发的
|
||||
Unread int `json:"unread"` // 未读数
|
||||
}
|
||||
list := make([]convItem, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
b, ok := briefs[p.Peer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m := msgMap[p.LastID]
|
||||
list = append(list, convItem{
|
||||
Peer: b, LastMsg: m.Content, LastAt: m.CreatedAt,
|
||||
FromMe: m.FromID == uid, Unread: unreadMap[p.Peer],
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"list": list, "total_unread": total})
|
||||
}
|
||||
|
||||
// ChatMessages 与某人的聊天记录(倒序分页,返回升序;拉取即把对方发来的标记已读)
|
||||
func ChatMessages(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
peer, _ := strconv.Atoi(c.Query("peer_id"))
|
||||
if peer <= 0 {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
beforeID, _ := strconv.Atoi(c.Query("before_id"))
|
||||
size, _ := strconv.Atoi(c.Query("size"))
|
||||
if size <= 0 || size > 100 {
|
||||
size = 50
|
||||
}
|
||||
q := database.DB.Where("(from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)", uid, peer, peer, uid)
|
||||
if beforeID > 0 {
|
||||
q = q.Where("id < ?", beforeID)
|
||||
}
|
||||
var msgs []model.ChatMessage
|
||||
q.Order("id DESC").Limit(size + 1).Find(&msgs)
|
||||
hasMore := len(msgs) > size
|
||||
if hasMore {
|
||||
msgs = msgs[:size]
|
||||
}
|
||||
// 反转为时间升序(前端从上到下渲染)
|
||||
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||
}
|
||||
// 拉取历史即视为已读
|
||||
database.DB.Model(&model.ChatMessage{}).
|
||||
Where("from_id = ? AND to_id = ? AND is_read = 0", peer, uid).
|
||||
Update("is_read", 1)
|
||||
briefs := loadBriefs([]int{peer})
|
||||
resp.OK(c, gin.H{"list": msgs, "has_more": hasMore, "peer": briefs[peer]})
|
||||
}
|
||||
|
||||
// chatSendBody 发送私聊请求体
|
||||
type chatSendBody struct {
|
||||
ToID int `json:"to_id" binding:"required"` // 接收方用户ID
|
||||
Content string `json:"content" binding:"required"` // 消息内容
|
||||
}
|
||||
|
||||
// ChatSend 发送私聊:仅好友之间可发;对方在线时通过 WebSocket 实时推送
|
||||
func ChatSend(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var req chatSendBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ToID == uid {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(req.Content)
|
||||
if content == "" {
|
||||
resp.Fail(c, "消息内容不能为空")
|
||||
return
|
||||
}
|
||||
if len([]rune(content)) > 500 {
|
||||
resp.Fail(c, "消息太长(最多 500 字)")
|
||||
return
|
||||
}
|
||||
if !isFriend(uid, req.ToID) {
|
||||
resp.Fail(c, "你们还不是好友,先添加对方为好友吧")
|
||||
return
|
||||
}
|
||||
msg := model.ChatMessage{FromID: uid, ToID: req.ToID, Content: content}
|
||||
if err := database.DB.Create(&msg).Error; err != nil {
|
||||
resp.Fail(c, "发送失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
// 对方在线:实时推送消息(含发送者摘要,便于会话列表直接更新)
|
||||
var me model.User
|
||||
database.DB.First(&me, uid)
|
||||
room.PushToUser(req.ToID, "chat_msg", gin.H{"msg": msg, "from": briefOf(&me, true)})
|
||||
resp.OK(c, msg)
|
||||
}
|
||||
|
||||
// chatReadBody 标记已读请求体
|
||||
type chatReadBody struct {
|
||||
PeerID int `json:"peer_id" binding:"required"` // 对端用户ID
|
||||
}
|
||||
|
||||
// ChatRead 把某人发给我的消息全部标记已读(聊天窗口打开时收到新消息调用)
|
||||
func ChatRead(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var req chatReadBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
database.DB.Model(&model.ChatMessage{}).
|
||||
Where("from_id = ? AND to_id = ? AND is_read = 0", req.PeerID, uid).
|
||||
Update("is_read", 1)
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// ChatUnread 我的未读私聊总数(导航角标)
|
||||
func ChatUnread(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var cnt int64
|
||||
database.DB.Model(&model.ChatMessage{}).Where("to_id = ? AND is_read = 0", uid).Count(&cnt)
|
||||
resp.OK(c, gin.H{"unread": cnt})
|
||||
}
|
||||
38
internal/handler/config.go
Normal file
38
internal/handler/config.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// SiteConfigPublic 公开的站点配置:站点名、公告、当前主题(含 CSS 变量),无需登录
|
||||
func SiteConfigPublic(c *gin.Context) {
|
||||
// 读取当前启用的主题(后台可切换)
|
||||
activeCode := service.GetConfig(model.ConfKeyActiveTheme, "arcade")
|
||||
var theme model.Theme
|
||||
if err := database.DB.Where("code = ? AND status = 1", activeCode).First(&theme).Error; err != nil {
|
||||
// 配置的主题不存在时回退到第一个可用主题
|
||||
database.DB.Where("status = 1").Order("sort").First(&theme)
|
||||
}
|
||||
// 把 CSS 变量 JSON 解析成对象,前端直接注入 :root
|
||||
vars := map[string]string{}
|
||||
json.Unmarshal([]byte(theme.CSSVars), &vars)
|
||||
resp.OK(c, gin.H{
|
||||
"site_name": service.GetConfig(model.ConfKeySiteName, "像素游戏厅"),
|
||||
"announcement": service.GetConfig(model.ConfKeyAnnouncement, ""),
|
||||
// 大厅轮播配置:效果样式 + 自动播放间隔(参与轮播的游戏由 games.carousel 决定)
|
||||
"carousel_style": service.GetConfig(model.ConfKeyCarouselStyle, "slide"),
|
||||
"carousel_interval": service.GetConfigInt(model.ConfKeyCarouselInterval, 5),
|
||||
"theme": gin.H{
|
||||
"code": theme.Code,
|
||||
"name": theme.Name,
|
||||
"css_vars": vars,
|
||||
},
|
||||
})
|
||||
}
|
||||
275
internal/handler/friend.go
Normal file
275
internal/handler/friend.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/room"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// userBrief 用户摘要(好友列表/会话列表/搜索结果通用)
|
||||
type userBrief struct {
|
||||
UserID int `json:"user_id"` // 用户ID
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Avatar string `json:"avatar"` // emoji 头像
|
||||
Online bool `json:"online"` // 是否在线(有活跃 WebSocket 连接)
|
||||
}
|
||||
|
||||
// briefOf 把用户模型转为摘要
|
||||
func briefOf(u *model.User, online bool) userBrief {
|
||||
return userBrief{UserID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, Online: online}
|
||||
}
|
||||
|
||||
// loadBriefs 批量加载用户摘要(自动附带在线状态)
|
||||
func loadBriefs(ids []int) map[int]userBrief {
|
||||
out := map[int]userBrief{}
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
var users []model.User
|
||||
database.DB.Where("id IN ?", ids).Find(&users)
|
||||
online := room.OnlineMap(ids)
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
out[u.ID] = briefOf(u, online[u.ID])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isFriend 两人是否已互为好友(任一方向的已通过记录)
|
||||
func isFriend(a, b int) bool {
|
||||
var cnt int64
|
||||
database.DB.Model(&model.Friend{}).
|
||||
Where("status = ? AND ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?))",
|
||||
model.FriendStatusAccepted, a, b, b, a).
|
||||
Count(&cnt)
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
// FriendList 我的好友列表(在线的排在前面)
|
||||
func FriendList(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var rels []model.Friend
|
||||
database.DB.Where("status = ? AND (user_id = ? OR friend_id = ?)", model.FriendStatusAccepted, uid, uid).
|
||||
Order("updated_at DESC").Find(&rels)
|
||||
ids := make([]int, 0, len(rels))
|
||||
sinceMap := map[int]int64{}
|
||||
for _, r := range rels {
|
||||
peer := r.UserID
|
||||
if peer == uid {
|
||||
peer = r.FriendID
|
||||
}
|
||||
ids = append(ids, peer)
|
||||
sinceMap[peer] = r.UpdatedAt
|
||||
}
|
||||
briefs := loadBriefs(ids)
|
||||
type friendItem struct {
|
||||
userBrief
|
||||
Since int64 `json:"since"` // 成为好友时间
|
||||
}
|
||||
list := make([]friendItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if b, ok := briefs[id]; ok {
|
||||
list = append(list, friendItem{userBrief: b, Since: sinceMap[id]})
|
||||
}
|
||||
}
|
||||
// 在线的排前面(保持各自原有相对顺序)
|
||||
sorted := make([]friendItem, 0, len(list))
|
||||
for _, it := range list {
|
||||
if it.Online {
|
||||
sorted = append(sorted, it)
|
||||
}
|
||||
}
|
||||
for _, it := range list {
|
||||
if !it.Online {
|
||||
sorted = append(sorted, it)
|
||||
}
|
||||
}
|
||||
resp.OK(c, sorted)
|
||||
}
|
||||
|
||||
// FriendRequests 好友申请:received=发给我的待处理申请,sent=我发出的待验证申请
|
||||
func FriendRequests(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var in, out []model.Friend
|
||||
database.DB.Where("friend_id = ? AND status = ?", uid, model.FriendStatusPending).Order("id DESC").Find(&in)
|
||||
database.DB.Where("user_id = ? AND status = ?", uid, model.FriendStatusPending).Order("id DESC").Find(&out)
|
||||
ids := make([]int, 0, len(in)+len(out))
|
||||
for _, r := range in {
|
||||
ids = append(ids, r.UserID)
|
||||
}
|
||||
for _, r := range out {
|
||||
ids = append(ids, r.FriendID)
|
||||
}
|
||||
briefs := loadBriefs(ids)
|
||||
type reqItem struct {
|
||||
ID int `json:"id"` // 关系ID(同意/拒绝时用)
|
||||
User userBrief `json:"user"` // 对方信息
|
||||
At int64 `json:"at"` // 申请时间
|
||||
}
|
||||
received := make([]reqItem, 0, len(in))
|
||||
for _, r := range in {
|
||||
received = append(received, reqItem{ID: r.ID, User: briefs[r.UserID], At: r.CreatedAt})
|
||||
}
|
||||
sent := make([]reqItem, 0, len(out))
|
||||
for _, r := range out {
|
||||
sent = append(sent, reqItem{ID: r.ID, User: briefs[r.FriendID], At: r.CreatedAt})
|
||||
}
|
||||
resp.OK(c, gin.H{"received": received, "sent": sent})
|
||||
}
|
||||
|
||||
// FriendSearch 按用户名/昵称搜索用户(加好友入口),附带与我的关系状态
|
||||
func FriendSearch(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
kw := strings.TrimSpace(c.Query("keyword"))
|
||||
if kw == "" {
|
||||
resp.OK(c, []any{})
|
||||
return
|
||||
}
|
||||
like := "%" + kw + "%"
|
||||
var users []model.User
|
||||
database.DB.Where("id != ? AND status = ? AND (username LIKE ? OR nickname LIKE ?)",
|
||||
uid, model.UserStatusNormal, like, like).Limit(10).Find(&users)
|
||||
ids := make([]int, 0, len(users))
|
||||
for _, u := range users {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
online := room.OnlineMap(ids)
|
||||
// 一次取出我与这批用户的全部关系,标注状态
|
||||
var rels []model.Friend
|
||||
if len(ids) > 0 {
|
||||
database.DB.Where("(user_id = ? AND friend_id IN ?) OR (friend_id = ? AND user_id IN ?)", uid, ids, uid, ids).Find(&rels)
|
||||
}
|
||||
// relation: none=无关系 friend=已是好友 pending_out=我已申请 pending_in=对方已申请我
|
||||
relMap := map[int]string{}
|
||||
for _, r := range rels {
|
||||
peer := r.UserID
|
||||
dir := "pending_in"
|
||||
if r.UserID == uid {
|
||||
peer = r.FriendID
|
||||
dir = "pending_out"
|
||||
}
|
||||
if r.Status == model.FriendStatusAccepted {
|
||||
relMap[peer] = "friend"
|
||||
} else if relMap[peer] != "friend" {
|
||||
relMap[peer] = dir
|
||||
}
|
||||
}
|
||||
type searchItem struct {
|
||||
userBrief
|
||||
Relation string `json:"relation"` // none/friend/pending_out/pending_in
|
||||
}
|
||||
list := make([]searchItem, 0, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
rel := relMap[u.ID]
|
||||
if rel == "" {
|
||||
rel = "none"
|
||||
}
|
||||
list = append(list, searchItem{userBrief: briefOf(u, online[u.ID]), Relation: rel})
|
||||
}
|
||||
resp.OK(c, list)
|
||||
}
|
||||
|
||||
// friendReqBody 发起好友申请请求体
|
||||
type friendReqBody struct {
|
||||
UserID int `json:"user_id" binding:"required"` // 要添加的用户ID
|
||||
}
|
||||
|
||||
// FriendRequest 发起好友申请:对方在线时实时推送;若对方已申请过我则直接成为好友
|
||||
func FriendRequest(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var req friendReqBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == uid {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
var target model.User
|
||||
if err := database.DB.First(&target, req.UserID).Error; err != nil || target.Status != model.UserStatusNormal {
|
||||
resp.Fail(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
var me model.User
|
||||
database.DB.First(&me, uid)
|
||||
// 已有关系判断(两个方向)
|
||||
var exist model.Friend
|
||||
if err := database.DB.Where("user_id = ? AND friend_id = ?", uid, req.UserID).First(&exist).Error; err == nil {
|
||||
if exist.Status == model.FriendStatusAccepted {
|
||||
resp.Fail(c, "你们已经是好友了")
|
||||
} else {
|
||||
resp.Fail(c, "已发送过申请,等待对方同意")
|
||||
}
|
||||
return
|
||||
}
|
||||
var reverse model.Friend
|
||||
if err := database.DB.Where("user_id = ? AND friend_id = ?", req.UserID, uid).First(&reverse).Error; err == nil {
|
||||
if reverse.Status == model.FriendStatusAccepted {
|
||||
resp.Fail(c, "你们已经是好友了")
|
||||
return
|
||||
}
|
||||
// 对方已申请过我:视为互相想加,直接通过
|
||||
database.DB.Model(&reverse).Update("status", model.FriendStatusAccepted)
|
||||
room.PushToUser(req.UserID, "friend_event", gin.H{"kind": "accept", "user": briefOf(&me, true)})
|
||||
resp.OK(c, gin.H{"relation": "friend", "msg": "对方也申请过你,已直接成为好友"})
|
||||
return
|
||||
}
|
||||
rel := model.Friend{UserID: uid, FriendID: req.UserID, Status: model.FriendStatusPending}
|
||||
if err := database.DB.Create(&rel).Error; err != nil {
|
||||
resp.Fail(c, "申请失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
// 对方在线则实时提醒
|
||||
room.PushToUser(req.UserID, "friend_event", gin.H{"kind": "request", "user": briefOf(&me, true)})
|
||||
resp.OK(c, gin.H{"relation": "pending_out"})
|
||||
}
|
||||
|
||||
// friendRespondBody 处理好友申请请求体
|
||||
type friendRespondBody struct {
|
||||
ID int `json:"id" binding:"required"` // 关系ID(来自申请列表)
|
||||
Accept bool `json:"accept"` // true=同意 false=拒绝
|
||||
}
|
||||
|
||||
// FriendRespond 同意/拒绝好友申请(同意后通知申请人)
|
||||
func FriendRespond(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var req friendRespondBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
var rel model.Friend
|
||||
if err := database.DB.First(&rel, req.ID).Error; err != nil || rel.FriendID != uid || rel.Status != model.FriendStatusPending {
|
||||
resp.Fail(c, "申请不存在或已处理")
|
||||
return
|
||||
}
|
||||
if !req.Accept {
|
||||
database.DB.Delete(&rel)
|
||||
resp.OK(c, gin.H{"relation": "none"})
|
||||
return
|
||||
}
|
||||
database.DB.Model(&rel).Update("status", model.FriendStatusAccepted)
|
||||
var me model.User
|
||||
database.DB.First(&me, uid)
|
||||
room.PushToUser(rel.UserID, "friend_event", gin.H{"kind": "accept", "user": briefOf(&me, true)})
|
||||
resp.OK(c, gin.H{"relation": "friend"})
|
||||
}
|
||||
|
||||
// FriendDelete 删除好友(清除双方全部关系记录,聊天记录保留)
|
||||
func FriendDelete(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
peer, _ := strconv.Atoi(c.Param("uid"))
|
||||
if peer <= 0 {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
database.DB.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", uid, peer, peer, uid).
|
||||
Delete(&model.Friend{})
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
500
internal/handler/game.go
Normal file
500
internal/handler/game.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// gameListItem 游戏列表项(附当前用户的拥有/周免/可玩状态)
|
||||
type gameListItem struct {
|
||||
model.Game
|
||||
Owned bool `json:"owned"` // 是否永久拥有(免费或已购买)
|
||||
WeeklyFree bool `json:"weekly_free"` // 是否在我的VIP本周周免批次内
|
||||
Playable bool `json:"playable"` // 是否可玩(拥有 或 周免)
|
||||
}
|
||||
|
||||
// GameList 大厅游戏列表:全部上架游戏 + 我的拥有/周免状态
|
||||
func GameList(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
var games []model.Game
|
||||
database.DB.Where("status = 1").Order("sort").Find(&games)
|
||||
// 我已购买的游戏ID集合
|
||||
ownedSet := map[int]bool{}
|
||||
var ownedIDs []int
|
||||
database.DB.Model(&model.UserGame{}).Where("user_id = ?", uid).Pluck("game_id", &ownedIDs)
|
||||
for _, id := range ownedIDs {
|
||||
ownedSet[id] = true
|
||||
}
|
||||
// 我的VIP本周周免游戏ID集合(非VIP为空集合)
|
||||
weeklySet := map[int]bool{}
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, uid).Error; err == nil {
|
||||
weeklySet = service.VipWeeklyFreeSet(&user, time.Now())
|
||||
}
|
||||
list := make([]gameListItem, 0, len(games))
|
||||
for _, g := range games {
|
||||
owned := g.Price == 0 || ownedSet[g.ID]
|
||||
weekly := !owned && weeklySet[g.ID]
|
||||
list = append(list, gameListItem{Game: g, Owned: owned, WeeklyFree: weekly, Playable: owned || weekly})
|
||||
}
|
||||
resp.OK(c, list)
|
||||
}
|
||||
|
||||
// GameDetail 游戏详情:基础信息、拥有/周免状态、我的最高分
|
||||
func GameDetail(c *gin.Context) {
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在或已下架")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
owned := game.Price == 0
|
||||
if !owned {
|
||||
var cnt int64
|
||||
database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&cnt)
|
||||
owned = cnt > 0
|
||||
}
|
||||
// 未拥有时看是否在我的VIP周免批次内(周免也可进入游玩)
|
||||
weekly := false
|
||||
if !owned {
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, uid).Error; err == nil {
|
||||
weekly = service.VipWeeklyFreeSet(&user, time.Now())[game.ID]
|
||||
}
|
||||
}
|
||||
// 我的历史最高分
|
||||
var best int
|
||||
database.DB.Model(&model.GameRecord{}).Where("user_id = ? AND game_id = ?", uid, game.ID).
|
||||
Select("COALESCE(MAX(score),0)").Scan(&best)
|
||||
resp.OK(c, gin.H{
|
||||
"game": game, "owned": owned, "best_score": best,
|
||||
"weekly_free": weekly, "playable": owned || weekly,
|
||||
})
|
||||
}
|
||||
|
||||
// submitScoreReq 提交成绩请求体
|
||||
type submitScoreReq struct {
|
||||
Score int `json:"score" binding:"min=0"` // 本局得分
|
||||
Duration int `json:"duration"` // 游玩时长(秒)
|
||||
UseDouble bool `json:"use_double"` // 是否使用双倍积分卡
|
||||
}
|
||||
|
||||
// SubmitScore 提交单机游戏成绩:分数 → 平台积分换算并入账,记录本局
|
||||
func SubmitScore(c *gin.Context) {
|
||||
var req submitScoreReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在或已下架")
|
||||
return
|
||||
}
|
||||
if game.Engine == model.EngineOnline {
|
||||
resp.Fail(c, "联机游戏不支持提交成绩")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
// 付费游戏必须已购买或在我的VIP周免批次内才能提交成绩(防接口刷分)
|
||||
if game.Price > 0 {
|
||||
var cnt int64
|
||||
database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
weekly := false
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, uid).Error; err == nil {
|
||||
weekly = service.VipWeeklyFreeSet(&user, time.Now())[game.ID]
|
||||
}
|
||||
if !weekly {
|
||||
resp.Fail(c, "请先购买该游戏")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
// 防刷 1:同一游戏两次交分之间的最小间隔(站点配置可调,默认 15 秒)
|
||||
if minGap := service.GetConfigInt("score_min_interval_sec", 15); minGap > 0 {
|
||||
var lastAt int64
|
||||
database.DB.Model(&model.GameRecord{}).Where("user_id = ? AND game_id = ?", uid, game.ID).
|
||||
Select("COALESCE(MAX(created_at),0)").Scan(&lastAt)
|
||||
if lastAt > 0 && now.Unix()-lastAt < int64(minGap) {
|
||||
resp.Fail(c, "提交太频繁,请稍后再试")
|
||||
return
|
||||
}
|
||||
}
|
||||
// 防刷 2:单游戏每日积分封顶 = 单局上限 × 倍数(站点配置可调,默认 10 倍)
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
|
||||
var todaySum int
|
||||
database.DB.Model(&model.GameRecord{}).
|
||||
Where("user_id = ? AND game_id = ? AND created_at >= ?", uid, game.ID, dayStart).
|
||||
Select("COALESCE(SUM(points_gained),0)").Scan(&todaySum)
|
||||
dailyCap := game.MaxPointsPerPlay * service.GetConfigInt("daily_points_cap_multiple", 10)
|
||||
// 换算可得积分:分数/换算比,封顶单局上限(后台可配)
|
||||
points := 0
|
||||
if game.ScorePerPoint > 0 && req.Score > 0 {
|
||||
points = req.Score / game.ScorePerPoint
|
||||
if points > game.MaxPointsPerPlay {
|
||||
points = game.MaxPointsPerPlay
|
||||
}
|
||||
}
|
||||
// 达到当日封顶后本局不再发积分(分数仍记录、仍可刷新最高分)
|
||||
capped := false
|
||||
clampDaily := func() {
|
||||
remaining := dailyCap - todaySum
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
if points > remaining {
|
||||
points = remaining
|
||||
capped = true
|
||||
}
|
||||
}
|
||||
clampDaily() // 事务前先夹一次:已封顶时连双倍卡都不消耗
|
||||
doubled := false
|
||||
var newBalance int
|
||||
var record model.GameRecord
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 使用双倍积分卡:原子扣减 1 张,扣到了才翻倍
|
||||
if req.UseDouble && points > 0 {
|
||||
res := tx.Exec(`UPDATE user_props up JOIN props p ON p.id = up.prop_id
|
||||
SET up.quantity = up.quantity - 1
|
||||
WHERE up.user_id = ? AND p.code = 'double_points' AND up.quantity > 0`, uid)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
points *= 2
|
||||
doubled = true
|
||||
}
|
||||
}
|
||||
// 翻倍后再夹一次,确保当日总量严格不超封顶
|
||||
clampDaily()
|
||||
// 写游戏记录
|
||||
record = model.GameRecord{UserID: uid, GameID: game.ID, Score: req.Score, PointsGained: points, Duration: req.Duration}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 游玩次数自增
|
||||
if err := tx.Model(&model.Game{}).Where("id = ?", game.ID).
|
||||
UpdateColumn("play_count", gorm.Expr("play_count + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 积分入账并写流水
|
||||
if points > 0 {
|
||||
b, err := service.ChangePoints(tx, uid, points, model.PointTypeGame, record.ID,
|
||||
fmt.Sprintf("游玩「%s」获得 %d 分", game.Name, req.Score))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newBalance = b
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, "提交失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
// 查询之前的最高分,判断是否刷新纪录
|
||||
var prevBest int
|
||||
database.DB.Model(&model.GameRecord{}).
|
||||
Where("user_id = ? AND game_id = ? AND id != ?", uid, game.ID, record.ID).
|
||||
Select("COALESCE(MAX(score),0)").Scan(&prevBest)
|
||||
if newBalance == 0 {
|
||||
// 本局没有积分入账时查一次当前余额(保证返回值正确)
|
||||
var user model.User
|
||||
database.DB.First(&user, uid)
|
||||
newBalance = user.Points
|
||||
}
|
||||
resp.OK(c, gin.H{
|
||||
"points_gained": points, // 本局获得积分(含翻倍与当日封顶)
|
||||
"doubled": doubled, // 是否成功使用双倍卡
|
||||
"capped": capped, // 是否触发单游戏每日积分封顶
|
||||
"balance": newBalance, // 最新积分余额
|
||||
"is_new_high": req.Score > prevBest, // 是否刷新个人纪录
|
||||
})
|
||||
}
|
||||
|
||||
// friendProgressItem 好友在某游戏的关卡进度
|
||||
type friendProgressItem struct {
|
||||
UserID int `json:"user_id"` // 好友用户ID
|
||||
Nickname string `json:"nickname"` // 好友昵称
|
||||
Avatar string `json:"avatar"` // 好友头像(emoji)
|
||||
Level int `json:"level"` // 好友解锁到的关卡
|
||||
}
|
||||
|
||||
// friendIDs 我的全部好友ID(已通过的关系,双向取对端)
|
||||
func friendIDs(uid int) []int {
|
||||
var rels []model.Friend
|
||||
database.DB.Where("status = ? AND (user_id = ? OR friend_id = ?)", model.FriendStatusAccepted, uid, uid).Find(&rels)
|
||||
ids := make([]int, 0, len(rels))
|
||||
for _, r := range rels {
|
||||
peer := r.UserID
|
||||
if peer == uid {
|
||||
peer = r.FriendID
|
||||
}
|
||||
ids = append(ids, peer)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// GameProgressGet 我的关卡进度 + 好友进度(关卡选择页展示「哪个好友在哪一关」)
|
||||
func GameProgressGet(c *gin.Context) {
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在或已下架")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
// 我的进度(无记录视为第 1 关)
|
||||
myLevel := 1
|
||||
var gp model.GameProgress
|
||||
if err := database.DB.Where("user_id = ? AND game_id = ?", uid, game.ID).First(&gp).Error; err == nil {
|
||||
myLevel = gp.Level
|
||||
}
|
||||
// 好友进度:只列出玩过本游戏(有进度记录)的好友
|
||||
friends := make([]friendProgressItem, 0)
|
||||
if ids := friendIDs(uid); len(ids) > 0 {
|
||||
database.DB.Table("game_progress gp").
|
||||
Select("gp.user_id, u.nickname, u.avatar, gp.level").
|
||||
Joins("JOIN users u ON u.id = gp.user_id").
|
||||
Where("gp.game_id = ? AND gp.user_id IN ?", game.ID, ids).
|
||||
Order("gp.level DESC").Scan(&friends)
|
||||
}
|
||||
resp.OK(c, gin.H{"level": myLevel, "friends": friends})
|
||||
}
|
||||
|
||||
// saveProgressReq 保存关卡进度请求体
|
||||
type saveProgressReq struct {
|
||||
Level int `json:"level" binding:"required,min=1,max=999"` // 解锁到的关卡
|
||||
}
|
||||
|
||||
// GameProgressSave 保存关卡进度(只增不减:过关后前端上报解锁的新关卡)
|
||||
// 关卡均为逐级解锁(过第 N 关解锁 N+1),服务端按"当前进度 +1"夹紧上报值,防接口跳关作弊
|
||||
func GameProgressSave(c *gin.Context) {
|
||||
var req saveProgressReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在或已下架")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
var gp model.GameProgress
|
||||
if err := database.DB.Where("user_id = ? AND game_id = ?", uid, game.ID).First(&gp).Error; err != nil {
|
||||
// 首次记录:初始进度为第 1 关,最多解锁到第 2 关
|
||||
level := req.Level
|
||||
if level > 2 {
|
||||
level = 2
|
||||
}
|
||||
gp = model.GameProgress{UserID: uid, GameID: game.ID, Level: level}
|
||||
if err := database.DB.Create(&gp).Error; err != nil {
|
||||
resp.Fail(c, "保存失败,请重试")
|
||||
return
|
||||
}
|
||||
} else if req.Level > gp.Level {
|
||||
level := req.Level
|
||||
if level > gp.Level+1 {
|
||||
level = gp.Level + 1
|
||||
}
|
||||
database.DB.Model(&gp).Update("level", level)
|
||||
gp.Level = level
|
||||
}
|
||||
resp.OK(c, gin.H{"level": gp.Level})
|
||||
}
|
||||
|
||||
// ---- 游戏存档(支持存档的单机游戏:饥荒等) ----
|
||||
|
||||
// GameSaveGet 读取我的存档:无档返回 exists=false,前端据此显示「继续/新开」
|
||||
func GameSaveGet(c *gin.Context) {
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在或已下架")
|
||||
return
|
||||
}
|
||||
var save model.GameSave
|
||||
if err := database.DB.Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).First(&save).Error; err != nil {
|
||||
resp.OK(c, gin.H{"exists": false})
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"exists": true, "day": save.Day, "score": save.Score, "data": save.Data, "updated_at": save.UpdatedAt})
|
||||
}
|
||||
|
||||
// saveGameReq 保存存档请求体
|
||||
type saveGameReq struct {
|
||||
Day int `json:"day" binding:"min=1"` // 存档天数(展示用)
|
||||
Score int `json:"score" binding:"min=0"` // 存档时分数(展示用)
|
||||
Data string `json:"data" binding:"required"` // 游戏状态快照(JSON 字符串)
|
||||
}
|
||||
|
||||
// GameSaveSet 覆盖保存存档(每天清晨自动存 + 手动存档按钮)
|
||||
func GameSaveSet(c *gin.Context) {
|
||||
var req saveGameReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Data) > 256*1024 {
|
||||
resp.Fail(c, "存档数据有误")
|
||||
return
|
||||
}
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在或已下架")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
var save model.GameSave
|
||||
if err := database.DB.Where("user_id = ? AND game_id = ?", uid, game.ID).First(&save).Error; err != nil {
|
||||
save = model.GameSave{UserID: uid, GameID: game.ID, Day: req.Day, Score: req.Score, Data: req.Data}
|
||||
if err := database.DB.Create(&save).Error; err != nil {
|
||||
resp.Fail(c, "存档失败,请重试")
|
||||
return
|
||||
}
|
||||
} else if err := database.DB.Model(&save).Updates(map[string]any{
|
||||
"day": req.Day, "score": req.Score, "data": req.Data,
|
||||
}).Error; err != nil {
|
||||
resp.Fail(c, "存档失败,请重试")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"day": req.Day})
|
||||
}
|
||||
|
||||
// GameSaveDelete 删除存档(死亡永久死亡删档 / 玩家主动重新开始)
|
||||
func GameSaveDelete(c *gin.Context) {
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ?", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在")
|
||||
return
|
||||
}
|
||||
database.DB.Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).Delete(&model.GameSave{})
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// ---- 游戏皮肤(更衣室:免费默认解锁,其余积分购买) ----
|
||||
|
||||
// skinListItem 皮肤列表项(附当前用户是否已拥有)
|
||||
type skinListItem struct {
|
||||
Code string `json:"code"` // 皮肤编码(对应前端绘制参数表)
|
||||
Name string `json:"name"` // 展示名称
|
||||
Price int `json:"price"` // 售价(0=免费)
|
||||
Owned bool `json:"owned"` // 是否已拥有(免费皮肤恒为 true)
|
||||
}
|
||||
|
||||
// GameSkins 皮肤列表:全部皮肤 + 我的拥有状态(price=0 视为人人拥有)
|
||||
func GameSkins(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
var skins []model.GameSkin
|
||||
database.DB.Where("game_code = ?", code).Order("sort").Find(&skins)
|
||||
// 我已购皮肤ID集合
|
||||
uid := middleware.UID(c)
|
||||
ownedSet := map[int]bool{}
|
||||
var ownedIDs []int
|
||||
database.DB.Table("user_skins us").
|
||||
Joins("JOIN game_skins gs ON gs.id = us.skin_id").
|
||||
Where("us.user_id = ? AND gs.game_code = ?", uid, code).
|
||||
Pluck("us.skin_id", &ownedIDs)
|
||||
for _, id := range ownedIDs {
|
||||
ownedSet[id] = true
|
||||
}
|
||||
list := make([]skinListItem, 0, len(skins))
|
||||
for _, s := range skins {
|
||||
list = append(list, skinListItem{Code: s.Code, Name: s.Name, Price: s.Price, Owned: s.Price == 0 || ownedSet[s.ID]})
|
||||
}
|
||||
resp.OK(c, list)
|
||||
}
|
||||
|
||||
// buySkinReq 购买皮肤请求体
|
||||
type buySkinReq struct {
|
||||
SkinCode string `json:"skin_code" binding:"required"` // 皮肤编码
|
||||
}
|
||||
|
||||
// GameSkinBuy 积分购买皮肤:校验未拥有 → 事务内扣积分 + 写流水 + 发放
|
||||
func GameSkinBuy(c *gin.Context) {
|
||||
var req buySkinReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
var skin model.GameSkin
|
||||
if err := database.DB.Where("game_code = ? AND code = ?", c.Param("code"), req.SkinCode).First(&skin).Error; err != nil {
|
||||
resp.Fail(c, "皮肤不存在")
|
||||
return
|
||||
}
|
||||
if skin.Price <= 0 {
|
||||
resp.Fail(c, "免费皮肤无需购买")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
var owned int64
|
||||
database.DB.Model(&model.UserSkin{}).Where("user_id = ? AND skin_id = ?", uid, skin.ID).Count(&owned)
|
||||
if owned > 0 {
|
||||
resp.Fail(c, "已拥有该皮肤")
|
||||
return
|
||||
}
|
||||
var balance int
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 扣积分并写流水(余额不足时事务整体回滚)
|
||||
b, err := service.ChangePoints(tx, uid, -skin.Price, model.PointTypeShop, skin.ID,
|
||||
fmt.Sprintf("购买皮肤「%s」", skin.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
balance = b
|
||||
// 发放皮肤(唯一索引防并发重复购买)
|
||||
return tx.Create(&model.UserSkin{UserID: uid, SkinID: skin.ID}).Error
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"balance": balance, "code": skin.Code})
|
||||
}
|
||||
|
||||
// usePropReq 使用道具请求体
|
||||
type usePropReq struct {
|
||||
PropCode string `json:"prop_code" binding:"required"` // 道具编码
|
||||
GameCode string `json:"game_code"` // 在哪个游戏中使用(记录用)
|
||||
}
|
||||
|
||||
// UseProp 游戏内使用道具:原子扣减库存,效果由前端游戏内生效
|
||||
func UseProp(c *gin.Context) {
|
||||
var req usePropReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
var prop model.Prop
|
||||
if err := database.DB.Where("code = ?", req.PropCode).First(&prop).Error; err != nil {
|
||||
resp.Fail(c, "道具不存在")
|
||||
return
|
||||
}
|
||||
// 原子扣减:数量不足时影响行数为 0
|
||||
res := database.DB.Exec(`UPDATE user_props SET quantity = quantity - 1
|
||||
WHERE user_id = ? AND prop_id = ? AND quantity > 0`, uid, prop.ID)
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
resp.Fail(c, "道具数量不足")
|
||||
return
|
||||
}
|
||||
// 写一条使用流水(积分变动为 0,仅作记录)
|
||||
var user model.User
|
||||
database.DB.First(&user, uid)
|
||||
database.DB.Create(&model.PointRecord{
|
||||
UserID: uid, ChangePoints: 0, Balance: user.Points,
|
||||
Type: model.PointTypeProp, Remark: fmt.Sprintf("在「%s」中使用道具「%s」", req.GameCode, prop.Name),
|
||||
})
|
||||
// 返回剩余数量
|
||||
var remain int
|
||||
database.DB.Model(&model.UserProp{}).Where("user_id = ? AND prop_id = ?", uid, prop.ID).
|
||||
Select("COALESCE(quantity,0)").Scan(&remain)
|
||||
resp.OK(c, gin.H{"remain": remain})
|
||||
}
|
||||
64
internal/handler/rank.go
Normal file
64
internal/handler/rank.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// rankUser 排行榜条目
|
||||
type rankUser struct {
|
||||
UserID int `json:"user_id"` // 用户ID
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Avatar string `json:"avatar"` // 头像
|
||||
TotalPoints int `json:"total_points"` // 累计积分
|
||||
}
|
||||
|
||||
// PointsRank 积分排行榜(前50) + 我的名次
|
||||
func PointsRank(c *gin.Context) {
|
||||
list := make([]rankUser, 0)
|
||||
database.DB.Table("users").
|
||||
Select("id AS user_id, nickname, avatar, total_points").
|
||||
Where("status = 1 AND deleted_at = 0").
|
||||
Order("total_points DESC, id ASC").Limit(50).Scan(&list)
|
||||
// 我的名次 = 比我累计积分高的人数 + 1
|
||||
uid := middleware.UID(c)
|
||||
var me model.User
|
||||
database.DB.First(&me, uid)
|
||||
var higher int64
|
||||
database.DB.Model(&model.User{}).
|
||||
Where("status = 1 AND (total_points > ? OR (total_points = ? AND id < ?))", me.TotalPoints, me.TotalPoints, uid).
|
||||
Count(&higher)
|
||||
resp.OK(c, gin.H{"list": list, "my_rank": higher + 1, "my_points": me.TotalPoints})
|
||||
}
|
||||
|
||||
// gameRankItem 单游戏排行条目
|
||||
type gameRankItem struct {
|
||||
UserID int `json:"user_id"` // 用户ID
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Avatar string `json:"avatar"` // 头像
|
||||
Best int `json:"best"` // 该游戏最高分
|
||||
}
|
||||
|
||||
// GameRank 指定游戏的最高分排行(每人取最高一条,前50)
|
||||
func GameRank(c *gin.Context) {
|
||||
var game model.Game
|
||||
if err := database.DB.Where("code = ?", c.Param("code")).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在")
|
||||
return
|
||||
}
|
||||
list := make([]gameRankItem, 0)
|
||||
database.DB.Raw(`SELECT t.user_id, u.nickname, u.avatar, t.best
|
||||
FROM (SELECT user_id, MAX(score) AS best FROM game_records WHERE game_id = ? GROUP BY user_id) t
|
||||
JOIN users u ON u.id = t.user_id AND u.status = 1 AND u.deleted_at = 0
|
||||
ORDER BY t.best DESC, t.user_id ASC LIMIT 50`, game.ID).Scan(&list)
|
||||
// 我的最高分
|
||||
var myBest int
|
||||
database.DB.Model(&model.GameRecord{}).
|
||||
Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).
|
||||
Select("COALESCE(MAX(score),0)").Scan(&myBest)
|
||||
resp.OK(c, gin.H{"game": game, "list": list, "my_best": myBest})
|
||||
}
|
||||
274
internal/handler/shop.go
Normal file
274
internal/handler/shop.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// PropList 道具商城列表(上架中)
|
||||
func PropList(c *gin.Context) {
|
||||
props := make([]model.Prop, 0)
|
||||
database.DB.Where("status = 1").Order("sort").Find(&props)
|
||||
resp.OK(c, props)
|
||||
}
|
||||
|
||||
// cartItem 购物车条目(附商品名称与价格)
|
||||
type cartItem struct {
|
||||
ID int `json:"id"` // 购物车ID
|
||||
ItemType int `json:"item_type"` // 1游戏 2道具
|
||||
ItemID int `json:"item_id"` // 商品ID
|
||||
Quantity int `json:"quantity"` // 数量
|
||||
Name string `json:"name"` // 商品名称
|
||||
Icon string `json:"icon"` // 商品图标
|
||||
Price int `json:"price"` // 单价(积分)
|
||||
}
|
||||
|
||||
// loadCartItems 加载用户购物车明细(游戏与道具分别联表取信息)
|
||||
func loadCartItems(uid int) []cartItem {
|
||||
list := make([]cartItem, 0)
|
||||
// 游戏类条目
|
||||
database.DB.Table("carts ct").
|
||||
Select("ct.id, ct.item_type, ct.item_id, ct.quantity, g.name, g.icon, g.price").
|
||||
Joins("JOIN games g ON g.id = ct.item_id").
|
||||
Where("ct.user_id = ? AND ct.item_type = ?", uid, model.ItemTypeGame).Scan(&list)
|
||||
// 道具类条目
|
||||
var propItems []cartItem
|
||||
database.DB.Table("carts ct").
|
||||
Select("ct.id, ct.item_type, ct.item_id, ct.quantity, p.name, p.icon, p.price").
|
||||
Joins("JOIN props p ON p.id = ct.item_id").
|
||||
Where("ct.user_id = ? AND ct.item_type = ?", uid, model.ItemTypeProp).Scan(&propItems)
|
||||
return append(list, propItems...)
|
||||
}
|
||||
|
||||
// CartList 我的购物车明细
|
||||
func CartList(c *gin.Context) {
|
||||
list := loadCartItems(middleware.UID(c))
|
||||
// 计算合计积分
|
||||
total := 0
|
||||
for _, it := range list {
|
||||
total += it.Price * it.Quantity
|
||||
}
|
||||
resp.OK(c, gin.H{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// addCartReq 加入购物车请求体
|
||||
type addCartReq struct {
|
||||
ItemType int `json:"item_type" binding:"required,oneof=1 2"` // 1游戏 2道具
|
||||
ItemID int `json:"item_id" binding:"required"` // 商品ID
|
||||
Quantity int `json:"quantity"` // 数量(默认为1)
|
||||
}
|
||||
|
||||
// AddCart 加入购物车:游戏查重不可重复购买,道具可累加数量
|
||||
func AddCart(c *gin.Context) {
|
||||
var req addCartReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
if req.Quantity < 1 {
|
||||
req.Quantity = 1
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
if req.ItemType == model.ItemTypeGame {
|
||||
// 游戏:校验存在、非免费、未拥有
|
||||
var game model.Game
|
||||
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在")
|
||||
return
|
||||
}
|
||||
if game.Price == 0 {
|
||||
resp.Fail(c, "免费游戏无需购买")
|
||||
return
|
||||
}
|
||||
var owned int64
|
||||
database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&owned)
|
||||
if owned > 0 {
|
||||
resp.Fail(c, "已拥有该游戏")
|
||||
return
|
||||
}
|
||||
req.Quantity = 1 // 游戏固定数量为1
|
||||
} else {
|
||||
var prop model.Prop
|
||||
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&prop).Error; err != nil {
|
||||
resp.Fail(c, "道具不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
// 已在购物车:道具累加数量,游戏保持1份不动
|
||||
var exist model.Cart
|
||||
err := database.DB.Where("user_id = ? AND item_type = ? AND item_id = ?", uid, req.ItemType, req.ItemID).First(&exist).Error
|
||||
if err == nil {
|
||||
if req.ItemType == model.ItemTypeProp {
|
||||
database.DB.Model(&exist).Update("quantity", exist.Quantity+req.Quantity)
|
||||
}
|
||||
resp.OK(c, nil)
|
||||
return
|
||||
}
|
||||
database.DB.Create(&model.Cart{UserID: uid, ItemType: req.ItemType, ItemID: req.ItemID, Quantity: req.Quantity})
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// RemoveCart 移除购物车条目(只能删自己的)
|
||||
func RemoveCart(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
database.DB.Where("id = ? AND user_id = ?", id, middleware.UID(c)).Delete(&model.Cart{})
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// genOrderNo 生成订单号(时间戳 + 4位随机)
|
||||
func genOrderNo() string {
|
||||
return fmt.Sprintf("PO%s%04d", time.Now().Format("20060102150405"), rand.Intn(10000))
|
||||
}
|
||||
|
||||
// purchaseItems 核心购买逻辑:事务内扣积分、写订单、发放游戏/道具
|
||||
// items 由调用方保证已校验(存在且可购买)
|
||||
func purchaseItems(uid int, items []cartItem) (*model.Order, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, errors.New("没有可购买的商品")
|
||||
}
|
||||
total := 0
|
||||
names := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
total += it.Price * it.Quantity
|
||||
names = append(names, it.Name)
|
||||
}
|
||||
var order model.Order
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 创建订单主体
|
||||
order = model.Order{
|
||||
OrderNo: genOrderNo(), UserID: uid, TotalPoints: total,
|
||||
Status: model.OrderStatusDone, Remark: strings.Join(names, "、"),
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 扣积分(余额不足时事务整体回滚)
|
||||
if _, err := service.ChangePoints(tx, uid, -total, model.PointTypeShop, order.ID,
|
||||
fmt.Sprintf("订单 %s 消费", order.OrderNo)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
// 写订单明细(价格快照)
|
||||
if err := tx.Create(&model.OrderItem{
|
||||
OrderID: order.ID, ItemType: it.ItemType, ItemID: it.ItemID,
|
||||
ItemName: it.Name, Price: it.Price, Quantity: it.Quantity,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if it.ItemType == model.ItemTypeGame {
|
||||
// 发放游戏(唯一索引防重复购买)
|
||||
if err := tx.Create(&model.UserGame{UserID: uid, GameID: it.ItemID, OrderID: order.ID}).Error; err != nil {
|
||||
return errors.New("已拥有该游戏,请勿重复购买")
|
||||
}
|
||||
} else {
|
||||
// 发放道具:已有记录累加,否则新建
|
||||
res := tx.Exec(`UPDATE user_props SET quantity = quantity + ? WHERE user_id = ? AND prop_id = ?`,
|
||||
it.Quantity, uid, it.ItemID)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
if err := tx.Create(&model.UserProp{UserID: uid, PropID: it.ItemID, Quantity: it.Quantity}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// Checkout 购物车结算下单
|
||||
func Checkout(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
items := loadCartItems(uid)
|
||||
order, err := purchaseItems(uid, items)
|
||||
if err != nil {
|
||||
resp.Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 下单成功后清空购物车
|
||||
database.DB.Where("user_id = ?", uid).Delete(&model.Cart{})
|
||||
resp.OK(c, order)
|
||||
}
|
||||
|
||||
// buyReq 立即购买请求体(跳过购物车直接下单)
|
||||
type buyReq struct {
|
||||
ItemType int `json:"item_type" binding:"required,oneof=1 2"` // 1游戏 2道具
|
||||
ItemID int `json:"item_id" binding:"required"` // 商品ID
|
||||
Quantity int `json:"quantity"` // 数量(仅道具)
|
||||
}
|
||||
|
||||
// BuyNow 立即购买单件商品
|
||||
func BuyNow(c *gin.Context) {
|
||||
var req buyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
if req.Quantity < 1 {
|
||||
req.Quantity = 1
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
var item cartItem
|
||||
if req.ItemType == model.ItemTypeGame {
|
||||
var game model.Game
|
||||
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&game).Error; err != nil {
|
||||
resp.Fail(c, "游戏不存在")
|
||||
return
|
||||
}
|
||||
if game.Price == 0 {
|
||||
resp.Fail(c, "免费游戏无需购买")
|
||||
return
|
||||
}
|
||||
var owned int64
|
||||
database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&owned)
|
||||
if owned > 0 {
|
||||
resp.Fail(c, "已拥有该游戏")
|
||||
return
|
||||
}
|
||||
item = cartItem{ItemType: 1, ItemID: game.ID, Quantity: 1, Name: game.Name, Price: game.Price}
|
||||
} else {
|
||||
var prop model.Prop
|
||||
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&prop).Error; err != nil {
|
||||
resp.Fail(c, "道具不存在")
|
||||
return
|
||||
}
|
||||
item = cartItem{ItemType: 2, ItemID: prop.ID, Quantity: req.Quantity, Name: prop.Name, Price: prop.Price}
|
||||
}
|
||||
order, err := purchaseItems(uid, []cartItem{item})
|
||||
if err != nil {
|
||||
resp.Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, order)
|
||||
}
|
||||
|
||||
// OrderList 我的订单列表(含明细,分页)
|
||||
func OrderList(c *gin.Context) {
|
||||
page, size, offset := pageParams(c)
|
||||
uid := middleware.UID(c)
|
||||
var total int64
|
||||
orders := make([]model.Order, 0)
|
||||
database.DB.Model(&model.Order{}).Where("user_id = ?", uid).Count(&total)
|
||||
database.DB.Preload("Items").Where("user_id = ?", uid).
|
||||
Order("id DESC").Limit(size).Offset(offset).Find(&orders)
|
||||
resp.OK(c, gin.H{"list": orders, "total": total, "page": page, "size": size})
|
||||
}
|
||||
143
internal/handler/signin.go
Normal file
143
internal/handler/signin.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// dayInt 把日期转成 yyyymmdd 整数(与 sign_ins.sign_day 对应)
|
||||
func dayInt(t time.Time) int {
|
||||
return t.Year()*10000 + int(t.Month())*100 + t.Day()
|
||||
}
|
||||
|
||||
// calcSignPoints 计算签到基础积分:基础 + 连签加成(有上限),不含VIP加成
|
||||
func calcSignPoints(streak int) int {
|
||||
base := service.GetConfigInt(model.ConfKeySignBasePoints, 10)
|
||||
step := service.GetConfigInt(model.ConfKeySignStreakStep, 5)
|
||||
capDays := service.GetConfigInt(model.ConfKeySignStreakCap, 7)
|
||||
if streak > capDays {
|
||||
streak = capDays
|
||||
}
|
||||
return base + step*(streak-1)
|
||||
}
|
||||
|
||||
// vipSigninBonus 查用户当前生效VIP等级与对应的签到加成积分(非VIP返回 0,0)
|
||||
func vipSigninBonus(uid int, now time.Time) (int, int) {
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, uid).Error; err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
level := service.UserVipLevel(&user, now)
|
||||
if level <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
var vl model.VipLevel
|
||||
if err := database.DB.Where("level = ?", level).First(&vl).Error; err != nil {
|
||||
return level, 0
|
||||
}
|
||||
return level, vl.SigninBonus
|
||||
}
|
||||
|
||||
// SignInStatus 签到页数据:本月已签日期、今日是否已签、连签天数与奖励预告(含VIP加成)
|
||||
func SignInStatus(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
now := time.Now()
|
||||
// 本月第一天的 yyyymmdd(用于筛选本月记录)
|
||||
monthStart := now.Year()*10000 + int(now.Month())*100 + 1
|
||||
var monthSigns []model.SignIn
|
||||
database.DB.Where("user_id = ? AND sign_day >= ?", uid, monthStart).Order("sign_day").Find(&monthSigns)
|
||||
signedDays := make([]int, 0, len(monthSigns))
|
||||
for _, s := range monthSigns {
|
||||
signedDays = append(signedDays, s.SignDay%100)
|
||||
}
|
||||
// 最近一次签到记录(用于计算今日/昨日连签)
|
||||
var last model.SignIn
|
||||
hasLast := database.DB.Where("user_id = ?", uid).Order("sign_day DESC").First(&last).Error == nil
|
||||
today := dayInt(now)
|
||||
yesterday := dayInt(now.AddDate(0, 0, -1))
|
||||
todaySigned := hasLast && last.SignDay == today
|
||||
// 计算"今天签到能连几天"(已签则取当前值)
|
||||
nextStreak := 1
|
||||
if hasLast {
|
||||
if last.SignDay == today {
|
||||
nextStreak = last.Streak
|
||||
} else if last.SignDay == yesterday {
|
||||
nextStreak = last.Streak + 1
|
||||
}
|
||||
}
|
||||
// VIP签到加成(随等级配置,非VIP为 0)
|
||||
vipLevel, vipBonus := vipSigninBonus(uid, now)
|
||||
resp.OK(c, gin.H{
|
||||
"year": now.Year(),
|
||||
"month": int(now.Month()),
|
||||
"today": now.Day(),
|
||||
"signed_days": signedDays, // 本月已签的"日"列表
|
||||
"today_signed": todaySigned, // 今日是否已签
|
||||
"streak": nextStreak, // 连签天数(今日签到后的值)
|
||||
"vip_level": vipLevel, // 当前生效VIP等级(0=非VIP)
|
||||
"vip_bonus": vipBonus, // VIP签到加成积分
|
||||
"today_points": calcSignPoints(nextStreak) + vipBonus, // 今日签到可得/已得积分(含加成)
|
||||
"next_points": calcSignPoints(nextStreak+1) + vipBonus, // 明日签到可得积分(预告,含加成)
|
||||
})
|
||||
}
|
||||
|
||||
// DoSignIn 执行签到:防重复、算连签、发积分(基础 + 连签 + VIP加成)
|
||||
func DoSignIn(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
now := time.Now()
|
||||
today := dayInt(now)
|
||||
yesterday := dayInt(now.AddDate(0, 0, -1))
|
||||
// 今日是否已签到
|
||||
var cnt int64
|
||||
database.DB.Model(&model.SignIn{}).Where("user_id = ? AND sign_day = ?", uid, today).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
resp.Fail(c, "今天已经签过啦")
|
||||
return
|
||||
}
|
||||
// 昨天有签则连签 +1,否则从 1 重新计
|
||||
streak := 1
|
||||
var last model.SignIn
|
||||
if database.DB.Where("user_id = ?", uid).Order("sign_day DESC").First(&last).Error == nil {
|
||||
if last.SignDay == yesterday {
|
||||
streak = last.Streak + 1
|
||||
}
|
||||
}
|
||||
// 总积分 = 基础 + 连签加成 + VIP加成
|
||||
vipLevel, vipBonus := vipSigninBonus(uid, now)
|
||||
points := calcSignPoints(streak) + vipBonus
|
||||
remark := fmt.Sprintf("每日签到(连签 %d 天)", streak)
|
||||
if vipBonus > 0 {
|
||||
remark = fmt.Sprintf("每日签到(连签 %d 天,VIP%d 加成 +%d)", streak, vipLevel, vipBonus)
|
||||
}
|
||||
// 签到落库与积分发放在同一事务:任一步失败整体回滚,
|
||||
// 避免"签到行已写入(当天唯一约束挡住重签)但积分没到账"的中间态
|
||||
var balance int
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
sign := model.SignIn{UserID: uid, SignDay: today, Streak: streak, Points: points}
|
||||
if err := tx.Create(&sign).Error; err != nil {
|
||||
// 唯一索引兜底(并发下重复签到会触发此分支)
|
||||
return errors.New("签到失败,请稍后重试")
|
||||
}
|
||||
b, err := service.ChangePoints(tx, uid, points, model.PointTypeSignIn, sign.ID, remark)
|
||||
if err != nil {
|
||||
return errors.New("积分发放失败")
|
||||
}
|
||||
balance = b
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"points": points, "streak": streak, "balance": balance, "vip_bonus": vipBonus})
|
||||
}
|
||||
277
internal/handler/user.go
Normal file
277
internal/handler/user.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// pageParams 解析分页参数(页码默认1、每页默认10、上限50)
|
||||
func pageParams(c *gin.Context) (page, size, offset int) {
|
||||
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ = strconv.Atoi(c.DefaultQuery("size", "10"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
// 上限 500:支持前端「批量拉取 + 虚拟滚动」模式一次取一大页(查询始终带 LIMIT,无全表风险)
|
||||
if size < 1 || size > 500 {
|
||||
size = 10
|
||||
}
|
||||
return page, size, (page - 1) * size
|
||||
}
|
||||
|
||||
// Profile 获取当前用户资料
|
||||
func Profile(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, middleware.UID(c)).Error; err != nil {
|
||||
resp.Unauthorized(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
resp.OK(c, user)
|
||||
}
|
||||
|
||||
// updateProfileReq 修改资料请求体
|
||||
type updateProfileReq struct {
|
||||
Nickname string `json:"nickname" binding:"required,max=20"` // 新昵称
|
||||
Avatar string `json:"avatar" binding:"required,max=8"` // 新 emoji 头像
|
||||
}
|
||||
|
||||
// UpdateProfile 修改昵称与头像
|
||||
func UpdateProfile(c *gin.Context) {
|
||||
var req updateProfileReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
database.DB.Model(&model.User{}).Where("id = ?", middleware.UID(c)).
|
||||
Updates(map[string]any{"nickname": req.Nickname, "avatar": req.Avatar})
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
|
||||
// PointRecords 我的积分流水(分页)
|
||||
func PointRecords(c *gin.Context) {
|
||||
page, size, offset := pageParams(c)
|
||||
uid := middleware.UID(c)
|
||||
var total int64
|
||||
list := make([]model.PointRecord, 0)
|
||||
database.DB.Model(&model.PointRecord{}).Where("user_id = ?", uid).Count(&total)
|
||||
database.DB.Where("user_id = ?", uid).Order("id DESC").Limit(size).Offset(offset).Find(&list)
|
||||
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// LoginLogs 我的登录记录(分页)
|
||||
func LoginLogs(c *gin.Context) {
|
||||
page, size, offset := pageParams(c)
|
||||
uid := middleware.UID(c)
|
||||
var total int64
|
||||
list := make([]model.LoginLog, 0)
|
||||
database.DB.Model(&model.LoginLog{}).Where("user_id = ?", uid).Count(&total)
|
||||
database.DB.Where("user_id = ?", uid).Order("id DESC").Limit(size).Offset(offset).Find(&list)
|
||||
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// gameRecordItem 游戏记录列表项(附游戏名称与图标)
|
||||
type gameRecordItem struct {
|
||||
model.GameRecord
|
||||
GameName string `json:"game_name"` // 游戏名称
|
||||
GameIcon string `json:"game_icon"` // 游戏图标
|
||||
}
|
||||
|
||||
// GameRecords 我的单机游戏记录(分页,联表补游戏信息)
|
||||
func GameRecords(c *gin.Context) {
|
||||
page, size, offset := pageParams(c)
|
||||
uid := middleware.UID(c)
|
||||
var total int64
|
||||
list := make([]gameRecordItem, 0)
|
||||
database.DB.Model(&model.GameRecord{}).Where("user_id = ?", uid).Count(&total)
|
||||
database.DB.Table("game_records gr").
|
||||
Select("gr.*, g.name AS game_name, g.icon AS game_icon").
|
||||
Joins("LEFT JOIN games g ON g.id = gr.game_id").
|
||||
Where("gr.user_id = ?", uid).
|
||||
Order("gr.id DESC").Limit(size).Offset(offset).Scan(&list)
|
||||
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// battleRecordItem 对战记录列表项(附游戏名称与图标)
|
||||
type battleRecordItem struct {
|
||||
model.BattleRecord
|
||||
GameName string `json:"game_name"` // 游戏名称
|
||||
GameIcon string `json:"game_icon"` // 游戏图标
|
||||
}
|
||||
|
||||
// BattleRecords 我的联机对战记录(分页)
|
||||
func BattleRecords(c *gin.Context) {
|
||||
page, size, offset := pageParams(c)
|
||||
uid := middleware.UID(c)
|
||||
var total int64
|
||||
list := make([]battleRecordItem, 0)
|
||||
database.DB.Model(&model.BattleRecord{}).Where("user_id = ?", uid).Count(&total)
|
||||
database.DB.Table("battle_records br").
|
||||
Select("br.*, g.name AS game_name, g.icon AS game_icon").
|
||||
Joins("LEFT JOIN games g ON g.id = br.game_id").
|
||||
Where("br.user_id = ?", uid).
|
||||
Order("br.id DESC").Limit(size).Offset(offset).Scan(&list)
|
||||
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// userPropItem 用户道具列表项(附道具详情)
|
||||
type userPropItem struct {
|
||||
PropID int `json:"prop_id"` // 道具ID
|
||||
Code string `json:"code"` // 道具编码
|
||||
Name string `json:"name"` // 道具名称
|
||||
Icon string `json:"icon"` // 图标
|
||||
Description string `json:"description"` // 用途说明
|
||||
Quantity int `json:"quantity"` // 持有数量
|
||||
}
|
||||
|
||||
// UserProps 我的道具背包(只返回数量大于0的)
|
||||
func UserProps(c *gin.Context) {
|
||||
list := make([]userPropItem, 0)
|
||||
database.DB.Table("user_props up").
|
||||
Select("up.prop_id, p.code, p.name, p.icon, p.description, up.quantity").
|
||||
Joins("JOIN props p ON p.id = up.prop_id").
|
||||
Where("up.user_id = ? AND up.quantity > 0", middleware.UID(c)).
|
||||
Order("p.sort").Scan(&list)
|
||||
resp.OK(c, list)
|
||||
}
|
||||
|
||||
// UserGames 我已购买的游戏ID列表(判断解锁状态用)
|
||||
func UserGames(c *gin.Context) {
|
||||
ids := make([]int, 0)
|
||||
database.DB.Model(&model.UserGame{}).Where("user_id = ?", middleware.UID(c)).Pluck("game_id", &ids)
|
||||
resp.OK(c, ids)
|
||||
}
|
||||
|
||||
// myGameItem 「我的游戏」列表项:游戏信息 + 来源 + 我的游玩统计
|
||||
type myGameItem struct {
|
||||
model.Game
|
||||
Source string `json:"source"` // 来源:bought=已购 weekly=本周周免 played=玩过的免费游戏
|
||||
GotAt int64 `json:"got_at"` // 获得时间(已购=购买时间,其余=0)
|
||||
Plays int `json:"plays"` // 我的总局数(单机+对战)
|
||||
Best int `json:"best"` // 我的最高分(单机游戏)
|
||||
LastPlayed int64 `json:"last_played"` // 最近一次游玩时间(0=没玩过)
|
||||
}
|
||||
|
||||
// UserMyGames 个人中心「我的游戏」:已购游戏 + 本周VIP周免 + 玩过的免费游戏,附游玩统计
|
||||
// 排序:已购按购买时间倒序 → 周免 → 玩过的按最近游玩倒序
|
||||
func UserMyGames(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
// 我的游玩统计(单机记录:局数/最高分/最近时间,按游戏聚合)
|
||||
type statRow struct {
|
||||
GameID int `gorm:"column:game_id"`
|
||||
Plays int `gorm:"column:plays"`
|
||||
Best int `gorm:"column:best"`
|
||||
Last int64 `gorm:"column:last"`
|
||||
}
|
||||
statMap := map[int]statRow{}
|
||||
var stats []statRow
|
||||
database.DB.Raw(`SELECT game_id, COUNT(*) AS plays, MAX(score) AS best, MAX(created_at) AS last
|
||||
FROM game_records WHERE user_id = ? GROUP BY game_id`, uid).Scan(&stats)
|
||||
for _, s := range stats {
|
||||
statMap[s.GameID] = s
|
||||
}
|
||||
// 对战局数并入统计(联机游戏没有单机得分,best 保持 0)
|
||||
var bstats []statRow
|
||||
database.DB.Raw(`SELECT game_id, COUNT(*) AS plays, 0 AS best, MAX(created_at) AS last
|
||||
FROM battle_records WHERE user_id = ? GROUP BY game_id`, uid).Scan(&bstats)
|
||||
for _, s := range bstats {
|
||||
m := statMap[s.GameID]
|
||||
m.GameID = s.GameID
|
||||
m.Plays += s.Plays
|
||||
if s.Last > m.Last {
|
||||
m.Last = s.Last
|
||||
}
|
||||
statMap[s.GameID] = m
|
||||
}
|
||||
// 已购游戏(购买时间倒序)
|
||||
var ugs []model.UserGame
|
||||
database.DB.Where("user_id = ?", uid).Order("id DESC").Find(&ugs)
|
||||
boughtAt := map[int]int64{}
|
||||
boughtOrder := make([]int, 0, len(ugs))
|
||||
for _, ug := range ugs {
|
||||
if _, ok := boughtAt[ug.GameID]; !ok {
|
||||
boughtAt[ug.GameID] = ug.CreatedAt
|
||||
boughtOrder = append(boughtOrder, ug.GameID)
|
||||
}
|
||||
}
|
||||
// 本周周免集合(非VIP为空)
|
||||
weeklySet := map[int]bool{}
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, uid).Error; err == nil {
|
||||
weeklySet = service.VipWeeklyFreeSet(&user, time.Now())
|
||||
}
|
||||
// 汇总需要展示的游戏ID:已购 + 周免 + 玩过的
|
||||
idSet := map[int]bool{}
|
||||
for _, id := range boughtOrder {
|
||||
idSet[id] = true
|
||||
}
|
||||
for id := range weeklySet {
|
||||
idSet[id] = true
|
||||
}
|
||||
for id := range statMap {
|
||||
idSet[id] = true
|
||||
}
|
||||
if len(idSet) == 0 {
|
||||
resp.OK(c, []myGameItem{})
|
||||
return
|
||||
}
|
||||
ids := make([]int, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var games []model.Game
|
||||
database.DB.Where("id IN ? AND status = 1", ids).Find(&games)
|
||||
gameMap := map[int]model.Game{}
|
||||
for _, g := range games {
|
||||
gameMap[g.ID] = g
|
||||
}
|
||||
build := func(id int, source string) (myGameItem, bool) {
|
||||
g, ok := gameMap[id]
|
||||
if !ok {
|
||||
return myGameItem{}, false
|
||||
}
|
||||
s := statMap[id]
|
||||
return myGameItem{
|
||||
Game: g, Source: source, GotAt: boughtAt[id],
|
||||
Plays: s.Plays, Best: s.Best, LastPlayed: s.Last,
|
||||
}, true
|
||||
}
|
||||
list := make([]myGameItem, 0, len(idSet))
|
||||
// 1. 已购(购买时间倒序;已下架的会被 build 过滤)
|
||||
for _, id := range boughtOrder {
|
||||
if it, ok := build(id, "bought"); ok {
|
||||
list = append(list, it)
|
||||
}
|
||||
}
|
||||
boughtCount := len(list)
|
||||
// 2. 本周周免(未购买的才标周免),按游戏排序权重稳定展示
|
||||
for id := range weeklySet {
|
||||
if boughtAt[id] == 0 {
|
||||
if it, ok := build(id, "weekly"); ok {
|
||||
list = append(list, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
weeklyPart := list[boughtCount:]
|
||||
sort.SliceStable(weeklyPart, func(i, j int) bool { return weeklyPart[i].Sort < weeklyPart[j].Sort })
|
||||
// 3. 玩过的其它游戏(免费游戏等,按最近游玩倒序)
|
||||
played := make([]myGameItem, 0)
|
||||
for id := range statMap {
|
||||
if boughtAt[id] == 0 && !weeklySet[id] {
|
||||
if it, ok := build(id, "played"); ok {
|
||||
played = append(played, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(played, func(i, j int) bool { return played[i].LastPlayed > played[j].LastPlayed })
|
||||
list = append(list, played...)
|
||||
resp.OK(c, list)
|
||||
}
|
||||
174
internal/handler/vip.go
Normal file
174
internal/handler/vip.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/internal/service"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// vipGameBrief 周免游戏的精简信息(VIP页展示用)
|
||||
type vipGameBrief struct {
|
||||
ID int `json:"id"` // 游戏ID
|
||||
Code string `json:"code"` // 游戏编码
|
||||
Name string `json:"name"` // 游戏名称
|
||||
Icon string `json:"icon"` // emoji 图标
|
||||
Price int `json:"price"` // 原价(积分)
|
||||
}
|
||||
|
||||
// vipLevelItem VIP等级 + 本周周免批次
|
||||
type vipLevelItem struct {
|
||||
model.VipLevel
|
||||
WeeklyGames []vipGameBrief `json:"weekly_games"` // 该等级本周的周免游戏
|
||||
}
|
||||
|
||||
// VipInfo VIP中心数据:我的VIP状态、5个等级的权益与本周周免批次、下次刷新时间
|
||||
func VipInfo(c *gin.Context) {
|
||||
uid := middleware.UID(c)
|
||||
now := time.Now()
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, uid).Error; err != nil {
|
||||
resp.Fail(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
var levels []model.VipLevel
|
||||
database.DB.Order("level").Find(&levels)
|
||||
pool := service.VipWeeklyPool(now)
|
||||
myLevel := service.UserVipLevel(&user, now)
|
||||
list := make([]vipLevelItem, 0, len(levels))
|
||||
myName := ""
|
||||
for _, l := range levels {
|
||||
batch := service.VipWeeklyBatch(pool, levels, l.Level)
|
||||
games := make([]vipGameBrief, 0, len(batch))
|
||||
for _, g := range batch {
|
||||
games = append(games, vipGameBrief{ID: g.ID, Code: g.Code, Name: g.Name, Icon: g.Icon, Price: g.Price})
|
||||
}
|
||||
list = append(list, vipLevelItem{VipLevel: l, WeeklyGames: games})
|
||||
if l.Level == myLevel {
|
||||
myName = l.Name
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{
|
||||
"my": gin.H{
|
||||
"level": myLevel, // 当前生效等级(过期后为 0)
|
||||
"level_name": myName, // 等级名称
|
||||
"expire": user.VipExpire, // 到期时间戳(秒)
|
||||
"active": myLevel > 0, // 是否生效中
|
||||
},
|
||||
"refresh_at": service.VipNextRefresh(now).Unix(), // 下次周免刷新时间(周五12:00)
|
||||
"levels": list,
|
||||
})
|
||||
}
|
||||
|
||||
// vipBuyReq 购买VIP请求体
|
||||
type vipBuyReq struct {
|
||||
Level int `json:"level" binding:"required,min=1,max=5"` // 目标等级(1~5)
|
||||
}
|
||||
|
||||
// VipBuy 开通/续费/升级VIP:扣积分并更新等级与到期时间(30天/次)
|
||||
// 规则:同等级续费在原到期时间上顺延;升级立即生效、按30天重新计时;不允许降级购买
|
||||
func VipBuy(c *gin.Context) {
|
||||
var req vipBuyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
uid := middleware.UID(c)
|
||||
var target model.VipLevel
|
||||
if err := database.DB.Where("level = ?", req.Level).First(&target).Error; err != nil {
|
||||
resp.Fail(c, "VIP等级不存在")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
var newExpire int64
|
||||
var balance int
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, uid).Error; err != nil {
|
||||
return fmt.Errorf("用户不存在")
|
||||
}
|
||||
curLevel := service.UserVipLevel(&user, now)
|
||||
if curLevel > req.Level {
|
||||
return fmt.Errorf("当前已是更高等级(VIP%d),无需降级购买", curLevel)
|
||||
}
|
||||
// 计算新到期时间:同级续费顺延,升级/新开从现在起算30天
|
||||
if curLevel == req.Level {
|
||||
newExpire = user.VipExpire + 30*86400
|
||||
} else {
|
||||
newExpire = now.Unix() + 30*86400
|
||||
}
|
||||
// 扣积分并记流水(复用事务,余额不足会报错回滚)
|
||||
b, err := service.ChangePoints(tx, uid, -target.PricePoints, model.PointTypeVIP, target.ID,
|
||||
fmt.Sprintf("开通VIP%d·%s(30天)", target.Level, target.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
balance = b
|
||||
return tx.Model(&model.User{}).Where("id = ?", uid).
|
||||
Updates(map[string]any{"vip_level": req.Level, "vip_expire": newExpire}).Error
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"level": req.Level, "expire": newExpire, "balance": balance})
|
||||
}
|
||||
|
||||
// AdminVipLevels 后台:VIP等级配置列表
|
||||
func AdminVipLevels(c *gin.Context) {
|
||||
var levels []model.VipLevel
|
||||
database.DB.Order("level").Find(&levels)
|
||||
resp.OK(c, levels)
|
||||
}
|
||||
|
||||
// adminUpdateVipReq 后台修改VIP等级配置请求体(nil=不修改)
|
||||
type adminUpdateVipReq struct {
|
||||
Name *string `json:"name"` // 等级名称
|
||||
PricePoints *int `json:"price_points"` // 30天价格(积分)
|
||||
WeeklyFreeQuota *int `json:"weekly_free_quota"` // 周免游戏数量
|
||||
SigninBonus *int `json:"signin_bonus"` // 签到加成积分
|
||||
Description *string `json:"description"` // 权益描述
|
||||
}
|
||||
|
||||
// AdminUpdateVipLevel 后台:修改某一VIP等级的价格与权益
|
||||
func AdminUpdateVipLevel(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var req adminUpdateVipReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, "参数有误")
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.Name != nil && *req.Name != "" {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.PricePoints != nil && *req.PricePoints >= 0 {
|
||||
updates["price_points"] = *req.PricePoints
|
||||
}
|
||||
if req.WeeklyFreeQuota != nil && *req.WeeklyFreeQuota >= 0 {
|
||||
updates["weekly_free_quota"] = *req.WeeklyFreeQuota
|
||||
}
|
||||
if req.SigninBonus != nil && *req.SigninBonus >= 0 {
|
||||
updates["signin_bonus"] = *req.SigninBonus
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
resp.Fail(c, "没有需要修改的内容")
|
||||
return
|
||||
}
|
||||
if err := database.DB.Model(&model.VipLevel{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
resp.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
80
internal/middleware/middleware.go
Normal file
80
internal/middleware/middleware.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Package middleware 提供 Gin 中间件:跨域、JWT 鉴权、超管校验
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/pkg/jwtutil"
|
||||
"nl-game-api-gin/pkg/resp"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件:开发时前端(5173)与后端(8080)端口不同,需要放行跨域请求
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
}
|
||||
// 预检请求直接返回 204
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Auth JWT 鉴权中间件:从 Authorization: Bearer xxx 中解析用户身份
|
||||
// 校验通过后将 user_id 与 role 写入上下文供后续处理函数使用
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token == "" || token == authHeader {
|
||||
resp.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
claims, err := jwtutil.Parse(token)
|
||||
if err != nil {
|
||||
resp.Unauthorized(c, "登录已过期,请重新登录")
|
||||
return
|
||||
}
|
||||
// 查库校验账号仍然有效:否则封禁/删除的用户在 Token 过期前仍可操作全部接口
|
||||
var user model.User
|
||||
if err := database.DB.Select("status").First(&user, claims.UserID).Error; err != nil {
|
||||
resp.Unauthorized(c, "账号不存在,请重新登录")
|
||||
return
|
||||
}
|
||||
if user.Status == model.UserStatusBanned {
|
||||
resp.Forbidden(c, "账号已被禁用")
|
||||
return
|
||||
}
|
||||
// 身份信息写入请求上下文
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly 超管校验中间件:需在 Auth 之后使用,非超管直接拒绝
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.GetInt("role") != model.RoleAdmin {
|
||||
resp.Forbidden(c, "仅超级管理员可操作")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UID 从上下文取当前登录用户ID的快捷方法
|
||||
func UID(c *gin.Context) int {
|
||||
return c.GetInt("user_id")
|
||||
}
|
||||
81
internal/model/game.go
Normal file
81
internal/model/game.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package model
|
||||
|
||||
import "gorm.io/plugin/soft_delete"
|
||||
|
||||
// 游戏引擎类型常量
|
||||
const (
|
||||
EngineCanvas = "canvas" // 2D 画布游戏
|
||||
EngineThree = "three" // Three.js 三维游戏
|
||||
EngineOnline = "online" // WebSocket 联机游戏
|
||||
)
|
||||
|
||||
// Game 游戏表:平台全部游戏(37 款)
|
||||
type Game struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 游戏ID
|
||||
Code string `gorm:"size:32;uniqueIndex" json:"code"` // 游戏编码(前端据此加载组件)
|
||||
Name string `gorm:"size:32" json:"name"` // 游戏名称
|
||||
Category string `gorm:"size:16" json:"category"` // 分类:益智/街机/动作/射击/棋牌/休闲/3D/联机
|
||||
Icon string `gorm:"size:16" json:"icon"` // emoji 图标
|
||||
Description string `gorm:"size:255" json:"description"` // 玩法简介
|
||||
Price int `gorm:"default:0" json:"price"` // 售价(积分,0=免费)
|
||||
Engine string `gorm:"size:16" json:"engine"` // 实现引擎:canvas/three/online
|
||||
ScorePerPoint int `gorm:"default:10" json:"score_per_point"` // 多少游戏得分兑1积分(0=不兑换)
|
||||
MaxPointsPerPlay int `gorm:"default:50" json:"max_points_per_play"` // 单局积分上限(防刷)
|
||||
PlayCount int `gorm:"default:0" json:"play_count"` // 累计游玩次数
|
||||
Sort int `gorm:"default:0" json:"sort"` // 排序权重
|
||||
Carousel int `gorm:"default:0" json:"carousel"` // 大厅轮播:0=不参与,>0=参与且按值升序排列
|
||||
Status int `gorm:"default:1" json:"status"` // 状态:1上架 2下架
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
DeletedAt soft_delete.DeletedAt `gorm:"default:0" json:"-"` // 软删除时间(0=未删除)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Game) TableName() string { return "games" }
|
||||
|
||||
// GameRecord 游玩记录表:单机游戏每局结算写一条
|
||||
type GameRecord struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 记录ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 玩家用户ID
|
||||
GameID int `gorm:"index" json:"game_id"` // 游戏ID
|
||||
Score int `gorm:"default:0" json:"score"` // 本局得分
|
||||
PointsGained int `gorm:"default:0" json:"points_gained"` // 本局获得的平台积分
|
||||
Duration int `gorm:"default:0" json:"duration"` // 游玩时长(秒)
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 结算时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (GameRecord) TableName() string { return "game_records" }
|
||||
|
||||
// 对战模式常量
|
||||
const (
|
||||
BattleModePVP = 1 // 好友联机
|
||||
BattleModeAI = 2 // 人机对战
|
||||
)
|
||||
|
||||
// 对战结果常量
|
||||
const (
|
||||
BattleResultWin = 1 // 胜利
|
||||
BattleResultLose = 2 // 失败
|
||||
BattleResultDraw = 3 // 平局
|
||||
)
|
||||
|
||||
// BattleRecord 对战记录表:斗地主/象棋每局结束后,每个真人玩家写一条
|
||||
type BattleRecord struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 记录ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 玩家用户ID
|
||||
GameID int `json:"game_id"` // 游戏ID(斗地主或象棋)
|
||||
RoomCode string `gorm:"size:16" json:"room_code"` // 房间邀请码
|
||||
Mode int `gorm:"default:1" json:"mode"` // 模式:1好友联机 2人机
|
||||
// 注意:AI 不在 GORM 常见缩写词表内,默认会被映射成 a_iprovider,必须显式指定列名
|
||||
AIProvider string `gorm:"size:16;column:ai_provider" json:"ai_provider"` // AI 提供方:spark/deepseek/rule
|
||||
AIDifficulty string `gorm:"size:16;column:ai_difficulty" json:"ai_difficulty"` // AI 难度:easy/medium/hard
|
||||
Result int `gorm:"default:1" json:"result"` // 结果:1胜 2负 3平
|
||||
PointsChange int `gorm:"default:0" json:"points_change"` // 积分变动(胜利奖励)
|
||||
Duration int `gorm:"default:0" json:"duration"` // 对局时长(秒)
|
||||
Detail string `gorm:"size:255" json:"detail"` // 对局详情摘要
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 结算时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (BattleRecord) TableName() string { return "battle_records" }
|
||||
41
internal/model/point.go
Normal file
41
internal/model/point.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
// 积分流水类型常量
|
||||
const (
|
||||
PointTypeSignIn = 1 // 每日签到
|
||||
PointTypeGame = 2 // 游戏奖励
|
||||
PointTypeShop = 3 // 商城消费
|
||||
PointTypeBattle = 4 // 对战奖励
|
||||
PointTypeProp = 5 // 道具使用
|
||||
PointTypeRegister = 6 // 注册赠送
|
||||
PointTypeAdmin = 7 // 后台调整
|
||||
PointTypeVIP = 8 // 开通/续费VIP
|
||||
)
|
||||
|
||||
// PointRecord 积分流水表:所有积分变动都记一条
|
||||
type PointRecord struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 流水ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 用户ID
|
||||
ChangePoints int `gorm:"default:0" json:"change_points"` // 变动值(正=获得,负=消耗)
|
||||
Balance int `gorm:"default:0" json:"balance"` // 变动后余额快照
|
||||
Type int `gorm:"default:1" json:"type"` // 类型:1签到 2游戏 3消费 4对战 5道具 6注册 7后台
|
||||
RelatedID int `gorm:"default:0" json:"related_id"` // 关联业务ID(订单/记录ID)
|
||||
Remark string `gorm:"size:255" json:"remark"` // 备注说明
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 发生时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PointRecord) TableName() string { return "point_records" }
|
||||
|
||||
// SignIn 签到记录表:每人每天一条
|
||||
type SignIn struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 签到ID
|
||||
UserID int `json:"user_id"` // 用户ID
|
||||
SignDay int `json:"sign_day"` // 签到日期(yyyymmdd 整数)
|
||||
Streak int `gorm:"default:1" json:"streak"` // 连续签到天数
|
||||
Points int `gorm:"default:0" json:"points"` // 本次获得积分
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 签到时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SignIn) TableName() string { return "sign_ins" }
|
||||
14
internal/model/progress.go
Normal file
14
internal/model/progress.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
// GameProgress 关卡进度表:记录用户在带关卡游戏里解锁到的最高关(一人一游戏一条)
|
||||
type GameProgress struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 进度ID
|
||||
UserID int `gorm:"uniqueIndex:uk_user_game" json:"user_id"` // 用户ID
|
||||
GameID int `gorm:"uniqueIndex:uk_user_game" json:"game_id"` // 游戏ID
|
||||
Level int `gorm:"default:1" json:"level"` // 已解锁的最高关卡(从 1 开始,通关第 N 关后为 N+1)
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 首次记录时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 最近推进时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (GameProgress) TableName() string { return "game_progress" }
|
||||
17
internal/model/save.go
Normal file
17
internal/model/save.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
// GameSave 游戏存档表:支持存档的单机游戏(如饥荒)一人一游戏一份快照
|
||||
// 快照内容由前端游戏自行序列化(JSON 字符串),后端只负责存取
|
||||
type GameSave struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 存档ID
|
||||
UserID int `gorm:"uniqueIndex:uk_user_game" json:"user_id"` // 玩家用户ID
|
||||
GameID int `gorm:"uniqueIndex:uk_user_game" json:"game_id"` // 游戏ID
|
||||
Day int `gorm:"default:1" json:"day"` // 存档进度天数(列表/继续面板展示用)
|
||||
Score int `gorm:"default:0" json:"score"` // 存档时分数(展示用)
|
||||
Data string `gorm:"type:mediumtext" json:"data"` // 游戏状态快照(JSON 字符串,前端序列化)
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (GameSave) TableName() string { return "game_saves" }
|
||||
104
internal/model/shop.go
Normal file
104
internal/model/shop.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package model
|
||||
|
||||
import "gorm.io/plugin/soft_delete"
|
||||
|
||||
// 商品类型常量(购物车与订单明细共用)
|
||||
const (
|
||||
ItemTypeGame = 1 // 游戏
|
||||
ItemTypeProp = 2 // 道具
|
||||
)
|
||||
|
||||
// 订单状态常量
|
||||
const (
|
||||
OrderStatusDone = 1 // 已完成(积分即时支付)
|
||||
OrderStatusCanceled = 2 // 已取消
|
||||
)
|
||||
|
||||
// Cart 购物车表:加入购物车但未结算的商品
|
||||
type Cart struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 购物车项ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 所属用户ID
|
||||
ItemType int `gorm:"default:1" json:"item_type"` // 商品类型:1游戏 2道具
|
||||
ItemID int `json:"item_id"` // 商品ID
|
||||
Quantity int `gorm:"default:1" json:"quantity"` // 数量(游戏固定1)
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 加入时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Cart) TableName() string { return "carts" }
|
||||
|
||||
// Order 订单表:购物车结算生成,用积分支付
|
||||
type Order struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 订单ID
|
||||
OrderNo string `gorm:"size:32;uniqueIndex" json:"order_no"` // 订单号
|
||||
UserID int `gorm:"index" json:"user_id"` // 下单用户ID
|
||||
TotalPoints int `gorm:"default:0" json:"total_points"` // 订单总价(积分)
|
||||
Status int `gorm:"default:1" json:"status"` // 状态:1已完成 2已取消
|
||||
Remark string `gorm:"size:255" json:"remark"` // 备注(商品概要)
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 下单时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
DeletedAt soft_delete.DeletedAt `gorm:"default:0" json:"-"` // 软删除(0=未删除)
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` // 订单明细(关联查询用)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Order) TableName() string { return "orders" }
|
||||
|
||||
// OrderItem 订单明细表:订单内每个商品一条(保存快照)
|
||||
type OrderItem struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 明细ID
|
||||
OrderID int `gorm:"index" json:"order_id"` // 所属订单ID
|
||||
ItemType int `gorm:"default:1" json:"item_type"` // 商品类型:1游戏 2道具
|
||||
ItemID int `json:"item_id"` // 商品ID
|
||||
ItemName string `gorm:"size:32" json:"item_name"` // 名称快照
|
||||
Price int `gorm:"default:0" json:"price"` // 单价快照(积分)
|
||||
Quantity int `gorm:"default:1" json:"quantity"` // 数量
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (OrderItem) TableName() string { return "order_items" }
|
||||
|
||||
// UserGame 用户已购游戏表:付费游戏解锁记录
|
||||
type UserGame struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 记录ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 用户ID
|
||||
GameID int `json:"game_id"` // 已解锁游戏ID
|
||||
OrderID int `gorm:"default:0" json:"order_id"` // 来源订单ID(0=赠送)
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 解锁时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (UserGame) TableName() string { return "user_games" }
|
||||
|
||||
// Prop 道具表:局内道具商品
|
||||
type Prop struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 道具ID
|
||||
Code string `gorm:"size:32;uniqueIndex" json:"code"` // 道具编码(游戏内识别效果)
|
||||
Name string `gorm:"size:32" json:"name"` // 道具名称
|
||||
Icon string `gorm:"size:16" json:"icon"` // emoji 图标
|
||||
Description string `gorm:"size:255" json:"description"` // 效果说明
|
||||
Price int `gorm:"default:0" json:"price"` // 售价(积分/个)
|
||||
Sort int `gorm:"default:0" json:"sort"` // 排序权重
|
||||
Status int `gorm:"default:1" json:"status"` // 状态:1上架 2下架
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
DeletedAt soft_delete.DeletedAt `gorm:"default:0" json:"-"` // 软删除(0=未删除)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Prop) TableName() string { return "props" }
|
||||
|
||||
// UserProp 用户道具背包表:持有道具及数量
|
||||
type UserProp struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 记录ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 用户ID
|
||||
PropID int `json:"prop_id"` // 道具ID
|
||||
Quantity int `gorm:"default:0" json:"quantity"` // 持有数量
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (UserProp) TableName() string { return "user_props" }
|
||||
23
internal/model/skin.go
Normal file
23
internal/model/skin.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
// GameSkin 游戏皮肤定义(更衣室商城):price=0 视为默认解锁
|
||||
type GameSkin struct {
|
||||
ID int `gorm:"primaryKey" json:"id"`
|
||||
GameCode string `gorm:"size:32;index" json:"game_code"` // 所属游戏编码(如 starve)
|
||||
Code string `gorm:"size:32" json:"code"` // 皮肤编码(前端绘制参数表的键)
|
||||
Name string `gorm:"size:32" json:"name"` // 展示名称
|
||||
Price int `gorm:"default:0" json:"price"` // 售价(积分,0=免费默认解锁)
|
||||
Sort int `gorm:"default:0" json:"sort"` // 展示排序
|
||||
}
|
||||
|
||||
func (GameSkin) TableName() string { return "game_skins" }
|
||||
|
||||
// UserSkin 用户已购皮肤(免费皮肤不落库,price=0 人人拥有)
|
||||
type UserSkin struct {
|
||||
ID int `gorm:"primaryKey" json:"id"`
|
||||
UserID int `gorm:"uniqueIndex:uk_user_skin" json:"user_id"`
|
||||
SkinID int `gorm:"uniqueIndex:uk_user_skin" json:"skin_id"`
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (UserSkin) TableName() string { return "user_skins" }
|
||||
33
internal/model/social.go
Normal file
33
internal/model/social.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package model
|
||||
|
||||
// 好友关系状态常量
|
||||
const (
|
||||
FriendStatusPending = 1 // 待验证(等待对方同意)
|
||||
FriendStatusAccepted = 2 // 已通过(互为好友)
|
||||
)
|
||||
|
||||
// Friend 好友关系表:申请方向为 user_id → friend_id,通过后一条记录即代表双向好友
|
||||
type Friend struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 关系ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 发起方用户ID(申请人)
|
||||
FriendID int `gorm:"index" json:"friend_id"` // 接收方用户ID(被申请人)
|
||||
Status int `gorm:"default:1" json:"status"` // 状态:1待验证 2已通过
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 申请时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 状态更新时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Friend) TableName() string { return "friends" }
|
||||
|
||||
// ChatMessage 私聊消息表:仅好友之间可互发
|
||||
type ChatMessage struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 消息ID
|
||||
FromID int `gorm:"index" json:"from_id"` // 发送方用户ID
|
||||
ToID int `gorm:"index" json:"to_id"` // 接收方用户ID
|
||||
Content string `gorm:"size:500" json:"content"` // 消息内容(纯文本)
|
||||
IsRead int `gorm:"default:0" json:"is_read"` // 接收方是否已读:0未读 1已读
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 发送时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ChatMessage) TableName() string { return "chat_messages" }
|
||||
57
internal/model/theme.go
Normal file
57
internal/model/theme.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package model
|
||||
|
||||
import "gorm.io/plugin/soft_delete"
|
||||
|
||||
// Theme 主题模板表:全站换肤模板
|
||||
type Theme struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 主题ID
|
||||
Code string `gorm:"size:32;uniqueIndex" json:"code"` // 主题编码(body 挂 theme-编码 class)
|
||||
Name string `gorm:"size:32" json:"name"` // 主题名称
|
||||
Description string `gorm:"size:255" json:"description"` // 风格描述
|
||||
CSSVars string `gorm:"column:css_vars" json:"css_vars"` // CSS 变量 JSON(写入 :root 生效)
|
||||
PreviewColors string `gorm:"size:64" json:"preview_colors"` // 预览色块(逗号分隔)
|
||||
Sort int `gorm:"default:0" json:"sort"` // 排序权重
|
||||
Status int `gorm:"default:1" json:"status"` // 状态:1可用 2停用
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
DeletedAt soft_delete.DeletedAt `gorm:"default:0" json:"-"` // 软删除(0=未删除)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Theme) TableName() string { return "themes" }
|
||||
|
||||
// SiteConfig 站点配置表:键值对形式的全站配置
|
||||
type SiteConfig struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 配置ID
|
||||
ConfigKey string `gorm:"size:64;uniqueIndex" json:"config_key"` // 配置键
|
||||
ConfigValue string `gorm:"size:500" json:"config_value"` // 配置值(字符串,自行转换)
|
||||
Remark string `gorm:"size:255" json:"remark"` // 配置说明
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SiteConfig) TableName() string { return "site_configs" }
|
||||
|
||||
// 站点配置键名常量(与 sql/init.sql 中的种子数据对应)
|
||||
const (
|
||||
ConfKeySiteName = "site_name" // 站点名称
|
||||
ConfKeyActiveTheme = "active_theme" // 当前启用主题编码
|
||||
ConfKeyAnnouncement = "announcement" // 站点公告
|
||||
ConfKeySignBasePoints = "signin_base_points" // 签到基础积分
|
||||
ConfKeySignStreakStep = "signin_streak_step" // 连签递增积分
|
||||
ConfKeySignStreakCap = "signin_streak_cap" // 连签封顶天数
|
||||
ConfKeyRegisterGift = "register_gift_points" // 注册赠送积分
|
||||
ConfKeyBattleWinPoints = "battle_win_points" // 对战获胜奖励积分
|
||||
// 大厅游戏轮播(参与轮播的游戏在 games.carousel 上配置)
|
||||
ConfKeyCarouselStyle = "carousel_style" // 轮播效果:slide横滑/fade淡入淡出/coverflow封面流/cards卡片堆叠
|
||||
ConfKeyCarouselInterval = "carousel_interval" // 自动轮播间隔(秒)
|
||||
// AI 大模型接入配置(后台可改,非空时覆盖 config.yaml 中的同名项)
|
||||
ConfKeyAIProvider = "ai_provider" // 全站统一的对战AI提供方:rule/spark/deepseek(前台只选难度)
|
||||
ConfKeyAISparkKey = "ai_spark_key" // 讯飞星火 APIPassword
|
||||
ConfKeyAISparkBase = "ai_spark_base" // 讯飞星火接口地址
|
||||
ConfKeyAISparkModel = "ai_spark_model" // 讯飞星火模型名
|
||||
ConfKeyAIDeepSeekKey = "ai_deepseek_key" // DeepSeek API Key
|
||||
ConfKeyAIDeepSeekBase = "ai_deepseek_base" // DeepSeek 接口地址
|
||||
ConfKeyAIDeepSeekModel = "ai_deepseek_model" // DeepSeek 模型名
|
||||
)
|
||||
51
internal/model/user.go
Normal file
51
internal/model/user.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package model 定义与数据库表一一对应的 GORM 模型
|
||||
// 约定:created_at/updated_at/deleted_at 统一为 int 时间戳(秒),
|
||||
// 由 GORM 的 autoCreateTime/autoUpdateTime 与 soft_delete 插件自动维护
|
||||
package model
|
||||
|
||||
import "gorm.io/plugin/soft_delete"
|
||||
|
||||
// 用户角色常量
|
||||
const (
|
||||
RoleAdmin = 1 // 超级管理员
|
||||
RoleNormal = 2 // 普通用户
|
||||
)
|
||||
|
||||
// 用户状态常量
|
||||
const (
|
||||
UserStatusNormal = 1 // 正常
|
||||
UserStatusBanned = 2 // 禁用
|
||||
)
|
||||
|
||||
// User 用户表:平台所有账号(超管与普通用户共用,靠 Role 区分)
|
||||
type User struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 用户ID
|
||||
Username string `gorm:"size:32;uniqueIndex" json:"username"` // 登录用户名(唯一)
|
||||
Password string `gorm:"size:100" json:"-"` // bcrypt 密文(不下发给前端)
|
||||
Nickname string `gorm:"size:32" json:"nickname"` // 昵称
|
||||
Avatar string `gorm:"size:16" json:"avatar"` // emoji 头像
|
||||
Role int `gorm:"default:2" json:"role"` // 角色:1超管 2普通
|
||||
Points int `gorm:"default:0" json:"points"` // 当前积分余额
|
||||
TotalPoints int `gorm:"default:0" json:"total_points"` // 历史累计积分(排行榜依据)
|
||||
VipLevel int `gorm:"default:0" json:"vip_level"` // VIP等级(0=非VIP,1~5)
|
||||
VipExpire int64 `gorm:"default:0" json:"vip_expire"` // VIP到期时间戳(秒,0=未开通)
|
||||
Status int `gorm:"default:1" json:"status"` // 状态:1正常 2禁用
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
DeletedAt soft_delete.DeletedAt `gorm:"default:0" json:"-"` // 软删除时间(0=未删除)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
// LoginLog 登录记录表:每次登录成功写一条
|
||||
type LoginLog struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 记录ID
|
||||
UserID int `gorm:"index" json:"user_id"` // 登录用户ID
|
||||
IP string `gorm:"size:64" json:"ip"` // 登录IP
|
||||
UserAgent string `gorm:"size:255" json:"user_agent"` // 浏览器UA
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 登录时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (LoginLog) TableName() string { return "login_logs" }
|
||||
18
internal/model/vip.go
Normal file
18
internal/model/vip.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
// VipLevel VIP等级配置表:5 个等级的价格与权益(后台可调)
|
||||
type VipLevel struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 配置ID
|
||||
Level int `gorm:"uniqueIndex" json:"level"` // VIP等级(1~5)
|
||||
Name string `gorm:"size:32" json:"name"` // 等级名称(青铜卡/白银卡…)
|
||||
Icon string `gorm:"size:16" json:"icon"` // emoji 图标
|
||||
PricePoints int `gorm:"default:0" json:"price_points"` // 开通/续费价格(积分,按30天计)
|
||||
WeeklyFreeQuota int `gorm:"column:weekly_free_quota;default:0" json:"weekly_free_quota"` // 每周免费游玩的付费游戏数量(周免批次大小)
|
||||
SigninBonus int `gorm:"column:signin_bonus;default:0" json:"signin_bonus"` // 每日签到额外加成积分
|
||||
Description string `gorm:"size:255" json:"description"` // 权益描述
|
||||
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (VipLevel) TableName() string { return "vip_levels" }
|
||||
98
internal/room/client.go
Normal file
98
internal/room/client.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Package room 实现 WebSocket 联机对战:房间管理、斗地主/象棋对局流程、AI 补位与托管
|
||||
package room
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client 一个 WebSocket 连接对应的客户端
|
||||
type Client struct {
|
||||
hub *Hub // 所属房间管理器
|
||||
conn *websocket.Conn // 底层连接
|
||||
send chan []byte // 待发送消息队列(写泵消费)
|
||||
userID int // 登录用户ID
|
||||
name string // 昵称
|
||||
avatar string // 头像
|
||||
}
|
||||
|
||||
// wsMessage 客户端与服务端通用的消息信封
|
||||
type wsMessage struct {
|
||||
Type string `json:"type"` // 消息类型
|
||||
Data json.RawMessage `json:"data"` // 消息负载(各类型自定义)
|
||||
}
|
||||
|
||||
// push 向该客户端推送一条消息(满队列时丢弃,防止慢客户端拖垮全局)
|
||||
func (c *Client) push(msgType string, data any) {
|
||||
payload, _ := json.Marshal(map[string]any{"type": msgType, "data": data})
|
||||
select {
|
||||
case c.send <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// pushError 推送错误提示
|
||||
func (c *Client) pushError(msg string) {
|
||||
c.push("error", map[string]string{"msg": msg})
|
||||
}
|
||||
|
||||
// readPump 读泵:持续读取客户端消息并分发处理,连接断开时做离线清理
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.onDisconnect(c)
|
||||
c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(8 * 1024)
|
||||
// 心跳:60秒收不到任何数据判定断线(前端每30秒发 ping)
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
for {
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
var msg wsMessage
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
c.pushError("消息格式错误")
|
||||
continue
|
||||
}
|
||||
// 客户端应用层心跳直接回应
|
||||
if msg.Type == "ping" {
|
||||
c.push("pong", nil)
|
||||
continue
|
||||
}
|
||||
c.hub.dispatch(c, msg.Type, msg.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// writePump 写泵:消费发送队列写入连接,定期发协议层 ping 保活
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(25 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case payload, ok := <-c.send:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
321
internal/room/hub.go
Normal file
321
internal/room/hub.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"nl-game-api-gin/internal/ai"
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
"nl-game-api-gin/pkg/jwtutil"
|
||||
)
|
||||
|
||||
// upgrader WebSocket 升级器(开发环境放行所有来源)
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Hub 房间管理器:维护全部活跃房间、用户所在房间以及全部在线连接的映射
|
||||
type Hub struct {
|
||||
mu sync.Mutex // 保护 rooms 与 userRoom
|
||||
rooms map[string]*Room // 邀请码 → 房间
|
||||
userRoom map[int]*Room // 用户ID → 所在房间(断线重连用)
|
||||
connMu sync.Mutex // 保护 userClients
|
||||
userClients map[int]map[*Client]bool // 用户ID → 该用户的全部在线连接(私聊推送/在线状态用)
|
||||
}
|
||||
|
||||
// defaultHub 进程内唯一的 Hub 实例(供 handler 包做私聊推送与在线查询)
|
||||
var defaultHub *Hub
|
||||
|
||||
// NewHub 创建房间管理器并启动过期房间清理协程
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
rooms: map[string]*Room{},
|
||||
userRoom: map[int]*Room{},
|
||||
userClients: map[int]map[*Client]bool{},
|
||||
}
|
||||
defaultHub = h
|
||||
go h.janitor()
|
||||
return h
|
||||
}
|
||||
|
||||
// registerClient 连接建立后登记到在线表
|
||||
func (h *Hub) registerClient(c *Client) {
|
||||
h.connMu.Lock()
|
||||
defer h.connMu.Unlock()
|
||||
if h.userClients[c.userID] == nil {
|
||||
h.userClients[c.userID] = map[*Client]bool{}
|
||||
}
|
||||
h.userClients[c.userID][c] = true
|
||||
}
|
||||
|
||||
// unregisterClient 连接断开后从在线表移除
|
||||
func (h *Hub) unregisterClient(c *Client) {
|
||||
h.connMu.Lock()
|
||||
defer h.connMu.Unlock()
|
||||
if set, ok := h.userClients[c.userID]; ok {
|
||||
delete(set, c)
|
||||
if len(set) == 0 {
|
||||
delete(h.userClients, c.userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PushToUser 向指定用户的全部在线连接推送一条消息(离线返回 false,调用方靠数据库兜底)
|
||||
func PushToUser(userID int, msgType string, data any) bool {
|
||||
if defaultHub == nil {
|
||||
return false
|
||||
}
|
||||
defaultHub.connMu.Lock()
|
||||
defer defaultHub.connMu.Unlock()
|
||||
set, ok := defaultHub.userClients[userID]
|
||||
if !ok || len(set) == 0 {
|
||||
return false
|
||||
}
|
||||
for c := range set {
|
||||
c.push(msgType, data)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OnlineCount 当前在线用户数(后台数据概览用)
|
||||
func OnlineCount() int {
|
||||
if defaultHub == nil {
|
||||
return 0
|
||||
}
|
||||
defaultHub.connMu.Lock()
|
||||
defer defaultHub.connMu.Unlock()
|
||||
return len(defaultHub.userClients)
|
||||
}
|
||||
|
||||
// OnlineMap 批量查询用户在线状态(好友列表/会话列表展示用)
|
||||
func OnlineMap(userIDs []int) map[int]bool {
|
||||
out := map[int]bool{}
|
||||
if defaultHub == nil {
|
||||
return out
|
||||
}
|
||||
defaultHub.connMu.Lock()
|
||||
defer defaultHub.connMu.Unlock()
|
||||
for _, id := range userIDs {
|
||||
out[id] = len(defaultHub.userClients[id]) > 0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// janitor 定时清理:每分钟检查一次,移除已解散或长时间无人在线的房间
|
||||
func (h *Hub) janitor() {
|
||||
for range time.Tick(time.Minute) {
|
||||
h.mu.Lock()
|
||||
for code, r := range h.rooms {
|
||||
if r.expired() {
|
||||
r.close("房间长时间无人活动,已解散")
|
||||
for _, uid := range r.humanUserIDs() {
|
||||
delete(h.userRoom, uid)
|
||||
}
|
||||
delete(h.rooms, code)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWS WebSocket 入口:?token= 鉴权成功后启动读写泵
|
||||
// 若用户已在房间中(断线重连),自动重新挂载并推送最新状态
|
||||
func (h *Hub) HandleWS(c *gin.Context) {
|
||||
claims, err := jwtutil.Parse(c.Query("token"))
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"code": 401, "msg": "凭证无效"})
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, claims.UserID).Error; err != nil || user.Status != model.UserStatusNormal {
|
||||
c.JSON(401, gin.H{"code": 401, "msg": "用户状态异常"})
|
||||
return
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
client := &Client{
|
||||
hub: h, conn: conn, send: make(chan []byte, 64),
|
||||
userID: user.ID, name: user.Nickname, avatar: user.Avatar,
|
||||
}
|
||||
go client.writePump()
|
||||
// 登记在线连接(私聊推送/好友在线状态)
|
||||
h.registerClient(client)
|
||||
// 连接成功先下发身份确认
|
||||
client.push("hello", gin.H{"user_id": user.ID})
|
||||
// 断线重连:若用户已在某房间,重新绑定连接并同步状态
|
||||
h.mu.Lock()
|
||||
if r, ok := h.userRoom[user.ID]; ok {
|
||||
h.mu.Unlock()
|
||||
r.reattach(client)
|
||||
} else {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
client.readPump()
|
||||
}
|
||||
|
||||
// onDisconnect 连接断开:注销在线登记,标记座位离线(等待重连),等待中的房间直接移除该玩家
|
||||
func (h *Hub) onDisconnect(c *Client) {
|
||||
h.unregisterClient(c)
|
||||
h.mu.Lock()
|
||||
r, ok := h.userRoom[c.userID]
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if left := r.onOffline(c); left {
|
||||
// 等待中离开即退出房间
|
||||
h.mu.Lock()
|
||||
delete(h.userRoom, c.userID)
|
||||
if r.empty() {
|
||||
delete(h.rooms, r.Code)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch 消息路由:按类型分发到建房/加入/游戏动作等处理函数
|
||||
func (h *Hub) dispatch(c *Client, msgType string, raw json.RawMessage) {
|
||||
switch msgType {
|
||||
case "create_room":
|
||||
h.createRoom(c, raw)
|
||||
case "join_room":
|
||||
h.joinRoom(c, raw)
|
||||
case "leave_room":
|
||||
h.leaveRoom(c)
|
||||
default:
|
||||
// 其余消息都要求已在房间内
|
||||
h.mu.Lock()
|
||||
r, ok := h.userRoom[c.userID]
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
c.pushError("你不在任何房间中")
|
||||
return
|
||||
}
|
||||
r.handleMessage(c, msgType, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// roomCodeChars 邀请码字符集(去掉易混淆的 0/O/1/I)
|
||||
const roomCodeChars = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
// genRoomCode 生成 6 位不重复邀请码(调用方需持有 h.mu)
|
||||
func (h *Hub) genRoomCode() string {
|
||||
for {
|
||||
code := make([]byte, 6)
|
||||
for i := range code {
|
||||
code[i] = roomCodeChars[rand.Intn(len(roomCodeChars))]
|
||||
}
|
||||
if _, exists := h.rooms[string(code)]; !exists {
|
||||
return string(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createRoomReq 建房请求参数(AI 提供方不由前台指定,取后台全站统一配置)
|
||||
type createRoomReq struct {
|
||||
Game string `json:"game"` // doudizhu / chess / monopoly / ludo / billiards
|
||||
Mode string `json:"mode"` // pvp=好友联机 ai=人机对战
|
||||
AIDifficulty string `json:"ai_difficulty"` // easy / medium / hard
|
||||
}
|
||||
|
||||
// validGames 支持联机对战的游戏编码(starve=饥荒组队联机,主机权威模拟)
|
||||
var validGames = map[string]bool{
|
||||
"doudizhu": true, "chess": true,
|
||||
"monopoly": true, "ludo": true, "billiards": true,
|
||||
"starve": true,
|
||||
}
|
||||
|
||||
// createRoom 创建房间:AI 模式立即用 AI 填满空位
|
||||
func (h *Hub) createRoom(c *Client, raw json.RawMessage) {
|
||||
var req createRoomReq
|
||||
json.Unmarshal(raw, &req)
|
||||
if !validGames[req.Game] {
|
||||
c.pushError("未知的游戏类型")
|
||||
return
|
||||
}
|
||||
if req.Mode != "pvp" && req.Mode != "ai" {
|
||||
req.Mode = "pvp"
|
||||
}
|
||||
h.mu.Lock()
|
||||
// 已在房间中不允许重复建房
|
||||
if _, ok := h.userRoom[c.userID]; ok {
|
||||
h.mu.Unlock()
|
||||
c.pushError("你已在房间中,请先退出")
|
||||
return
|
||||
}
|
||||
code := h.genRoomCode()
|
||||
// AI 模型全站统一:由后台「AI 设置」配置,房间创建时读取当前值
|
||||
r := newRoom(code, req.Game, req.Mode, ai.GlobalProvider(), req.AIDifficulty)
|
||||
h.rooms[code] = r
|
||||
h.userRoom[c.userID] = r
|
||||
h.mu.Unlock()
|
||||
r.join(c, true)
|
||||
}
|
||||
|
||||
// joinRoomReq 加入房间请求参数
|
||||
type joinRoomReq struct {
|
||||
Code string `json:"code"` // 6位邀请码
|
||||
}
|
||||
|
||||
// joinRoom 凭邀请码加入房间
|
||||
func (h *Hub) joinRoom(c *Client, raw json.RawMessage) {
|
||||
var req joinRoomReq
|
||||
json.Unmarshal(raw, &req)
|
||||
h.mu.Lock()
|
||||
if _, ok := h.userRoom[c.userID]; ok {
|
||||
h.mu.Unlock()
|
||||
c.pushError("你已在房间中,请先退出")
|
||||
return
|
||||
}
|
||||
r, ok := h.rooms[req.Code]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
c.pushError("房间不存在或已解散")
|
||||
return
|
||||
}
|
||||
h.userRoom[c.userID] = r
|
||||
h.mu.Unlock()
|
||||
if err := r.join(c, false); err != nil {
|
||||
h.mu.Lock()
|
||||
delete(h.userRoom, c.userID)
|
||||
h.mu.Unlock()
|
||||
c.pushError(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// leaveRoom 主动退出房间:对局中退出按认输处理
|
||||
func (h *Hub) leaveRoom(c *Client) {
|
||||
h.mu.Lock()
|
||||
r, ok := h.userRoom[c.userID]
|
||||
if ok {
|
||||
delete(h.userRoom, c.userID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.leave(c)
|
||||
h.mu.Lock()
|
||||
if r.empty() {
|
||||
delete(h.rooms, r.Code)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// removeUserRoom 房间内部结算/解散时同步清理映射(房间锁外调用)
|
||||
func (h *Hub) removeUserRoom(uid int) {
|
||||
h.mu.Lock()
|
||||
delete(h.userRoom, uid)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
1436
internal/room/room.go
Normal file
1436
internal/room/room.go
Normal file
File diff suppressed because it is too large
Load Diff
172
internal/room/room_billiards.go
Normal file
172
internal/room/room_billiards.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// 2D 台球在房间内的动作处理、AI/托管击球与状态快照
|
||||
// 每一杆由服务端权威模拟,回放关键帧通过 bill_anim 事件推给双方
|
||||
package room
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/billiards"
|
||||
)
|
||||
|
||||
// handleBillShotLocked 真人击球(需持锁):支持杆法(高低杆/左右塞)
|
||||
func (r *Room) handleBillShotLocked(seatIdx int, angle, power, spinX, spinY float64, c *Client) {
|
||||
if r.status != "playing" || r.billGame == nil {
|
||||
c.pushError("对局未开始")
|
||||
return
|
||||
}
|
||||
res, err := r.billGame.ShootSpin(seatIdx, angle, power, spinX, spinY)
|
||||
if err != nil {
|
||||
c.pushError(err.Error())
|
||||
return
|
||||
}
|
||||
r.afterBillShotLocked(seatIdx, res)
|
||||
}
|
||||
|
||||
// aiBillActLocked AI/托管击球:规则 AI 选线路后带噪声出杆(需持锁)
|
||||
// escrow=true 为超时托管:使用低难度(大噪声)出杆
|
||||
func (r *Room) aiBillActLocked(seatIdx int, escrow bool) {
|
||||
g := r.billGame
|
||||
if g == nil || r.status != "playing" || g.Turn != seatIdx {
|
||||
return
|
||||
}
|
||||
diff := r.AIDifficulty
|
||||
if escrow {
|
||||
diff = "easy"
|
||||
}
|
||||
angle, power := billiards.AIShot(g, seatIdx, diff)
|
||||
res, err := g.Shoot(seatIdx, angle, power)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.afterBillShotLocked(seatIdx, res)
|
||||
}
|
||||
|
||||
// afterBillShotLocked 一杆结束后的统一收尾:推送回放动画、播报、结算或调度下一杆(需持锁)
|
||||
func (r *Room) afterBillShotLocked(seatIdx int, res *billiards.ShotResult) {
|
||||
// 回放动画推给所有真人(20fps 关键帧,前端插值播放)
|
||||
// 附带犯规标记与原因:前端回放结束后先弹犯规提示,再播换人横幅
|
||||
animMs := len(res.Frames) * 50
|
||||
foulReason := ""
|
||||
if res.Foul {
|
||||
foulReason = "空杆,未碰到任何球"
|
||||
for _, id := range res.Potted {
|
||||
if id == 0 {
|
||||
foulReason = "白球落袋"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
anim := map[string]any{
|
||||
"shooter": seatIdx, "frames": res.Frames,
|
||||
"potted": res.Potted, "interval_ms": 50,
|
||||
"foul": res.Foul && !res.Over, "foul_reason": foulReason,
|
||||
}
|
||||
for _, s := range r.seats {
|
||||
if s.client != nil {
|
||||
s.client.push("bill_anim", anim)
|
||||
}
|
||||
}
|
||||
r.animMs = animMs
|
||||
// 组装战报
|
||||
name := r.seats[seatIdx].Name
|
||||
var lines []string
|
||||
if names := pottedNames(res.Potted); names != "" {
|
||||
lines = append(lines, fmt.Sprintf("%s 打进了 %s", name, names))
|
||||
}
|
||||
if res.Desc != "" {
|
||||
lines = append(lines, res.Desc)
|
||||
}
|
||||
// 打进对方目标球的说明(简化规则:送球不判犯规,只影响是否续杆)
|
||||
if !res.Over && oppBallPotted(r.billGame.Groups[seatIdx], res.Potted) {
|
||||
lines = append(lines, fmt.Sprintf("%s 把对方的球送进袋(不判犯规)", name))
|
||||
}
|
||||
if res.Foul && !res.Over {
|
||||
lines = append(lines, fmt.Sprintf("%s 犯规,击球权交给对方", name))
|
||||
} else if res.Continue && !res.Over {
|
||||
lines = append(lines, fmt.Sprintf("%s 打进己方目标球,继续击球", name))
|
||||
}
|
||||
for _, l := range lines {
|
||||
r.broadcastChatLocked(-1, "系统", "", l, false)
|
||||
}
|
||||
if res.Over {
|
||||
// 黑八落袋的胜负结算延迟到回放播完再广播,避免提前剧透结果
|
||||
r.turnSeq++ // 使遗留的回合超时定时器失效
|
||||
if r.turnTimer != nil {
|
||||
r.turnTimer.Stop()
|
||||
}
|
||||
winners := []int{res.Winner}
|
||||
desc := fmt.Sprintf("%s 获胜!", r.seats[res.Winner].Name)
|
||||
time.AfterFunc(time.Duration(animMs+600)*time.Millisecond, func() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// 等待期间房间可能已被解散,不再结算与广播
|
||||
if r.closed {
|
||||
return
|
||||
}
|
||||
r.settleLocked(winners, desc)
|
||||
r.broadcastStateLocked()
|
||||
})
|
||||
} else {
|
||||
r.scheduleTurnLocked()
|
||||
}
|
||||
r.broadcastStateLocked()
|
||||
}
|
||||
|
||||
// oppBallPotted 本杆是否把对方组的球打进袋(分组未定时不判断)
|
||||
func oppBallPotted(myGroup int, potted []int) bool {
|
||||
if myGroup == billiards.GroupNone {
|
||||
return false
|
||||
}
|
||||
for _, id := range potted {
|
||||
var g int
|
||||
switch {
|
||||
case id >= 1 && id <= 7:
|
||||
g = billiards.GroupSolid
|
||||
case id >= 9 && id <= 15:
|
||||
g = billiards.GroupStripe
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if g != myGroup {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pottedNames 落袋球的可读描述(白球单独说明)
|
||||
func pottedNames(potted []int) string {
|
||||
var parts []string
|
||||
for _, id := range potted {
|
||||
if id == 0 {
|
||||
parts = append(parts, "白球(犯规)")
|
||||
} else if id == 8 {
|
||||
parts = append(parts, "黑八")
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("%d 号球", id))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
}
|
||||
|
||||
// billStateLocked 台球状态快照(需持锁)
|
||||
func (r *Room) billStateLocked(viewSeat int) map[string]any {
|
||||
g := r.billGame
|
||||
balls := make([]map[string]any, 0, 16)
|
||||
for _, b := range g.Balls {
|
||||
balls = append(balls, map[string]any{
|
||||
"id": b.ID, "x": b.X, "y": b.Y, "on": b.On,
|
||||
})
|
||||
}
|
||||
myGroup := 0
|
||||
if viewSeat >= 0 && viewSeat < 2 {
|
||||
myGroup = g.Groups[viewSeat]
|
||||
}
|
||||
return map[string]any{
|
||||
"phase": g.Phase, "turn": g.Turn, "groups": g.Groups,
|
||||
"my_group": myGroup, "balls": balls, "shots": g.Shots,
|
||||
"cleared": []bool{g.GroupCleared(0), g.GroupCleared(1)},
|
||||
}
|
||||
}
|
||||
161
internal/room/room_ludo.go
Normal file
161
internal/room/room_ludo.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// 飞行棋在房间内的动作处理、AI/托管行动与状态快照
|
||||
package room
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/ludo"
|
||||
)
|
||||
|
||||
// handleLudoRollLocked 真人掷骰(需持锁)
|
||||
func (r *Room) handleLudoRollLocked(seatIdx int, c *Client) {
|
||||
if r.status != "playing" || r.ludoGame == nil {
|
||||
c.pushError("对局未开始")
|
||||
return
|
||||
}
|
||||
if err := r.ludoGame.Roll(seatIdx); err != nil {
|
||||
c.pushError(err.Error())
|
||||
return
|
||||
}
|
||||
r.afterLudoActionLocked()
|
||||
}
|
||||
|
||||
// handleLudoMoveLocked 真人选棋移动(需持锁)
|
||||
func (r *Room) handleLudoMoveLocked(seatIdx, plane int, c *Client) {
|
||||
if r.status != "playing" || r.ludoGame == nil {
|
||||
c.pushError("对局未开始")
|
||||
return
|
||||
}
|
||||
if err := r.ludoGame.Move(seatIdx, plane); err != nil {
|
||||
c.pushError(err.Error())
|
||||
return
|
||||
}
|
||||
r.afterLudoActionLocked()
|
||||
}
|
||||
|
||||
// aiLudoActLocked AI/托管行动:掷骰后按估值选棋(需持锁)
|
||||
// escrow=true 为超时托管:使用中等难度规则决策
|
||||
func (r *Room) aiLudoActLocked(seatIdx int, escrow bool) {
|
||||
g := r.ludoGame
|
||||
if g == nil || r.status != "playing" || g.Turn != seatIdx {
|
||||
return
|
||||
}
|
||||
diff := r.AIDifficulty
|
||||
if escrow {
|
||||
diff = "medium"
|
||||
}
|
||||
if g.Phase == ludo.PhaseRoll {
|
||||
g.Roll(seatIdx)
|
||||
} else if g.Phase == ludo.PhaseMove {
|
||||
if plane := ludo.AIChoosePlane(g, seatIdx, diff); plane >= 0 {
|
||||
g.Move(seatIdx, plane)
|
||||
}
|
||||
}
|
||||
r.afterLudoActionLocked()
|
||||
}
|
||||
|
||||
// ludoAnimMs 估算一批动画事件的前端播放时长(毫秒),用于调度预留观看时间
|
||||
func ludoAnimMs(events []ludo.Event) int {
|
||||
ms := 0
|
||||
for _, e := range events {
|
||||
switch e.Kind {
|
||||
case "roll":
|
||||
ms += 1000
|
||||
case "nomove":
|
||||
ms += 600
|
||||
case "takeoff":
|
||||
ms += 650
|
||||
case "move":
|
||||
// 反弹时实际走的格数等于骰子点数,正常前进为 To-From
|
||||
steps := e.To - e.From
|
||||
if e.Die > steps {
|
||||
steps = e.Die
|
||||
}
|
||||
ms += steps*150 + 150
|
||||
case "jump":
|
||||
ms += 650
|
||||
case "fly":
|
||||
ms += 750
|
||||
case "capture":
|
||||
ms += 950
|
||||
case "arrive":
|
||||
ms += 700
|
||||
case "bonus":
|
||||
ms += 500
|
||||
case "sixpenalty":
|
||||
ms += 950
|
||||
}
|
||||
}
|
||||
if ms > 4500 {
|
||||
ms = 4500
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
// afterLudoActionLocked 动作后的统一收尾:收集动画事件、广播战报、结算或调度下一回合(需持锁)
|
||||
func (r *Room) afterLudoActionLocked() {
|
||||
g := r.ludoGame
|
||||
for _, line := range g.DrainLog() {
|
||||
r.broadcastChatLocked(-1, "系统", "", r.replaceSeatNames(line), false)
|
||||
}
|
||||
// 真人全部退赛/离开:立即按进度提前结算,避免纯 AI 空跑到掷骰上限
|
||||
if g.Phase != ludo.PhaseOver {
|
||||
humanAlive := false
|
||||
for _, s := range r.seats {
|
||||
if s.UserID > 0 && g.Active[s.Index] {
|
||||
humanAlive = true
|
||||
}
|
||||
}
|
||||
if !humanAlive {
|
||||
g.ForceFinish()
|
||||
for _, line := range g.DrainLog() {
|
||||
r.broadcastChatLocked(-1, "系统", "", r.replaceSeatNames(line), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 收集本次动作的动画事件:递增序号、按事件估算播放时长
|
||||
r.ludoEvents = g.DrainEvents()
|
||||
r.ludoSeq++
|
||||
r.animMs = ludoAnimMs(r.ludoEvents)
|
||||
if g.Phase == ludo.PhaseOver {
|
||||
winners := []int{}
|
||||
desc := "对局结束"
|
||||
if g.Winner >= 0 {
|
||||
winners = append(winners, g.Winner)
|
||||
desc = fmt.Sprintf("%s 率先完成飞行,获得胜利!", r.seats[g.Winner].Name)
|
||||
}
|
||||
// 结算延迟到最后一段动画播完再广播,避免飞机还没落地就弹出结果
|
||||
r.turnSeq++ // 使遗留的回合超时定时器失效
|
||||
if r.turnTimer != nil {
|
||||
r.turnTimer.Stop()
|
||||
}
|
||||
delay := time.Duration(r.animMs+600) * time.Millisecond
|
||||
time.AfterFunc(delay, func() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// 等待期间房间可能已被解散,不再结算与广播
|
||||
if r.closed {
|
||||
return
|
||||
}
|
||||
r.settleLocked(winners, desc)
|
||||
r.broadcastStateLocked()
|
||||
})
|
||||
} else {
|
||||
r.scheduleTurnLocked()
|
||||
}
|
||||
r.broadcastStateLocked()
|
||||
}
|
||||
|
||||
// ludoStateLocked 飞行棋状态快照(信息全公开,需持锁)
|
||||
func (r *Room) ludoStateLocked() map[string]any {
|
||||
g := r.ludoGame
|
||||
return map[string]any{
|
||||
"phase": g.Phase, "turn": g.Turn, "die": g.Die,
|
||||
"planes": g.Planes, "active": g.Active,
|
||||
"movable": g.Movable(g.Turn), "last_move": g.LastMv,
|
||||
"rolls": g.Rolls, "max_rolls": ludo.MaxRolls,
|
||||
// 动作序号 + 动画事件:前端凭连续序号判断播放动画还是直接同步
|
||||
"seq": r.ludoSeq, "events": r.ludoEvents,
|
||||
}
|
||||
}
|
||||
207
internal/room/room_monopoly.go
Normal file
207
internal/room/room_monopoly.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// 大富翁在房间内的动作处理、AI/托管行动与状态快照
|
||||
package room
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nl-game-api-gin/internal/gamecore/monopoly"
|
||||
)
|
||||
|
||||
// replaceSeatNames 把战报中的 P0-P3 座位占位符替换为玩家昵称(需持锁)
|
||||
func (r *Room) replaceSeatNames(line string) string {
|
||||
for i := len(r.seats) - 1; i >= 0; i-- {
|
||||
line = strings.ReplaceAll(line, fmt.Sprintf("P%d", i), r.seats[i].Name)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// handleMonoRollLocked 真人掷骰(需持锁)
|
||||
func (r *Room) handleMonoRollLocked(seatIdx int, c *Client) {
|
||||
if r.status != "playing" || r.monoGame == nil {
|
||||
c.pushError("对局未开始")
|
||||
return
|
||||
}
|
||||
if err := r.monoGame.Roll(seatIdx); err != nil {
|
||||
c.pushError(err.Error())
|
||||
return
|
||||
}
|
||||
r.afterMonoActionLocked()
|
||||
}
|
||||
|
||||
// handleMonoBuyLocked 真人购买决策(需持锁)
|
||||
func (r *Room) handleMonoBuyLocked(seatIdx int, buy bool, c *Client) {
|
||||
if r.status != "playing" || r.monoGame == nil {
|
||||
c.pushError("对局未开始")
|
||||
return
|
||||
}
|
||||
if err := r.monoGame.Buy(seatIdx, buy); err != nil {
|
||||
c.pushError(err.Error())
|
||||
return
|
||||
}
|
||||
r.afterMonoActionLocked()
|
||||
}
|
||||
|
||||
// handleMonoUpgradeLocked 真人建筑升级决策(需持锁)
|
||||
func (r *Room) handleMonoUpgradeLocked(seatIdx int, up bool, c *Client) {
|
||||
if r.status != "playing" || r.monoGame == nil {
|
||||
c.pushError("对局未开始")
|
||||
return
|
||||
}
|
||||
if err := r.monoGame.Upgrade(seatIdx, up); err != nil {
|
||||
c.pushError(err.Error())
|
||||
return
|
||||
}
|
||||
r.afterMonoActionLocked()
|
||||
}
|
||||
|
||||
// aiMonoActLocked AI/托管行动:按阶段掷骰、购买或升级(需持锁)
|
||||
// escrow=true 为超时托管:只掷骰、不替真人买地/升级
|
||||
func (r *Room) aiMonoActLocked(seatIdx int, escrow bool) {
|
||||
g := r.monoGame
|
||||
if g == nil || r.status != "playing" || g.Turn != seatIdx {
|
||||
return
|
||||
}
|
||||
switch g.Phase {
|
||||
case monopoly.PhaseRoll:
|
||||
g.Roll(seatIdx)
|
||||
case monopoly.PhaseBuy:
|
||||
buy := false
|
||||
if !escrow {
|
||||
buy = monopoly.AIShouldBuy(g, seatIdx, r.AIDifficulty)
|
||||
}
|
||||
g.Buy(seatIdx, buy)
|
||||
case monopoly.PhaseUpgrade:
|
||||
up := false
|
||||
if !escrow {
|
||||
up = monopoly.AIShouldUpgrade(g, seatIdx, r.AIDifficulty)
|
||||
}
|
||||
g.Upgrade(seatIdx, up)
|
||||
}
|
||||
r.afterMonoActionLocked()
|
||||
}
|
||||
|
||||
// monoAnimMs 估算一批动画事件的前端播放时长(毫秒),用于调度预留观看时间
|
||||
func monoAnimMs(events []monopoly.Event, boardLen int) int {
|
||||
ms := 0
|
||||
for _, e := range events {
|
||||
switch e.Kind {
|
||||
case "roll":
|
||||
ms += 1100
|
||||
case "move":
|
||||
steps := e.To - e.From
|
||||
if steps <= 0 {
|
||||
steps += boardLen
|
||||
}
|
||||
ms += steps*170 + 200
|
||||
case "teleport":
|
||||
ms += 900
|
||||
case "chance":
|
||||
ms += 1700
|
||||
case "cash":
|
||||
ms += 300
|
||||
case "buy":
|
||||
ms += 900
|
||||
case "upgrade":
|
||||
ms += 900
|
||||
case "bankrupt":
|
||||
ms += 900
|
||||
case "rest":
|
||||
ms += 400
|
||||
}
|
||||
}
|
||||
if ms > 5000 {
|
||||
ms = 5000
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
// afterMonoActionLocked 动作后的统一收尾:收集动画事件、广播战报、结算或调度下一回合(需持锁)
|
||||
func (r *Room) afterMonoActionLocked() {
|
||||
g := r.monoGame
|
||||
for _, line := range g.DrainLog() {
|
||||
r.broadcastChatLocked(-1, "系统", "", r.replaceSeatNames(line), false)
|
||||
}
|
||||
// 真人全部破产/退出:立即按资产结算,避免纯 AI 空跑到轮数上限
|
||||
if g.Phase != monopoly.PhaseOver {
|
||||
humanAlive := false
|
||||
for _, s := range r.seats {
|
||||
if s.UserID > 0 && !g.Players[s.Index].Out {
|
||||
humanAlive = true
|
||||
}
|
||||
}
|
||||
if !humanAlive {
|
||||
g.ForceFinish()
|
||||
for _, line := range g.DrainLog() {
|
||||
r.broadcastChatLocked(-1, "系统", "", r.replaceSeatNames(line), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 收集本次动作的动画事件:替换昵称占位符、递增序号、按事件估算播放时长
|
||||
evs := g.DrainEvents()
|
||||
for i := range evs {
|
||||
evs[i].Text = r.replaceSeatNames(evs[i].Text)
|
||||
}
|
||||
r.monoEvents = evs
|
||||
r.monoSeq++
|
||||
r.animMs = monoAnimMs(evs, len(g.Tiles))
|
||||
if g.Phase == monopoly.PhaseOver {
|
||||
names := []string{}
|
||||
for _, w := range g.Winners {
|
||||
names = append(names, r.seats[w].Name)
|
||||
}
|
||||
winners, desc := g.Winners, fmt.Sprintf("对局结束,%s 获胜!", strings.Join(names, "、"))
|
||||
// 结算延迟到最后一段动画播完再广播,避免棋子还没走完就弹出结果
|
||||
r.turnSeq++ // 使遗留的回合超时定时器失效
|
||||
if r.turnTimer != nil {
|
||||
r.turnTimer.Stop()
|
||||
}
|
||||
delay := time.Duration(r.animMs+600) * time.Millisecond
|
||||
time.AfterFunc(delay, func() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// 等待期间房间可能已被解散,不再结算与广播
|
||||
if r.closed {
|
||||
return
|
||||
}
|
||||
r.settleLocked(winners, desc)
|
||||
r.broadcastStateLocked()
|
||||
})
|
||||
} else {
|
||||
r.scheduleTurnLocked()
|
||||
}
|
||||
r.broadcastStateLocked()
|
||||
}
|
||||
|
||||
// monoStateLocked 大富翁状态快照(信息全公开,各座位一致,需持锁)
|
||||
func (r *Room) monoStateLocked() map[string]any {
|
||||
g := r.monoGame
|
||||
players := make([]map[string]any, len(g.Players))
|
||||
for i, p := range g.Players {
|
||||
players[i] = map[string]any{
|
||||
"seat": p.Seat, "money": p.Money, "pos": p.Pos,
|
||||
"out": p.Out, "skip": p.Skip, "assets": g.AssetsOf(i),
|
||||
}
|
||||
}
|
||||
st := map[string]any{
|
||||
"phase": g.Phase, "turn": g.Turn, "dice": g.Dice,
|
||||
"round": g.Round, "max_rounds": monopoly.MaxRounds,
|
||||
"tiles": g.Tiles, "players": players,
|
||||
// 动作序号 + 动画事件:前端凭连续序号判断播放动画还是直接同步
|
||||
"seq": r.monoSeq, "events": r.monoEvents,
|
||||
}
|
||||
// 购买/升级阶段附带待决策地块信息(kind 区分弹哪种卡片)
|
||||
if g.Phase == monopoly.PhaseBuy && g.Pending >= 0 {
|
||||
t := g.Tiles[g.Pending]
|
||||
st["pending"] = map[string]any{"kind": "buy", "tile": t.Idx, "name": t.Name, "price": t.Price}
|
||||
}
|
||||
if g.Phase == monopoly.PhaseUpgrade && g.Pending >= 0 {
|
||||
t := g.Tiles[g.Pending]
|
||||
st["pending"] = map[string]any{
|
||||
"kind": "upgrade", "tile": t.Idx, "name": t.Name,
|
||||
"price": monopoly.UpgradeCost(t), "level": t.Level,
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
108
internal/router/router.go
Normal file
108
internal/router/router.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Package router 注册全部 HTTP 路由与 WebSocket 入口
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-game-api-gin/internal/handler"
|
||||
"nl-game-api-gin/internal/middleware"
|
||||
"nl-game-api-gin/internal/room"
|
||||
)
|
||||
|
||||
// Setup 创建 Gin 引擎并挂载全部路由
|
||||
func Setup() *gin.Engine {
|
||||
r := gin.Default()
|
||||
r.Use(middleware.CORS())
|
||||
// WebSocket 对战入口(?token= 鉴权,内部自行校验)
|
||||
hub := room.NewHub()
|
||||
r.GET("/ws", hub.HandleWS)
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 公开接口:注册、登录、站点配置(主题)
|
||||
api.POST("/auth/register", handler.Register)
|
||||
api.POST("/auth/login", handler.Login)
|
||||
api.GET("/config", handler.SiteConfigPublic)
|
||||
// 登录后接口
|
||||
auth := api.Group("", middleware.Auth())
|
||||
{
|
||||
// 个人中心
|
||||
auth.GET("/user/profile", handler.Profile)
|
||||
auth.PUT("/user/profile", handler.UpdateProfile)
|
||||
auth.GET("/user/points", handler.PointRecords)
|
||||
auth.GET("/user/logins", handler.LoginLogs)
|
||||
auth.GET("/user/records", handler.GameRecords)
|
||||
auth.GET("/user/battles", handler.BattleRecords)
|
||||
auth.GET("/user/props", handler.UserProps)
|
||||
auth.GET("/user/games", handler.UserGames)
|
||||
auth.GET("/user/mygames", handler.UserMyGames)
|
||||
// 游戏
|
||||
auth.GET("/games", handler.GameList)
|
||||
auth.GET("/games/:code", handler.GameDetail)
|
||||
auth.POST("/games/:code/score", handler.SubmitScore)
|
||||
// 关卡进度(带关卡的游戏:查我的+好友进度 / 过关上报)
|
||||
auth.GET("/games/:code/progress", handler.GameProgressGet)
|
||||
auth.POST("/games/:code/progress", handler.GameProgressSave)
|
||||
// 游戏存档(支持存档的游戏:读档/覆盖保存/删档)
|
||||
auth.GET("/games/:code/save", handler.GameSaveGet)
|
||||
auth.POST("/games/:code/save", handler.GameSaveSet)
|
||||
auth.DELETE("/games/:code/save", handler.GameSaveDelete)
|
||||
// 游戏皮肤(更衣室:皮肤列表 / 积分购买)
|
||||
auth.GET("/games/:code/skins", handler.GameSkins)
|
||||
auth.POST("/games/:code/skins/buy", handler.GameSkinBuy)
|
||||
auth.POST("/props/use", handler.UseProp)
|
||||
// 签到
|
||||
auth.GET("/signin/status", handler.SignInStatus)
|
||||
auth.POST("/signin", handler.DoSignIn)
|
||||
// VIP会员(等级权益、周免批次、开通续费)
|
||||
auth.GET("/vip/info", handler.VipInfo)
|
||||
auth.POST("/vip/buy", handler.VipBuy)
|
||||
// 商城(游戏+道具+购物车+订单)
|
||||
auth.GET("/shop/props", handler.PropList)
|
||||
auth.GET("/shop/cart", handler.CartList)
|
||||
auth.POST("/shop/cart", handler.AddCart)
|
||||
auth.DELETE("/shop/cart/:id", handler.RemoveCart)
|
||||
auth.POST("/shop/checkout", handler.Checkout)
|
||||
auth.POST("/shop/buy", handler.BuyNow)
|
||||
auth.GET("/shop/orders", handler.OrderList)
|
||||
// 排行榜
|
||||
auth.GET("/rank/points", handler.PointsRank)
|
||||
auth.GET("/rank/game/:code", handler.GameRank)
|
||||
// 好友(申请/同意/列表/搜索/删除)
|
||||
auth.GET("/friends", handler.FriendList)
|
||||
auth.GET("/friends/requests", handler.FriendRequests)
|
||||
auth.GET("/friends/search", handler.FriendSearch)
|
||||
auth.POST("/friends/request", handler.FriendRequest)
|
||||
auth.POST("/friends/respond", handler.FriendRespond)
|
||||
auth.DELETE("/friends/:uid", handler.FriendDelete)
|
||||
// 私聊(会话列表/聊天记录/发送/已读/未读数)
|
||||
auth.GET("/chat/conversations", handler.ChatConversations)
|
||||
auth.GET("/chat/messages", handler.ChatMessages)
|
||||
auth.POST("/chat/send", handler.ChatSend)
|
||||
auth.POST("/chat/read", handler.ChatRead)
|
||||
auth.GET("/chat/unread", handler.ChatUnread)
|
||||
// 后台管理(仅超管)
|
||||
admin := auth.Group("/admin", middleware.AdminOnly())
|
||||
{
|
||||
admin.GET("/stats", handler.AdminStats)
|
||||
admin.GET("/analytics", handler.AdminAnalytics)
|
||||
admin.GET("/themes", handler.AdminThemes)
|
||||
admin.POST("/theme", handler.AdminSwitchTheme)
|
||||
admin.PUT("/site", handler.AdminUpdateSite)
|
||||
admin.GET("/users", handler.AdminUsers)
|
||||
admin.PUT("/users/:id", handler.AdminUpdateUser)
|
||||
admin.GET("/users/:id/points", handler.AdminUserPoints)
|
||||
admin.GET("/games", handler.AdminGames)
|
||||
admin.PUT("/games/:id", handler.AdminUpdateGame)
|
||||
admin.GET("/orders", handler.AdminOrders)
|
||||
// VIP等级配置
|
||||
admin.GET("/vip/levels", handler.AdminVipLevels)
|
||||
admin.PUT("/vip/levels/:id", handler.AdminUpdateVipLevel)
|
||||
// AI大模型接入配置(查看/保存/连通性测试)
|
||||
admin.GET("/ai", handler.AdminAIConfig)
|
||||
admin.PUT("/ai", handler.AdminSaveAIConfig)
|
||||
admin.POST("/ai/test", handler.AdminTestAI)
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
91
internal/service/service.go
Normal file
91
internal/service/service.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Package service 沉淀跨接口复用的业务逻辑:积分变动、站点配置读写
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
)
|
||||
|
||||
// ChangePoints 变动用户积分并写流水(事务保证,正数=增加、负数=扣减)
|
||||
// tx 传 nil 时自动开启新事务;传入已有事务则复用(用于下单等复合操作)
|
||||
func ChangePoints(tx *gorm.DB, userID, change, ptype, relatedID int, remark string) (int, error) {
|
||||
run := func(db *gorm.DB) (int, error) {
|
||||
var user model.User
|
||||
// 行级锁读取(SELECT ... FOR UPDATE),防止并发变动基于同一余额快照造成丢失更新
|
||||
// 注意:GORM v2 必须用 clause.Locking,旧的 Set("gorm:query_option") 会被静默忽略
|
||||
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return 0, errors.New("用户不存在")
|
||||
}
|
||||
newBalance := user.Points + change
|
||||
if newBalance < 0 {
|
||||
return 0, errors.New("积分不足")
|
||||
}
|
||||
// 更新余额;增加时同步累计 total_points(排行榜依据)
|
||||
updates := map[string]any{"points": newBalance}
|
||||
if change > 0 {
|
||||
updates["total_points"] = user.TotalPoints + change
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 写积分流水(审计与个人中心展示)
|
||||
record := model.PointRecord{
|
||||
UserID: userID, ChangePoints: change, Balance: newBalance,
|
||||
Type: ptype, RelatedID: relatedID, Remark: remark,
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return newBalance, nil
|
||||
}
|
||||
// 复用外部事务或自行开启事务
|
||||
if tx != nil {
|
||||
return run(tx)
|
||||
}
|
||||
var balance int
|
||||
err := database.DB.Transaction(func(db *gorm.DB) error {
|
||||
b, err := run(db)
|
||||
balance = b
|
||||
return err
|
||||
})
|
||||
return balance, err
|
||||
}
|
||||
|
||||
// GetConfig 读取站点配置,不存在时返回默认值
|
||||
func GetConfig(key, def string) string {
|
||||
var conf model.SiteConfig
|
||||
if err := database.DB.Where("config_key = ?", key).First(&conf).Error; err != nil {
|
||||
return def
|
||||
}
|
||||
return conf.ConfigValue
|
||||
}
|
||||
|
||||
// GetConfigInt 读取整数类型站点配置,解析失败返回默认值
|
||||
func GetConfigInt(key string, def int) int {
|
||||
v := GetConfig(key, "")
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SetConfig 写入站点配置(不存在则创建)
|
||||
func SetConfig(key, value string) error {
|
||||
var conf model.SiteConfig
|
||||
err := database.DB.Where("config_key = ?", key).First(&conf).Error
|
||||
if err != nil {
|
||||
conf = model.SiteConfig{ConfigKey: key, ConfigValue: value}
|
||||
return database.DB.Create(&conf).Error
|
||||
}
|
||||
return database.DB.Model(&conf).Update("config_value", value).Error
|
||||
}
|
||||
81
internal/service/vip.go
Normal file
81
internal/service/vip.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/model"
|
||||
)
|
||||
|
||||
// VipPeriodStart 计算当前周免周期的起点:最近一个已经到来的周五 12:00(服务器本地时区)
|
||||
// 周免批次以该时间戳作随机种子,整周稳定,每周五中午自动轮换
|
||||
func VipPeriodStart(now time.Time) time.Time {
|
||||
t := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, now.Location())
|
||||
for t.Weekday() != time.Friday {
|
||||
t = t.AddDate(0, 0, -1)
|
||||
}
|
||||
// 今天恰好是周五但还没到中午 12 点:仍属于上一周期
|
||||
if t.After(now) {
|
||||
t = t.AddDate(0, 0, -7)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// VipNextRefresh 下一次周免刷新时间(当前周期起点 + 7 天)
|
||||
func VipNextRefresh(now time.Time) time.Time {
|
||||
return VipPeriodStart(now).AddDate(0, 0, 7)
|
||||
}
|
||||
|
||||
// VipWeeklyPool 本周期的周免候选池:全部上架付费游戏按种子洗牌后的固定顺序
|
||||
// 各等级按配额取前 N 款,等级越高批次越大(高等级天然包含低等级的周免)
|
||||
func VipWeeklyPool(now time.Time) []model.Game {
|
||||
var paid []model.Game
|
||||
database.DB.Where("status = 1 AND price > 0").Order("id").Find(&paid)
|
||||
seed := VipPeriodStart(now).Unix()
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
rng.Shuffle(len(paid), func(i, j int) { paid[i], paid[j] = paid[j], paid[i] })
|
||||
return paid
|
||||
}
|
||||
|
||||
// VipWeeklyBatch 指定等级本周的周免游戏列表(等级≤0 返回空)
|
||||
func VipWeeklyBatch(pool []model.Game, levels []model.VipLevel, level int) []model.Game {
|
||||
if level <= 0 {
|
||||
return nil
|
||||
}
|
||||
quota := 0
|
||||
for _, l := range levels {
|
||||
if l.Level == level {
|
||||
quota = l.WeeklyFreeQuota
|
||||
break
|
||||
}
|
||||
}
|
||||
if quota > len(pool) {
|
||||
quota = len(pool)
|
||||
}
|
||||
return pool[:quota]
|
||||
}
|
||||
|
||||
// UserVipLevel 用户当前生效的 VIP 等级(已过期视为 0)
|
||||
func UserVipLevel(u *model.User, now time.Time) int {
|
||||
if u.VipLevel > 0 && u.VipExpire > now.Unix() {
|
||||
return u.VipLevel
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// VipWeeklyFreeSet 用户本周可白嫖的付费游戏ID集合(非VIP返回空集合)
|
||||
func VipWeeklyFreeSet(u *model.User, now time.Time) map[int]bool {
|
||||
set := map[int]bool{}
|
||||
level := UserVipLevel(u, now)
|
||||
if level <= 0 {
|
||||
return set
|
||||
}
|
||||
var levels []model.VipLevel
|
||||
database.DB.Order("level").Find(&levels)
|
||||
pool := VipWeeklyPool(now)
|
||||
for _, g := range VipWeeklyBatch(pool, levels, level) {
|
||||
set[g.ID] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
38
main.go
Normal file
38
main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// 像素游戏厅后端入口:加载配置 → 连接数据库 → 启动 HTTP/WebSocket 服务
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"nl-game-api-gin/internal/config"
|
||||
"nl-game-api-gin/internal/database"
|
||||
"nl-game-api-gin/internal/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载 config.yaml(端口、数据库、JWT、AI Key)
|
||||
if err := config.Load("config.yaml"); err != nil {
|
||||
log.Fatalln("配置加载失败:", err)
|
||||
}
|
||||
// 初始化 MySQL 连接
|
||||
if err := database.Init(); err != nil {
|
||||
log.Fatalln("数据库初始化失败:", err)
|
||||
}
|
||||
// 仅开发模式(go run 启动)自动补建缺失的表;
|
||||
// go build 打包后的二进制绝不迁移,生产库结构变更一律手动执行 sql/ 脚本
|
||||
if database.IsDevRun() {
|
||||
if err := database.AutoMigrateMissing(); err != nil {
|
||||
log.Fatalln("dev 自动建表失败:", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("生产模式:跳过表结构迁移(结构变更请手动执行 sql/ 脚本)")
|
||||
}
|
||||
// 启动服务
|
||||
engine := router.Setup()
|
||||
addr := fmt.Sprintf(":%d", config.C.Server.Port)
|
||||
log.Println("后端服务已启动,监听", addr)
|
||||
if err := engine.Run(addr); err != nil {
|
||||
log.Fatalln("服务启动失败:", err)
|
||||
}
|
||||
}
|
||||
48
pkg/jwtutil/jwtutil.go
Normal file
48
pkg/jwtutil/jwtutil.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package jwtutil 封装 JWT Token 的签发与解析
|
||||
package jwtutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"nl-game-api-gin/internal/config"
|
||||
)
|
||||
|
||||
// Claims 自定义 JWT 载荷:用户ID与角色
|
||||
type Claims struct {
|
||||
UserID int `json:"user_id"` // 用户ID
|
||||
Role int `json:"role"` // 角色:1超管 2普通
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Generate 为用户签发 Token
|
||||
func Generate(userID, role int) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
// 过期时间从配置文件读取
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.C.JWT.ExpireHours) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(config.C.JWT.Secret))
|
||||
}
|
||||
|
||||
// Parse 解析并校验 Token,返回载荷
|
||||
func Parse(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
return []byte(config.C.JWT.Secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("无效的 Token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
29
pkg/resp/resp.go
Normal file
29
pkg/resp/resp.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Package resp 提供统一的 JSON 响应格式:{code, msg, data}
|
||||
// code=0 表示成功,非 0 表示业务失败
|
||||
package resp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OK 成功响应,data 为业务数据
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "ok", "data": data})
|
||||
}
|
||||
|
||||
// Fail 业务失败响应(HTTP 仍为 200,靠 code 区分)
|
||||
func Fail(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1, "msg": msg, "data": nil})
|
||||
}
|
||||
|
||||
// Unauthorized 未登录/凭证失效响应(HTTP 401,前端据此跳转登录页)
|
||||
func Unauthorized(c *gin.Context, msg string) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": msg, "data": nil})
|
||||
}
|
||||
|
||||
// Forbidden 无权限响应(HTTP 403)
|
||||
func Forbidden(c *gin.Context, msg string) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 403, "msg": msg, "data": nil})
|
||||
}
|
||||
45
tools/dbinit/main.go
Normal file
45
tools/dbinit/main.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// 数据库初始化工具:读取 sql/init.sql 并整体执行(建库建表+种子数据)
|
||||
// 使用方式:在 backend 目录下执行 go run ./tools/dbinit
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 读取初始化 SQL 脚本(相对 backend 目录的上级 sql 目录)
|
||||
content, err := os.ReadFile("../sql/init.sql")
|
||||
if err != nil {
|
||||
fmt.Println("读取 sql/init.sql 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 连接本地 MySQL(不指定库名,因为脚本内部会自行建库)
|
||||
// multiStatements=true 允许一次执行整个脚本
|
||||
dsn := "root:root@tcp(127.0.0.1:3306)/?charset=utf8mb4&multiStatements=true"
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
fmt.Println("连接 MySQL 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
fmt.Println("MySQL 无法连通(请确认 3306 端口、账号 root/root):", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 整体执行初始化脚本
|
||||
if _, err := db.Exec(string(content)); err != nil {
|
||||
fmt.Println("执行初始化脚本失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 简单校验:统计游戏数量确认种子数据写入成功
|
||||
var gameCount int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM xiaoyouxi.games").Scan(&gameCount); err != nil {
|
||||
fmt.Println("校验失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("数据库初始化成功!games 表共 %d 款游戏\n", gameCount)
|
||||
}
|
||||
22
tools/genhash/main.go
Normal file
22
tools/genhash/main.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// 密码哈希生成工具:用于生成 SQL 种子数据中的 bcrypt 密码哈希
|
||||
// 使用方式:go run ./tools/genhash
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 需要生成哈希的明文密码列表(超管密码 + 演示账号密码)
|
||||
passwords := []string{"qiqi991012", "123456"}
|
||||
for _, p := range passwords {
|
||||
// 使用默认成本因子(10)生成 bcrypt 哈希
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("%s => %s\n", p, string(hash))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user