commit 2f5ce0260867758ac9e51a0391552e8c93110583 Author: 李琦 Date: Fri Aug 14 13:17:03 2026 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46fe67d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3110a67 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/nl-game-api-gin.iml b/.idea/nl-game-api-gin.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/nl-game-api-gin.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cmd/dbtool/main.go b/cmd/dbtool/main.go new file mode 100644 index 0000000..7b6eab3 --- /dev/null +++ b/cmd/dbtool/main.go @@ -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 +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..ddc9e4a --- /dev/null +++ b/config.yaml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d16769 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8171ab2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/ai/chess_llm.go b/internal/ai/chess_llm.go new file mode 100644 index 0000000..9a96b32 --- /dev/null +++ b/internal/ai/chess_llm.go @@ -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) +} diff --git a/internal/ai/chess_rule.go b/internal/ai/chess_rule.go new file mode 100644 index 0000000..2174597 --- /dev/null +++ b/internal/ai/chess_rule.go @@ -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 +} diff --git a/internal/ai/ddz_llm.go b/internal/ai/ddz_llm.go new file mode 100644 index 0000000..89ecaf4 --- /dev/null +++ b/internal/ai/ddz_llm.go @@ -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) +} diff --git a/internal/ai/ddz_rule.go b/internal/ai/ddz_rule.go new file mode 100644 index 0000000..e7251d7 --- /dev/null +++ b/internal/ai/ddz_rule.go @@ -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] +} diff --git a/internal/ai/ddz_rule_test.go b/internal/ai/ddz_rule_test.go new file mode 100644 index 0000000..b4ebfd0 --- /dev/null +++ b/internal/ai/ddz_rule_test.go @@ -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) + } +} diff --git a/internal/ai/factory.go b/internal/ai/factory.go new file mode 100644 index 0000000..8e3c235 --- /dev/null +++ b/internal/ai/factory.go @@ -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), + } +} diff --git a/internal/ai/llm.go b/internal/ai/llm.go new file mode 100644 index 0000000..67aae30 --- /dev/null +++ b/internal/ai/llm.go @@ -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 +} diff --git a/internal/ai/llm_test.go b/internal/ai/llm_test.go new file mode 100644 index 0000000..1111aa8 --- /dev/null +++ b/internal/ai/llm_test.go @@ -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("bad gateway")) + 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("连续越界应返回错误") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d5f004a --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..49910f6 --- /dev/null +++ b/internal/database/db.go @@ -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 +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..3139ee2 --- /dev/null +++ b/internal/database/migrate.go @@ -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 +} diff --git a/internal/gamecore/billiards/billiards.go b/internal/gamecore/billiards/billiards.go new file mode 100644 index 0000000..aea164f --- /dev/null +++ b/internal/gamecore/billiards/billiards.go @@ -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 +} diff --git a/internal/gamecore/billiards/billiards_test.go b/internal/gamecore/billiards/billiards_test.go new file mode 100644 index 0000000..89f4248 --- /dev/null +++ b/internal/gamecore/billiards/billiards_test.go @@ -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("犯规后击球权应交给对方") + } +} diff --git a/internal/gamecore/ddz/cards.go b/internal/gamecore/ddz/cards.go new file mode 100644 index 0000000..65cb192 --- /dev/null +++ b/internal/gamecore/ddz/cards.go @@ -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 +} diff --git a/internal/gamecore/ddz/combo.go b/internal/gamecore/ddz/combo.go new file mode 100644 index 0000000..173ed99 --- /dev/null +++ b/internal/gamecore/ddz/combo.go @@ -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 +} diff --git a/internal/gamecore/ddz/game.go b/internal/gamecore/ddz/game.go new file mode 100644 index 0000000..c06f15f --- /dev/null +++ b/internal/gamecore/ddz/game.go @@ -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 +} diff --git a/internal/gamecore/ddz/game_test.go b/internal/gamecore/ddz/game_test.go new file mode 100644 index 0000000..fddbc40 --- /dev/null +++ b/internal/gamecore/ddz/game_test.go @@ -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) + } +} diff --git a/internal/gamecore/ddz/moves.go b/internal/gamecore/ddz/moves.go new file mode 100644 index 0000000..365e8f7 --- /dev/null +++ b/internal/gamecore/ddz/moves.go @@ -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 +} diff --git a/internal/gamecore/ludo/ludo.go b/internal/gamecore/ludo/ludo.go new file mode 100644 index 0000000..01e0f7a --- /dev/null +++ b/internal/gamecore/ludo/ludo.go @@ -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 +} diff --git a/internal/gamecore/ludo/ludo_test.go b/internal/gamecore/ludo/ludo_test.go new file mode 100644 index 0000000..a9ddb96 --- /dev/null +++ b/internal/gamecore/ludo/ludo_test.go @@ -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 事件缺失或字段错误") + } +} diff --git a/internal/gamecore/monopoly/monopoly.go b/internal/gamecore/monopoly/monopoly.go new file mode 100644 index 0000000..6088c23 --- /dev/null +++ b/internal/gamecore/monopoly/monopoly.go @@ -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 + } +} diff --git a/internal/gamecore/monopoly/monopoly_test.go b/internal/gamecore/monopoly/monopoly_test.go new file mode 100644 index 0000000..ff85f11 --- /dev/null +++ b/internal/gamecore/monopoly/monopoly_test.go @@ -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< 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 +} diff --git a/internal/gamecore/xiangqi/board.go b/internal/gamecore/xiangqi/board.go new file mode 100644 index 0000000..927e8d5 --- /dev/null +++ b/internal/gamecore/xiangqi/board.go @@ -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) +} diff --git a/internal/gamecore/xiangqi/rules.go b/internal/gamecore/xiangqi/rules.go new file mode 100644 index 0000000..bf952d3 --- /dev/null +++ b/internal/gamecore/xiangqi/rules.go @@ -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 +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..aae9919 --- /dev/null +++ b/internal/handler/admin.go @@ -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}) +} diff --git a/internal/handler/aiconf.go b/internal/handler/aiconf.go new file mode 100644 index 0000000..121098f --- /dev/null +++ b/internal/handler/aiconf.go @@ -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(), // 往返延迟(毫秒) + }) +} diff --git a/internal/handler/analytics.go b/internal/handler/analytics.go new file mode 100644 index 0000000..c51e7e7 --- /dev/null +++ b/internal/handler/analytics.go @@ -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 + }) +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..b2e5b9b --- /dev/null +++ b/internal/handler/auth.go @@ -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}) +} diff --git a/internal/handler/chat.go b/internal/handler/chat.go new file mode 100644 index 0000000..8fea6cc --- /dev/null +++ b/internal/handler/chat.go @@ -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}) +} diff --git a/internal/handler/config.go b/internal/handler/config.go new file mode 100644 index 0000000..9653bf8 --- /dev/null +++ b/internal/handler/config.go @@ -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, + }, + }) +} diff --git a/internal/handler/friend.go b/internal/handler/friend.go new file mode 100644 index 0000000..5968d99 --- /dev/null +++ b/internal/handler/friend.go @@ -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) +} diff --git a/internal/handler/game.go b/internal/handler/game.go new file mode 100644 index 0000000..dc6af54 --- /dev/null +++ b/internal/handler/game.go @@ -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}) +} diff --git a/internal/handler/rank.go b/internal/handler/rank.go new file mode 100644 index 0000000..2c55888 --- /dev/null +++ b/internal/handler/rank.go @@ -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}) +} diff --git a/internal/handler/shop.go b/internal/handler/shop.go new file mode 100644 index 0000000..fc1b905 --- /dev/null +++ b/internal/handler/shop.go @@ -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}) +} diff --git a/internal/handler/signin.go b/internal/handler/signin.go new file mode 100644 index 0000000..1d7015f --- /dev/null +++ b/internal/handler/signin.go @@ -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}) +} diff --git a/internal/handler/user.go b/internal/handler/user.go new file mode 100644 index 0000000..50ae7c0 --- /dev/null +++ b/internal/handler/user.go @@ -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) +} diff --git a/internal/handler/vip.go b/internal/handler/vip.go new file mode 100644 index 0000000..482a6c0 --- /dev/null +++ b/internal/handler/vip.go @@ -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) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..ccc8856 --- /dev/null +++ b/internal/middleware/middleware.go @@ -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") +} diff --git a/internal/model/game.go b/internal/model/game.go new file mode 100644 index 0000000..2cf4093 --- /dev/null +++ b/internal/model/game.go @@ -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" } diff --git a/internal/model/point.go b/internal/model/point.go new file mode 100644 index 0000000..d522050 --- /dev/null +++ b/internal/model/point.go @@ -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" } diff --git a/internal/model/progress.go b/internal/model/progress.go new file mode 100644 index 0000000..b38fda9 --- /dev/null +++ b/internal/model/progress.go @@ -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" } diff --git a/internal/model/save.go b/internal/model/save.go new file mode 100644 index 0000000..1b505f2 --- /dev/null +++ b/internal/model/save.go @@ -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" } diff --git a/internal/model/shop.go b/internal/model/shop.go new file mode 100644 index 0000000..b6fb3f8 --- /dev/null +++ b/internal/model/shop.go @@ -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" } diff --git a/internal/model/skin.go b/internal/model/skin.go new file mode 100644 index 0000000..15e0b61 --- /dev/null +++ b/internal/model/skin.go @@ -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" } diff --git a/internal/model/social.go b/internal/model/social.go new file mode 100644 index 0000000..dd3a26c --- /dev/null +++ b/internal/model/social.go @@ -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" } diff --git a/internal/model/theme.go b/internal/model/theme.go new file mode 100644 index 0000000..0960503 --- /dev/null +++ b/internal/model/theme.go @@ -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 模型名 +) diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..11ba1bb --- /dev/null +++ b/internal/model/user.go @@ -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" } diff --git a/internal/model/vip.go b/internal/model/vip.go new file mode 100644 index 0000000..c223784 --- /dev/null +++ b/internal/model/vip.go @@ -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" } diff --git a/internal/room/client.go b/internal/room/client.go new file mode 100644 index 0000000..80ec287 --- /dev/null +++ b/internal/room/client.go @@ -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 + } + } + } +} diff --git a/internal/room/hub.go b/internal/room/hub.go new file mode 100644 index 0000000..e1b456a --- /dev/null +++ b/internal/room/hub.go @@ -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() +} diff --git a/internal/room/room.go b/internal/room/room.go new file mode 100644 index 0000000..fa66d9b --- /dev/null +++ b/internal/room/room.go @@ -0,0 +1,1436 @@ +package room + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "sync" + "time" + + "nl-game-api-gin/internal/ai" + "nl-game-api-gin/internal/database" + "nl-game-api-gin/internal/gamecore/billiards" + "nl-game-api-gin/internal/gamecore/ddz" + "nl-game-api-gin/internal/gamecore/ludo" + "nl-game-api-gin/internal/gamecore/monopoly" + "nl-game-api-gin/internal/gamecore/xiangqi" + "nl-game-api-gin/internal/model" + "nl-game-api-gin/internal/service" +) + +// 回合时限(超时自动托管行动) +const ( + bidTimeout = 20 * time.Second // 斗地主叫牌时限 + playTimeout = 30 * time.Second // 斗地主出牌时限 + chessTimeout = 45 * time.Second // 象棋走子时限 + monoTimeout = 30 * time.Second // 大富翁掷骰/购买时限 + ludoTimeout = 30 * time.Second // 飞行棋掷骰/选棋时限 + billTimeout = 45 * time.Second // 台球瞄准击球时限 +) + +// Seat 房间内的一个座位 +type Seat struct { + Index int // 座位号(斗地主0-2,象棋0红1黑) + UserID int // 真人用户ID(AI 座位为 0) + Name string // 显示昵称 + Avatar string // 头像 + Skin string // 皮肤编码(饥荒组队联机:更衣室选择的皮肤) + IsAI bool // 是否 AI 座位 + Ready bool // 是否已准备(AI 恒为 true) + Online bool // 是否在线(断线重连用) + Auto bool // 真人主动托管中(每回合由规则 AI 代打,结算后自动取消) + client *Client // 关联的连接(AI 为 nil) +} + +// chessState 象棋对局状态 +type chessState struct { + board xiangqi.Board // 棋盘 + turnSide int // 轮到哪方走(1红 -1黑) + lastMove *xiangqi.Move // 上一步着法(前端高亮) + moveCount int // 总步数(超过上限判和) +} + +// Room 一个对战房间(斗地主/象棋/大富翁/飞行棋/台球) +type Room struct { + Code string // 6位邀请码 + Game string // doudizhu / chess / monopoly / ludo / billiards + Mode string // pvp / ai + AIProvider string // AI 提供方(spark/deepseek/rule) + AIDifficulty string // AI 难度(easy/medium/hard) + + mu sync.Mutex // 保护以下全部状态 + hub *Hub // 反向引用(结算时清理映射) + seats []*Seat // 座位列表 + hostUserID int // 房主用户ID + status string // waiting / playing + ddzGame *ddz.Game // 斗地主对局(仅斗地主房间) + ddzMarks [3]string // 斗地主叫/抢表态气泡("叫地主!"/"不叫"/"抢地主!"/"不抢",重发时清空) + chessGame *chessState // 象棋对局(仅象棋房间) + monoGame *monopoly.Game // 大富翁对局(仅大富翁房间) + monoEvents []monopoly.Event // 大富翁最近一次动作的动画事件(随状态广播) + monoSeq int // 大富翁动作序号(前端凭连续序号判断是否播放动画) + ludoGame *ludo.Game // 飞行棋对局(仅飞行棋房间) + ludoEvents []ludo.Event // 飞行棋最近一次动作的动画事件(随状态广播) + ludoSeq int // 飞行棋动作序号 + billGame *billiards.Game // 台球对局(仅台球房间) + animMs int // 最近动作的前端动画时长(毫秒,调度下一回合时预留观看时间) + gameAI ai.DdzAI // 斗地主 AI 实例(按房间配置构造) + chessAI ai.ChessAI // 象棋 AI 实例 + escrowDdz ai.DdzAI // 托管用规则 AI(超时/离线代打) + escrowChs ai.ChessAI // 托管用规则象棋 AI + turnSeq int // 回合序号(使过期的定时器与 AI 任务失效) + turnDeadln int64 // 当前回合截止时间戳 + turnTimer *time.Timer // 回合超时定时器 + starveMs int64 // 饥荒:上次转发主机快照的毫秒时间戳(节流保护) + startedAt int64 // 本局开始时间戳 + createdAt int64 // 房间创建时间戳 + lastActive int64 // 最近活跃时间戳(清理判定) + lastResult map[string]any // 上一局结算信息(房间内展示) + closed bool // 房间是否已关闭 +} + +// newRoom 构造房间:按游戏类型确定座位数,按配置构造 AI 实例 +func newRoom(code, game, mode, aiProvider, aiDifficulty string) *Room { + if aiProvider == "" { + aiProvider = ai.ProviderRule + } + if aiDifficulty == "" { + aiDifficulty = ai.DiffMedium + } + // 座位数按游戏类型:斗地主 3、象棋/台球 2、大富翁/飞行棋/饥荒 4 + seatCount := 3 + switch game { + case "chess", "billiards": + seatCount = 2 + case "monopoly", "ludo", "starve": + seatCount = 4 + } + seats := make([]*Seat, seatCount) + for i := range seats { + seats[i] = &Seat{Index: i} + } + now := time.Now().Unix() + return &Room{ + Code: code, Game: game, Mode: mode, + AIProvider: aiProvider, AIDifficulty: aiDifficulty, + seats: seats, + status: "waiting", + gameAI: ai.NewDdzAI(aiProvider, aiDifficulty), + chessAI: ai.NewChessAI(aiProvider, aiDifficulty), + escrowDdz: ai.NewDdzAI(ai.ProviderRule, ai.DiffMedium), + escrowChs: ai.NewChessAI(ai.ProviderRule, ai.DiffEasy), + createdAt: now, lastActive: now, + } +} + +// --------------------------------------------------------------------- +// 座位与成员管理 +// --------------------------------------------------------------------- + +// join 加入房间:isHost 表示建房者(AI 模式建房时立即用 AI 填满其余座位) +func (r *Room) join(c *Client, isHost bool) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return fmt.Errorf("房间已解散") + } + if r.status == "playing" { + return fmt.Errorf("对局进行中,无法加入") + } + // 找空位 + var seat *Seat + for _, s := range r.seats { + if s.UserID == 0 && !s.IsAI { + seat = s + break + } + } + if seat == nil { + return fmt.Errorf("房间已满") + } + seat.UserID = c.userID + seat.Name = c.name + seat.Avatar = c.avatar + seat.Online = true + seat.Ready = isHost // 房主默认准备 + seat.client = c + if isHost { + r.hostUserID = c.userID + r.hub = c.hub + // AI 模式:其余座位立即补 AI + if r.Mode == "ai" { + r.fillAISeatsLocked() + } + } + r.touch() + r.broadcastStateLocked() + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 进入了房间", c.name), false) + return nil +} + +// fillAISeatsLocked 把所有空位补成 AI 座位(需持锁) +func (r *Room) fillAISeatsLocked() { + // 大富翁/飞行棋/台球使用本地规则 AI,名称固定 + aiName := "电脑AI" + switch r.Game { + case "doudizhu": + aiName = r.gameAI.Name() + case "chess": + aiName = r.chessAI.Name() + } + idx := 1 + for _, s := range r.seats { + if s.UserID == 0 && !s.IsAI { + s.IsAI = true + s.Name = fmt.Sprintf("%s·%d号", aiName, idx) + s.Avatar = "🤖" + s.Ready = true + s.Online = true + idx++ + } + } +} + +// reattach 断线重连:把新连接绑回原座位并推送最新状态 +func (r *Room) reattach(c *Client) { + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.seats { + if s.UserID == c.userID { + s.client = c + s.Online = true + r.touch() + r.broadcastStateLocked() + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 重新连接", c.name), false) + return + } + } +} + +// onOffline 连接断开:等待中直接移出房间(返回 true),对局中标记离线等待重连 +func (r *Room) onOffline(c *Client) (leftRoom bool) { + r.mu.Lock() + defer r.mu.Unlock() + seat := r.seatByUser(c.userID) + if seat == nil || seat.client != c { + return false + } + seat.client = nil + seat.Online = false + if r.status == "waiting" { + r.clearSeatLocked(seat) + r.broadcastStateLocked() + return true + } + // 饥荒组队:主机断线对局无法继续,直接结束通知各自结算;队友断线保留座位等待重连 + if r.Game == "starve" { + if seat.UserID == r.hostUserID { + r.broadcastChatLocked(-1, "系统", "", "主机掉线,冒险结束", false) + r.endStarveLocked("主机掉线,冒险结束") + } else { + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 掉线了", seat.Name), false) + } + r.broadcastStateLocked() + return false + } + // 对局中:保留座位等待重连,超时后由托管 AI 代打 + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 掉线了,回合超时将自动托管", seat.Name), false) + r.broadcastStateLocked() + return false +} + +// endStarveLocked 结束饥荒联机对局:房间回等待状态并广播事件,客户端各自走单机结算(需持锁) +func (r *Room) endStarveLocked(reason string) { + if r.status != "playing" { + return + } + r.status = "waiting" + for _, s := range r.seats { + if s.UserID > 0 { + s.Ready = s.UserID == r.hostUserID + } + } + for _, s := range r.seats { + if s.client != nil { + s.client.push("starve_end", map[string]any{"reason": reason}) + } + } +} + +// leave 主动退出:对局中视为认输(大富翁/飞行棋按退赛处理,其余玩家继续) +func (r *Room) leave(c *Client) { + r.mu.Lock() + defer r.mu.Unlock() + seat := r.seatByUser(c.userID) + if seat == nil { + return + } + name := seat.Name + if r.status == "playing" { + switch { + case r.Game == "starve": + // 饥荒组队:房主(主机)退出则对局无法继续,通知全员各自结算;队友退出对局继续 + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 离开了世界", name), false) + if seat.UserID == r.hostUserID { + r.endStarveLocked("主机离开,冒险结束") + } + case r.Game == "monopoly" && r.monoGame != nil: + // 多人棋盘游戏:退出者按破产退赛处理,对局继续 + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 中途退赛", name), false) + r.monoGame.Eliminate(seat.Index) + r.afterMonoActionLocked() + case r.Game == "ludo" && r.ludoGame != nil: + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 中途退赛", name), false) + r.ludoGame.Eliminate(seat.Index) + r.afterLudoActionLocked() + default: + // 双方/三方对局:退出即认输结算 + r.settleLocked(r.winnersWithout(seat.Index), fmt.Sprintf("%s 中途退出,对局结束", name)) + } + } + r.clearSeatLocked(seat) + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 离开了房间", name), false) + r.broadcastStateLocked() +} + +// clearSeatLocked 清空座位;房主离开时移交房主(需持锁) +func (r *Room) clearSeatLocked(seat *Seat) { + uid := seat.UserID + *seat = Seat{Index: seat.Index} + if uid == r.hostUserID { + r.hostUserID = 0 + for _, s := range r.seats { + if s.UserID > 0 { + r.hostUserID = s.UserID + s.Ready = true + break + } + } + } +} + +// winnersWithout 认输结算辅助:返回除指定座位所在方之外的获胜座位(需持锁) +func (r *Room) winnersWithout(loserSeat int) []int { + winners := []int{} + switch r.Game { + case "chess", "billiards": + winners = append(winners, 1-loserSeat) + return winners + case "monopoly", "ludo": + // 多人棋盘游戏:其余所有已占用座位获胜 + for _, s := range r.seats { + if s.Index != loserSeat && (s.UserID > 0 || s.IsAI) { + winners = append(winners, s.Index) + } + } + return winners + } + // 斗地主:退出者是地主 → 两农民胜;退出者是农民 → 地主胜 + g := r.ddzGame + if g == nil || g.Landlord < 0 { + // 还没确定地主就退出:其余两家算胜 + for i := 0; i < 3; i++ { + if i != loserSeat { + winners = append(winners, i) + } + } + return winners + } + if loserSeat == g.Landlord { + for i := 0; i < 3; i++ { + if i != g.Landlord { + winners = append(winners, i) + } + } + } else { + winners = append(winners, g.Landlord) + } + return winners +} + +// seatByUser 按用户ID找座位(需持锁) +func (r *Room) seatByUser(uid int) *Seat { + for _, s := range r.seats { + if s.UserID == uid && uid > 0 { + return s + } + } + return nil +} + +// empty 房间内是否已无真人 +func (r *Room) empty() bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.seats { + if s.UserID > 0 { + return false + } + } + return true +} + +// humanUserIDs 房间内全部真人用户ID +func (r *Room) humanUserIDs() []int { + r.mu.Lock() + defer r.mu.Unlock() + ids := []int{} + for _, s := range r.seats { + if s.UserID > 0 { + ids = append(ids, s.UserID) + } + } + return ids +} + +// expired 房间是否应被清理:无人在线超10分钟或创建超6小时 +func (r *Room) expired() bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return true + } + now := time.Now().Unix() + anyOnline := false + for _, s := range r.seats { + if s.UserID > 0 && s.Online { + anyOnline = true + } + } + if !anyOnline && now-r.lastActive > 600 { + return true + } + return now-r.createdAt > 6*3600 +} + +// close 解散房间并通知所有人 +func (r *Room) close(reason string) { + r.mu.Lock() + defer r.mu.Unlock() + r.closed = true + if r.turnTimer != nil { + r.turnTimer.Stop() + } + for _, s := range r.seats { + if s.client != nil { + s.client.push("room_closed", map[string]string{"reason": reason}) + } + } +} + +// touch 更新活跃时间(需持锁) +func (r *Room) touch() { r.lastActive = time.Now().Unix() } + +// --------------------------------------------------------------------- +// 消息处理与广播 +// --------------------------------------------------------------------- + +// handleMessage 处理房间内消息(准备/开始/聊天/游戏动作) +func (r *Room) handleMessage(c *Client, msgType string, raw json.RawMessage) { + r.mu.Lock() + defer r.mu.Unlock() + seat := r.seatByUser(c.userID) + if seat == nil { + c.pushError("你不在该房间的座位上") + return + } + r.touch() + switch msgType { + case "get_state": + r.pushStateLocked(seat) + case "ready": + var req struct { + Ready bool `json:"ready"` + } + json.Unmarshal(raw, &req) + if r.status != "waiting" { + return + } + seat.Ready = req.Ready + r.broadcastStateLocked() + case "start": + r.handleStartLocked(c, seat) + case "skin": + // 饥荒更衣室:等待阶段设置座位皮肤并广播(对局中不可换装) + var req struct { + Skin string `json:"skin"` + } + json.Unmarshal(raw, &req) + if r.status != "waiting" || len(req.Skin) > 32 { + return + } + seat.Skin = req.Skin + r.broadcastStateLocked() + case "starve_input": + // 饥荒队友输入:不解析内容,附上座位号仅转发给房主连接(主机权威模拟) + if r.Game != "starve" || r.status != "playing" { + return + } + host := r.seatByUser(r.hostUserID) + if host == nil || host.client == nil || seat.UserID == r.hostUserID { + return + } + host.client.push("starve_input", map[string]any{"seat": seat.Index, "data": raw}) + case "starve_state": + // 饥荒主机快照:仅房主可发,原样广播给其余座位(间隔 <80ms 丢弃防刷) + if r.Game != "starve" || r.status != "playing" || seat.UserID != r.hostUserID { + return + } + now := time.Now().UnixMilli() + if now-r.starveMs < 80 { + return + } + r.starveMs = now + for _, s := range r.seats { + if s.client != nil && s.UserID != r.hostUserID { + s.client.push("starve_state", raw) + } + } + case "starve_over": + // 饥荒全灭:房主宣告对局结束,房间回等待状态(各端自行走单机结算) + if r.Game != "starve" || seat.UserID != r.hostUserID { + return + } + r.broadcastChatLocked(-1, "系统", "", "全员阵亡,冒险结束", false) + r.endStarveLocked("全员阵亡,冒险结束") + r.broadcastStateLocked() + case "chat": + var req struct { + Text string `json:"text"` + } + json.Unmarshal(raw, &req) + if text := trimChat(req.Text); text != "" { + r.broadcastChatLocked(seat.Index, seat.Name, seat.Avatar, text, false) + } + case "ddz_bid": + var req struct { + Call bool `json:"call"` + } + json.Unmarshal(raw, &req) + r.handleBidLocked(seat.Index, req.Call, c) + case "ddz_play": + var req struct { + Cards []int `json:"cards"` + } + json.Unmarshal(raw, &req) + r.handlePlayLocked(seat.Index, req.Cards, c) + case "ddz_rob": + var req struct { + Rob bool `json:"rob"` + } + json.Unmarshal(raw, &req) + r.handleRobLocked(seat.Index, req.Rob, c) + case "ddz_pass": + r.handlePassLocked(seat.Index, c) + case "ddz_hint": + var req struct { + Seq int `json:"seq"` + } + json.Unmarshal(raw, &req) + r.handleDdzHintLocked(seat.Index, req.Seq, c) + case "chess_move": + var m xiangqi.Move + json.Unmarshal(raw, &m) + r.handleChessMoveLocked(seat.Index, m, c) + case "chess_hints": + // 走法提示:返回指定己方棋子的合法落点(新手高亮) + var req struct { + FromR int `json:"from_r"` + FromC int `json:"from_c"` + } + json.Unmarshal(raw, &req) + r.handleChessHintsLocked(seat.Index, req.FromR, req.FromC, c) + case "mono_roll": + r.handleMonoRollLocked(seat.Index, c) + case "mono_buy": + var req struct { + Buy bool `json:"buy"` + } + json.Unmarshal(raw, &req) + r.handleMonoBuyLocked(seat.Index, req.Buy, c) + case "mono_upgrade": + var req struct { + Up bool `json:"up"` + } + json.Unmarshal(raw, &req) + r.handleMonoUpgradeLocked(seat.Index, req.Up, c) + case "ludo_roll": + r.handleLudoRollLocked(seat.Index, c) + case "ludo_move": + var req struct { + Plane int `json:"plane"` + } + json.Unmarshal(raw, &req) + r.handleLudoMoveLocked(seat.Index, req.Plane, c) + case "bill_shot": + var req struct { + Angle float64 `json:"angle"` + Power float64 `json:"power"` + SpinX float64 `json:"spin_x"` // 左右塞 -1~1(右塞为正) + SpinY float64 `json:"spin_y"` // 高低杆 -1~1(高杆为正) + } + json.Unmarshal(raw, &req) + r.handleBillShotLocked(seat.Index, req.Angle, req.Power, req.SpinX, req.SpinY, c) + case "escrow": + // 主动托管开关:开启后每回合由托管规则 AI 代打 + var req struct { + On bool `json:"on"` + } + json.Unmarshal(raw, &req) + r.handleEscrowLocked(seat, req.On) + case "resign": + // 认输:双人局直接判负结算;大富翁/飞行棋按退赛处理(留座观战) + if r.status != "playing" { + return + } + switch { + case r.Game == "monopoly" && r.monoGame != nil: + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 认输退赛", seat.Name), false) + r.monoGame.Eliminate(seat.Index) + r.afterMonoActionLocked() + case r.Game == "ludo" && r.ludoGame != nil: + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 认输退赛", seat.Name), false) + r.ludoGame.Eliminate(seat.Index) + r.afterLudoActionLocked() + default: + r.settleLocked(r.winnersWithout(seat.Index), fmt.Sprintf("%s 认输,对局结束", seat.Name)) + r.broadcastStateLocked() + } + default: + c.pushError("未知的消息类型") + } +} + +// handleEscrowLocked 真人切换托管状态(需持锁): +// 开启后每回合由托管规则 AI 自动代打,结算时自动取消 +func (r *Room) handleEscrowLocked(seat *Seat, on bool) { + if seat.IsAI || seat.Auto == on { + return + } + seat.Auto = on + if on { + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 开启托管,由系统代打", seat.Name), false) + } else { + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 取消托管,恢复亲自操作", seat.Name), false) + } + r.broadcastStateLocked() + // 开启时恰好轮到自己:立即安排一次代打 + if on && r.status == "playing" { + if cur := r.currentSeatLocked(); cur == seat { + seq := r.turnSeq + go func() { + time.Sleep(600 * time.Millisecond) + r.aiActWith(seq, true) + }() + } + } +} + +// trimChat 聊天内容清洗:去空格并限制长度 +func trimChat(text string) string { + runes := []rune(text) + if len(runes) > 100 { + runes = runes[:100] + } + return string(runes) +} + +// handleStartLocked 房主开始游戏:空位自动补 AI,人满且都准备后开局(需持锁) +func (r *Room) handleStartLocked(c *Client, seat *Seat) { + if r.status != "waiting" { + c.pushError("对局已在进行中") + return + } + if c.userID != r.hostUserID { + c.pushError("只有房主可以开始游戏") + return + } + // 除房主外所有真人必须已准备 + for _, s := range r.seats { + if s.UserID > 0 && s.UserID != r.hostUserID && !s.Ready { + c.pushError(fmt.Sprintf("%s 还没准备", s.Name)) + return + } + } + // 空位补 AI(饥荒组队为 1~4 人弹性开局,空位保持空置) + if r.Game != "starve" { + r.fillAISeatsLocked() + } + r.startGameLocked() +} + +// broadcastChatLocked 广播聊天消息(seat=-1 表示系统消息,需持锁) +func (r *Room) broadcastChatLocked(seatIdx int, name, avatar, text string, isAI bool) { + msg := map[string]any{ + "seat": seatIdx, "name": name, "avatar": avatar, + "text": text, "is_ai": isAI, "ts": time.Now().Unix(), + } + for _, s := range r.seats { + if s.client != nil { + s.client.push("chat", msg) + } + } +} + +// broadcastStateLocked 向所有真人座位推送各自视角的房间状态(需持锁) +func (r *Room) broadcastStateLocked() { + for _, s := range r.seats { + if s.client != nil { + r.pushStateLocked(s) + } + } +} + +// pushStateLocked 向单个座位推送其视角的状态快照(需持锁) +func (r *Room) pushStateLocked(seat *Seat) { + if seat.client == nil { + return + } + seat.client.push("room_state", r.buildStateLocked(seat.Index)) +} + +// buildStateLocked 构建指定座位视角的状态快照(隐藏他人手牌,需持锁) +func (r *Room) buildStateLocked(viewSeat int) map[string]any { + seats := make([]map[string]any, len(r.seats)) + for i, s := range r.seats { + info := map[string]any{ + "index": i, "user_id": s.UserID, "name": s.Name, "avatar": s.Avatar, + "is_ai": s.IsAI, "ready": s.Ready, "online": s.Online, + "occupied": s.UserID > 0 || s.IsAI, "auto": s.Auto, "skin": s.Skin, + } + if r.ddzGame != nil { + info["cards_count"] = len(r.ddzGame.Hands[i]) + info["is_landlord"] = r.ddzGame.Landlord == i + } + seats[i] = info + } + state := map[string]any{ + "code": r.Code, "game": r.Game, "mode": r.Mode, + "ai_provider": r.AIProvider, "ai_difficulty": r.AIDifficulty, + "status": r.status, "host_id": r.hostUserID, + "my_seat": viewSeat, "seats": seats, + "deadline": r.turnDeadln, "last_result": r.lastResult, + } + // 斗地主视角数据 + if r.Game == "doudizhu" && r.ddzGame != nil { + g := r.ddzGame + dd := map[string]any{ + "phase": string(g.Phase), "turn": g.Turn, + "my_hand": g.Hands[viewSeat], "bombs": g.Bombs, + "landlord": g.Landlord, + // 当前倍数(叫抢×炸弹,春天在结算时另乘) + "multiplier": g.CurrentMultiplier(), + // 叫/抢表态气泡(前端在叫抢阶段显示在座位旁) + "marks": r.ddzMarks[:], + } + // 底牌只在地主确定后公开 + if g.Landlord >= 0 { + dd["bottom"] = g.Bottom + } + // 最近一手牌 + if g.LastCombo != nil { + dd["last_seat"] = g.LastSeat + dd["last_cards"] = g.LastCombo.Cards + dd["last_desc"] = g.LastCombo.Desc() + } else { + dd["last_seat"] = -1 + } + // 当前玩家能否过牌 + dd["can_pass"] = g.LastCombo != nil && g.LastSeat != g.Turn + // 出牌历史尾部(最多10条) + tail := g.History + if len(tail) > 10 { + tail = tail[len(tail)-10:] + } + dd["history"] = tail + state["ddz"] = dd + } + // 象棋视角数据 + if r.Game == "chess" && r.chessGame != nil { + cs := r.chessGame + mySide := xiangqi.Red + if viewSeat == 1 { + mySide = xiangqi.Black + } + state["chess"] = map[string]any{ + "board": cs.board, "turn_side": cs.turnSide, + "my_side": mySide, "last_move": cs.lastMove, + "in_check": cs.board.InCheck(cs.turnSide), + "move_count": cs.moveCount, + } + } + // 大富翁/飞行棋/台球数据(信息全公开) + if r.Game == "monopoly" && r.monoGame != nil { + state["mono"] = r.monoStateLocked() + } + if r.Game == "ludo" && r.ludoGame != nil { + state["ludo"] = r.ludoStateLocked() + } + if r.Game == "billiards" && r.billGame != nil { + state["bill"] = r.billStateLocked(viewSeat) + } + return state +} + +// --------------------------------------------------------------------- +// 对局流程(开局 → 回合调度 → 动作处理 → 结算) +// --------------------------------------------------------------------- + +// startGameLocked 初始化对局并开始第一回合(需持锁) +func (r *Room) startGameLocked() { + r.status = "playing" + r.startedAt = time.Now().Unix() + r.lastResult = nil + switch r.Game { + case "doudizhu": + r.ddzGame = ddz.NewGame() + r.ddzMarks = [3]string{} + case "chess": + r.chessGame = &chessState{board: xiangqi.Initial(), turnSide: xiangqi.Red} + case "monopoly": + r.monoGame = monopoly.NewGame(len(r.seats)) + r.monoEvents, r.monoSeq = nil, 0 + case "ludo": + r.ludoGame = ludo.NewGame() + r.ludoEvents, r.ludoSeq = nil, 0 + case "billiards": + r.billGame = billiards.NewGame() + case "starve": + // 饥荒:主机权威模拟在房主端进行,服务端只做转发,无引擎与回合调度 + r.starveMs = 0 + r.broadcastChatLocked(-1, "系统", "", "冒险开始!一起活下去!", false) + r.broadcastStateLocked() + return + } + r.animMs = 0 + r.broadcastChatLocked(-1, "系统", "", "对局开始!", false) + r.scheduleTurnLocked() + r.broadcastStateLocked() +} + +// currentSeatLocked 当前该行动的座位(需持锁) +func (r *Room) currentSeatLocked() *Seat { + if r.status != "playing" { + return nil + } + switch r.Game { + case "doudizhu": + if r.ddzGame != nil { + return r.seats[r.ddzGame.Turn] + } + case "chess": + if r.chessGame != nil { + if r.chessGame.turnSide == xiangqi.Red { + return r.seats[0] + } + return r.seats[1] + } + case "monopoly": + if r.monoGame != nil { + return r.seats[r.monoGame.Turn] + } + case "ludo": + if r.ludoGame != nil { + return r.seats[r.ludoGame.Turn] + } + case "billiards": + if r.billGame != nil { + return r.seats[r.billGame.Turn] + } + } + return nil +} + +// turnDuration 当前回合时限(需持锁) +func (r *Room) turnDuration() time.Duration { + switch r.Game { + case "chess": + return chessTimeout + case "monopoly": + return monoTimeout + case "ludo": + return ludoTimeout + case "billiards": + return billTimeout + } + if r.ddzGame != nil && (r.ddzGame.Phase == ddz.PhaseBidding || r.ddzGame.Phase == ddz.PhaseRobbing) { + return bidTimeout + } + return playTimeout +} + +// scheduleTurnLocked 开启新回合:重置超时定时器,AI 座位安排延迟行动(需持锁) +func (r *Room) scheduleTurnLocked() { + r.turnSeq++ + seq := r.turnSeq + dur := r.turnDuration() + // 上一动作的前端动画还在播放(台球回放/大富翁走格/飞行棋飞行),超时与 AI 行动都预留观看时间 + extra := time.Duration(0) + if r.animMs > 0 { + extra = time.Duration(r.animMs) * time.Millisecond + r.animMs = 0 + } + dur += extra + r.turnDeadln = time.Now().Add(dur).Unix() + if r.turnTimer != nil { + r.turnTimer.Stop() + } + r.turnTimer = time.AfterFunc(dur, func() { r.onTimeout(seq) }) + // AI 座位:1.2~2.7 秒后行动(模拟思考) + seat := r.currentSeatLocked() + if seat != nil && seat.IsAI { + delay := time.Duration(1200+rand.Intn(1500))*time.Millisecond + extra + go func() { + time.Sleep(delay) + r.aiAct(seq) + }() + } + // 真人开启托管:短暂延迟后由托管规则 AI 代打 + if seat != nil && !seat.IsAI && seat.Auto { + delay := time.Duration(900+rand.Intn(600))*time.Millisecond + extra + go func() { + time.Sleep(delay) + r.aiActWith(seq, true) + }() + } +} + +// onTimeout 回合超时:由托管规则 AI 代替当前座位行动 +func (r *Room) onTimeout(seq int) { + r.mu.Lock() + if r.closed || r.status != "playing" || seq != r.turnSeq { + r.mu.Unlock() + return + } + seat := r.currentSeatLocked() + if seat == nil { + r.mu.Unlock() + return + } + if !seat.IsAI { + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 超时,本回合自动托管", seat.Name), false) + } + r.mu.Unlock() + // 超时托管与 AI 行动共用一条路径(用托管规则 AI) + r.aiActWith(seq, true) +} + +// aiAct AI 座位的正常行动(用房间配置的 AI 实例) +func (r *Room) aiAct(seq int) { r.aiActWith(seq, false) } + +// aiActWith AI 决策并执行:escrow=true 时使用托管规则 AI +// 决策阶段不持锁(LLM 可能耗时数秒),执行前重新校验回合有效性 +func (r *Room) aiActWith(seq int, escrow bool) { + // 第一步:持锁快照当前局面 + r.mu.Lock() + if r.closed || r.status != "playing" || seq != r.turnSeq { + r.mu.Unlock() + return + } + seat := r.currentSeatLocked() + if seat == nil { + r.mu.Unlock() + return + } + seatIdx := seat.Index + game := r.Game + // 大富翁/飞行棋/台球:本地规则 AI 决策瞬时完成,直接持锁执行后返回 + switch game { + case "monopoly": + r.aiMonoActLocked(seatIdx, escrow) + r.mu.Unlock() + return + case "ludo": + r.aiLudoActLocked(seatIdx, escrow) + r.mu.Unlock() + return + case "billiards": + r.aiBillActLocked(seatIdx, escrow) + r.mu.Unlock() + return + } + var ( + ddzHand []int + ddzLast *ddz.Combo + ddzPhase ddz.Phase + ddzCtx ai.PlayContext + chessBoard xiangqi.Board + chessSide int + ) + if game == "doudizhu" { + g := r.ddzGame + ddzHand = append([]int{}, g.Hands[seatIdx]...) + ddzLast = g.LastCombo + ddzPhase = g.Phase + ddzCtx = r.ddzPlayCtxLocked(seatIdx) + } else { + cs := r.chessGame + chessBoard = cs.board + chessSide = cs.turnSide + } + // 选择决策器:正常 AI 或托管规则 AI + ddzBrain := r.gameAI + chessBrain := r.chessAI + if escrow { + ddzBrain = r.escrowDdz + chessBrain = r.escrowChs + } + r.mu.Unlock() + // 第二步:锁外决策(LLM 最长十余秒) + var ( + bidCall bool + robCall bool + playCards []int + say string + chessMv xiangqi.Move + ) + if game == "doudizhu" { + switch ddzPhase { + case ddz.PhaseBidding: + bidCall, say = ddzBrain.DecideBid(ddzHand) + case ddz.PhaseRobbing: + robCall, say = ddzBrain.DecideRob(ddzHand) + default: + playCards, say = ddzBrain.DecidePlay(ddzHand, ddzLast, ddzCtx) + } + } else { + chessMv, say = chessBrain.DecideMove(&chessBoard, chessSide) + } + // 第三步:持锁校验回合仍有效后执行 + r.mu.Lock() + defer r.mu.Unlock() + if r.closed || r.status != "playing" || seq != r.turnSeq { + return + } + seat = r.seats[seatIdx] + // AI 台词随聊天下发 + if say != "" { + r.broadcastChatLocked(seatIdx, seat.Name, seat.Avatar, say, true) + } + if game == "doudizhu" { + if ddzPhase == ddz.PhaseBidding { + r.applyBidLocked(seatIdx, bidCall) + } else if ddzPhase == ddz.PhaseRobbing { + r.applyRobLocked(seatIdx, robCall) + } else if playCards == nil { + // 托管/AI 选择过牌;不能过时强制出最小牌 + if err := r.ddzGame.Pass(seatIdx); err != nil { + if moves := ddz.GenMoves(r.ddzGame.Hands[seatIdx], r.ddzGame.LastCombo); len(moves) > 0 { + r.applyPlayLocked(seatIdx, moves[0]) + return + } + } else { + r.afterDdzActionLocked(seatIdx, nil) + } + } else { + // AI 决策基于快照,执行前再校验一次合法性,失败则出最小牌兜底 + if _, err := r.tryPlayLocked(seatIdx, playCards); err != nil { + if moves := ddz.GenMoves(r.ddzGame.Hands[seatIdx], r.ddzGame.LastCombo); len(moves) > 0 { + r.applyPlayLocked(seatIdx, moves[0]) + } else if r.ddzGame.LastCombo != nil { + r.ddzGame.Pass(seatIdx) + r.afterDdzActionLocked(seatIdx, nil) + } + } + } + } else { + // 象棋:校验合法性,非法则规则 AI 重算 + if !r.chessGame.board.IsLegal(chessMv, chessSide) { + chessMv, _ = r.escrowChs.DecideMove(&r.chessGame.board, chessSide) + } + r.applyChessMoveLocked(seatIdx, chessMv) + } +} + +// --------------------------------------------------------------------- +// 斗地主动作 +// --------------------------------------------------------------------- + +// handleBidLocked 真人叫地主(需持锁) +func (r *Room) handleBidLocked(seatIdx int, call bool, c *Client) { + if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhaseBidding { + c.pushError("当前不在叫地主阶段") + return + } + if r.ddzGame.Turn != seatIdx { + c.pushError("还没轮到你表态") + return + } + r.applyBidLocked(seatIdx, call) +} + +// applyBidLocked 执行叫地主表态并推进流程(需持锁) +func (r *Room) applyBidLocked(seatIdx int, call bool) { + seat := r.seats[seatIdx] + redeal, err := r.ddzGame.Bid(seatIdx, call) + if err != nil { + return + } + action := "不叫" + if call { + action = "叫地主!" + } + r.ddzMarks[seatIdx] = action + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s:%s", seat.Name, action), false) + if redeal { + // 三家都不叫:重新发牌重新叫,表态气泡一并清空 + r.broadcastChatLocked(-1, "系统", "", "三家都不叫,重新发牌", false) + r.ddzGame = ddz.NewGame() + r.ddzMarks = [3]string{} + } else if r.ddzGame.Phase == ddz.PhaseRobbing { + r.broadcastChatLocked(-1, "系统", "", "进入抢地主环节:其余两家可以抢,每抢一次倍数×2", false) + } + r.scheduleTurnLocked() + r.broadcastStateLocked() +} + +// handleRobLocked 真人抢地主表态(需持锁) +func (r *Room) handleRobLocked(seatIdx int, rob bool, c *Client) { + if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhaseRobbing { + c.pushError("当前不在抢地主阶段") + return + } + if r.ddzGame.Turn != seatIdx { + c.pushError("还没轮到你表态") + return + } + r.applyRobLocked(seatIdx, rob) +} + +// applyRobLocked 执行抢地主表态并推进流程(需持锁) +func (r *Room) applyRobLocked(seatIdx int, rob bool) { + seat := r.seats[seatIdx] + if err := r.ddzGame.Rob(seatIdx, rob); err != nil { + return + } + if rob { + r.ddzMarks[seatIdx] = "抢地主!" + r.broadcastChatLocked(-1, "系统", "", + fmt.Sprintf("%s:抢地主!倍数翻倍(当前 %d 倍)", seat.Name, r.ddzGame.Multiplier), false) + } else { + r.ddzMarks[seatIdx] = "不抢" + r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s:不抢", seat.Name), false) + } + if r.ddzGame.Phase == ddz.PhasePlaying { + r.broadcastChatLocked(-1, "系统", "", + fmt.Sprintf("%s 成为地主,亮出底牌", r.seats[r.ddzGame.Landlord].Name), false) + } + r.scheduleTurnLocked() + r.broadcastStateLocked() +} + +// handlePlayLocked 真人出牌(需持锁) +func (r *Room) handlePlayLocked(seatIdx int, cards []int, c *Client) { + if _, err := r.tryPlayLocked(seatIdx, cards); err != nil { + c.pushError(err.Error()) + } +} + +// tryPlayLocked 尝试出牌,成功后推进流程(需持锁) +func (r *Room) tryPlayLocked(seatIdx int, cards []int) (*ddz.Combo, error) { + if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhasePlaying { + return nil, fmt.Errorf("当前不在出牌阶段") + } + combo, err := r.ddzGame.Play(seatIdx, cards) + if err != nil { + return nil, err + } + r.afterDdzActionLocked(seatIdx, combo) + return combo, nil +} + +// applyPlayLocked 直接出牌(AI 兜底路径,忽略错误,需持锁) +func (r *Room) applyPlayLocked(seatIdx int, cards []int) { + if combo, err := r.ddzGame.Play(seatIdx, cards); err == nil { + r.afterDdzActionLocked(seatIdx, combo) + } +} + +// handlePassLocked 真人过牌(需持锁) +func (r *Room) handlePassLocked(seatIdx int, c *Client) { + if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhasePlaying { + c.pushError("当前不在出牌阶段") + return + } + if err := r.ddzGame.Pass(seatIdx); err != nil { + c.pushError(err.Error()) + return + } + r.afterDdzActionLocked(seatIdx, nil) +} + +// ddzPlayCtxLocked 组装斗地主出牌决策上下文(需持锁):身份与各家剩牌 +func (r *Room) ddzPlayCtxLocked(seatIdx int) ai.PlayContext { + g := r.ddzGame + pctx := ai.PlayContext{} + if g == nil || g.Landlord < 0 { + return pctx + } + lord := g.Landlord + pctx.IsLandlord = seatIdx == lord + pctx.LandlordCards = len(g.Hands[lord]) + if pctx.IsLandlord { + // 地主视角:敌方是两位平民,取剩牌最少者 + for i := 0; i < 3; i++ { + if i == seatIdx { + continue + } + if n := len(g.Hands[i]); pctx.OppMin == 0 || n < pctx.OppMin { + pctx.OppMin = n + } + } + } else { + // 平民视角:队友是另一位平民(三座位号之和为 3),敌方只有地主 + partner := 3 - seatIdx - lord + pctx.PartnerCards = len(g.Hands[partner]) + pctx.OppMin = len(g.Hands[lord]) + pctx.LastIsPartner = g.LastCombo != nil && g.LastSeat == partner + } + return pctx +} + +// handleDdzHintLocked 出牌提示(需持锁):seq=0 返回配合策略推荐的一手, +// 连续点击(seq 递增)时按牌力从小到大轮换全部可压候选 +func (r *Room) handleDdzHintLocked(seatIdx, seq int, c *Client) { + g := r.ddzGame + if g == nil || r.status != "playing" || g.Phase != ddz.PhasePlaying { + c.pushError("当前不在出牌阶段") + return + } + if g.Turn != seatIdx { + c.pushError("还没轮到你出牌") + return + } + moves := ddz.GenMoves(g.Hands[seatIdx], g.LastCombo) + if len(moves) == 0 { + // 没有任何能压的牌:告知只能过 + c.push("ddz_hint", map[string]any{"none": true}) + return + } + if seq <= 0 { + // 首次提示:走托管规则 AI(带平民配合),可能建议过牌 + hand := append([]int{}, g.Hands[seatIdx]...) + pick, _ := r.escrowDdz.DecidePlay(hand, g.LastCombo, r.ddzPlayCtxLocked(seatIdx)) + if pick == nil && g.LastCombo != nil { + c.push("ddz_hint", map[string]any{"suggest_pass": true}) + return + } + if pick != nil { + c.push("ddz_hint", map[string]any{"cards": pick}) + return + } + } + idx := seq + if idx < 0 { + idx = 0 + } + c.push("ddz_hint", map[string]any{"cards": moves[idx%len(moves)]}) +} + +// afterDdzActionLocked 斗地主动作后的统一收尾:结束判定或进入下一回合(需持锁) +func (r *Room) afterDdzActionLocked(seatIdx int, combo *ddz.Combo) { + g := r.ddzGame + if g.Phase == ddz.PhaseOver { + // 结算:地主胜或农民胜,描述带上春天与总倍数 + winners := []int{} + desc := "" + if g.LandlordWon() { + winners = append(winners, g.Landlord) + desc = fmt.Sprintf("地主 %s 获胜!", r.seats[g.Landlord].Name) + if g.SpringMultiplier() == 2 { + desc += "春天!农民一张牌都没出!" + } + } else { + for i := 0; i < 3; i++ { + if i != g.Landlord { + winners = append(winners, i) + } + } + desc = "农民配合获胜!" + if g.SpringMultiplier() == 2 { + desc += "反春!地主只出了一手牌!" + } + } + if m := g.TotalMultiplier(); m > 1 { + desc += fmt.Sprintf("(%d 倍结算:叫抢 ×%d、炸弹 ×%d、春天 ×%d)", + m, g.Multiplier, 1<= 300 { + r.settleLocked(nil, "步数达到上限,双方战平") + r.broadcastStateLocked() + return + } + r.scheduleTurnLocked() + r.broadcastStateLocked() +} + +// --------------------------------------------------------------------- +// 结算 +// --------------------------------------------------------------------- + +// settleLocked 对局结算:发积分、写对战记录、重置房间为等待状态(需持锁) +// winners 为获胜座位列表(nil=平局) +func (r *Room) settleLocked(winners []int, desc string) { + if r.status != "playing" { + return + } + r.status = "waiting" + if r.turnTimer != nil { + r.turnTimer.Stop() + } + r.turnSeq++ // 使遗留的定时器与 AI 任务全部失效 + r.turnDeadln = 0 + duration := int(time.Now().Unix() - r.startedAt) + winSet := map[int]bool{} + for _, w := range winners { + winSet[w] = true + } + // 查游戏ID与奖励配置 + var game model.Game + database.DB.Where("code = ?", r.Game).First(&game) + winPoints := service.GetConfigInt(model.ConfKeyBattleWinPoints, 30) + // 斗地主按倍数放大胜利积分(叫抢 × 炸弹 × 春天,牌局核心已封顶 64 倍) + if r.Game == "doudizhu" && r.ddzGame != nil { + winPoints *= r.ddzGame.TotalMultiplier() + } + mode := model.BattleModePVP + aiProvider, aiDiff := "", "" + if r.Mode == "ai" { + mode = model.BattleModeAI + aiProvider, aiDiff = r.AIProvider, r.AIDifficulty + } + rewards := map[int]int{} + // 给每个真人座位写对战记录与积分 + for _, s := range r.seats { + if s.UserID <= 0 { + continue + } + result := model.BattleResultLose + points := 0 + if winners == nil { + result = model.BattleResultDraw + } else if winSet[s.Index] { + result = model.BattleResultWin + points = winPoints + } + if points > 0 { + // 发放失败时不能让结算面板与对战记录显示"已获奖"的假象 + if _, err := service.ChangePoints(nil, s.UserID, points, model.PointTypeBattle, 0, + fmt.Sprintf("「%s」对战获胜奖励", game.Name)); err != nil { + log.Printf("[结算] 用户 %d 对战奖励 %d 积分发放失败: %v", s.UserID, points, err) + points = 0 + } else { + rewards[s.Index] = points + } + } + database.DB.Create(&model.BattleRecord{ + UserID: s.UserID, GameID: game.ID, RoomCode: r.Code, + Mode: mode, AIProvider: aiProvider, AIDifficulty: aiDiff, + Result: result, PointsChange: points, Duration: duration, Detail: desc, + }) + } + // 组装结算信息(房间内展示)+ 重置准备状态 + winnerNames := []string{} + for _, w := range winners { + winnerNames = append(winnerNames, r.seats[w].Name) + } + r.lastResult = map[string]any{ + "desc": desc, "winners": winners, "winner_names": winnerNames, + "rewards": rewards, "duration": duration, + } + for _, s := range r.seats { + if s.UserID > 0 { + s.Ready = s.UserID == r.hostUserID // 房主保持准备 + } + s.Auto = false // 托管随对局结束自动取消 + } + r.broadcastChatLocked(-1, "系统", "", desc, false) + // 广播结算事件(前端弹结算面板) + for _, s := range r.seats { + if s.client != nil { + s.client.push("battle_end", r.lastResult) + } + } +} diff --git a/internal/room/room_billiards.go b/internal/room/room_billiards.go new file mode 100644 index 0000000..e33ee52 --- /dev/null +++ b/internal/room/room_billiards.go @@ -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)}, + } +} diff --git a/internal/room/room_ludo.go b/internal/room/room_ludo.go new file mode 100644 index 0000000..7cf3692 --- /dev/null +++ b/internal/room/room_ludo.go @@ -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, + } +} diff --git a/internal/room/room_monopoly.go b/internal/room/room_monopoly.go new file mode 100644 index 0000000..35322c8 --- /dev/null +++ b/internal/room/room_monopoly.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..45231d0 --- /dev/null +++ b/internal/router/router.go @@ -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 +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..c711569 --- /dev/null +++ b/internal/service/service.go @@ -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 +} diff --git a/internal/service/vip.go b/internal/service/vip.go new file mode 100644 index 0000000..54d9b25 --- /dev/null +++ b/internal/service/vip.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1b5be75 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/pkg/jwtutil/jwtutil.go b/pkg/jwtutil/jwtutil.go new file mode 100644 index 0000000..09c970e --- /dev/null +++ b/pkg/jwtutil/jwtutil.go @@ -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 +} diff --git a/pkg/resp/resp.go b/pkg/resp/resp.go new file mode 100644 index 0000000..ca81853 --- /dev/null +++ b/pkg/resp/resp.go @@ -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}) +} diff --git a/tools/dbinit/main.go b/tools/dbinit/main.go new file mode 100644 index 0000000..d4d09dd --- /dev/null +++ b/tools/dbinit/main.go @@ -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) +} diff --git a/tools/genhash/main.go b/tools/genhash/main.go new file mode 100644 index 0000000..eb75ba1 --- /dev/null +++ b/tools/genhash/main.go @@ -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)) + } +}