feat: 优化在线玩家统计逻辑,更新游戏引擎构造函数并调整跳过回合物品行为。

This commit is contained in:
邹方成 2026-01-09 14:03:28 +08:00
parent 7e5f77ffd4
commit 6b2f86da1e
20 changed files with 784 additions and 109 deletions

View File

@ -1,5 +1,5 @@
# 全量服务部署 (后端 + 游戏服 + 数据库)
# 使用方法: docker-compose -f docker-compose.all.yml up -d
# 全量服务部署 (前端 + 后端 + 游戏服 + 数据库 + 中间件 + Nginx)
# 使用方法: docker-compose -f docker-compose.all.yml up -d --build
services:
# ----------------------------------------------------
# 1. 业务后端 (Bindbox Game Backend)
@ -8,11 +8,11 @@ services:
image: zfc931912343/bindbox-game:v1.15
container_name: bindbox-game
restart: always
ports:
- "9991:9991"
# ports:
# - "9991:9991" (Internal only)
volumes:
- ../bindbox_game/logs:/app/logs
# - ../bindbox_game/configs:/app/configs # 指向 bindbox_game 目录下的配置
- ../bindbox_game/configs:/app/configs
environment:
- ACTIVE_ENV=pro
- TZ=Asia/Shanghai
@ -23,8 +23,27 @@ services:
options:
max-size: "10m"
max-file: "3"
depends_on:
- mysql
- redis
# ----------------------------------------------------
# 2. 游戏数据库 (CockroachDB for Nakama)
# 2. 管理后台 (Admin Web)
# ----------------------------------------------------
admin-web:
build: ../bindbox_game/web/admin
container_name: bindbox-admin-web
restart: always
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 3. 游戏数据库 (CockroachDB for Nakama)
# ----------------------------------------------------
nakama-db:
image: cockroachdb/cockroach:latest-v23.1
@ -33,9 +52,7 @@ services:
restart: always
volumes:
- nakama-db-data:/var/lib/cockroach
ports:
- "26257:26257"
- "8081:8080"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
interval: 3s
@ -50,14 +67,14 @@ services:
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 3. 游戏服务器 (Nakama)
# 4. 游戏服务器 (Nakama)
# ----------------------------------------------------
nakama:
image: zfc931912343/bindbox-saolei:v1.6
container_name: nakama-server
environment:
# 直接使用服务名访问后端
- MINESWEEPER_BACKEND_URL=http://bindbox-game:9991/api/internal
- TZ=Asia/Shanghai
entrypoint:
@ -72,10 +89,7 @@ services:
condition: service_started
volumes:
- nakama-data:/nakama/data
ports:
- "7350:7350"
- "7351:7351"
- "9100:9100"
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://bindbox-game:9991/" ]
interval: 10s
@ -88,9 +102,147 @@ services:
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 5. MySQL Database (For Bindbox Backend)
# ----------------------------------------------------
mysql:
image: mysql:8.0
container_name: bindbox-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: "123456"
MYSQL_DATABASE: "bindbox_game"
TZ: Asia/Shanghai
command: --default-authentication-plugin=mysql_native_password
volumes:
- mysql_data:/var/lib/mysql
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 6. Redis (For Bindbox Backend)
# ----------------------------------------------------
redis:
image: redis:7.0
container_name: bindbox-redis
restart: always
volumes:
- redis_data:/data
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 7. Nginx Gateway
# ----------------------------------------------------
nginx:
image: nginx:latest
container_name: bindbox-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/ssl:/etc/nginx/ssl
depends_on:
- bindbox-game
- admin-web
- nakama
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
# ----------------------------------------------------
# 8. Loki (Log Storage)
# ----------------------------------------------------
loki:
image: grafana/loki:3.0.0
container_name: bindbox-loki
restart: always
# ports:
# - "3100:3100"
volumes:
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 9. Promtail (Log Collector)
# ----------------------------------------------------
promtail:
image: grafana/promtail:3.0.0
container_name: bindbox-promtail
restart: always
volumes:
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock
- ../bindbox_game/logs:/var/log/bindbox-game:ro
command: -config.file=/etc/promtail/config.yaml
networks:
- bindbox_net
depends_on:
- loki
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 10. Grafana (Visualization)
# ----------------------------------------------------
grafana:
image: grafana/grafana:latest
container_name: bindbox-grafana
restart: always
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
networks:
- bindbox_net
depends_on:
- loki
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
nakama-db-data:
nakama-data:
mysql_data:
redis_data:
loki_data:
grafana_data:
networks:
bindbox_net:
name: bindbox_net

View File

@ -1,55 +1,350 @@
# 云端部署专用 - 扫雷游戏服务
# 使用方法: docker-compose -f docker-compose.cloud.yml up -d
# 全量服务部署 (云端/无源码版)
# 使用方法:
# 1. 确保已将 docker-compose.cloud.yml, configs/, nginx/, loki/ 目录上传到服务器同一目录
# 2. 确保 logs/ 目录存在 (mkdir logs)
# 3. 运行: docker-compose -f docker-compose.cloud.yml up -d
services:
# ----------------------------------------------------
# 1. 业务后端 (Bindbox Game Backend)
# ----------------------------------------------------
bindbox-game:
image: zfc931912343/bindbox-game:v1.15
container_name: bindbox-game
restart: always
# ports:
# - "9991:9991" (Internal only)
volumes:
# 改为挂载当前目录下的 logs 和 configs
- ./logs:/app/logs
- ./configs:/app/configs
environment:
- ACTIVE_ENV=pro
- TZ=Asia/Shanghai
# MySQL 配置(覆盖编译时的默认值)
- MYSQL_ADDR=mysql:3306
- MYSQL_USER=root
- MYSQL_PASS=bindbox2025kdy
- MYSQL_NAME=bindbox_game
# Redis 配置
- REDIS_ADDR=redis:6379
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
- mysql
- redis
# ----------------------------------------------------
# 2. 游戏数据库 (CockroachDB for Nakama)
# ----------------------------------------------------
nakama-db:
image: cockroachdb/cockroach:latest-v23.1
container_name: nakama-db
command: start-single-node --insecure --store=attrs=ssd,path=/var/lib/cockroach/
command: start-single-node --insecure --store=attrs=ssd,path=/var/lib/cockroach/ --cache=.25 --max-sql-memory=.25
restart: always
volumes:
- nakama-db-data:/var/lib/cockroach
ports:
- "26257:26257"
- "8081:8080"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
interval: 3s
timeout: 3s
retries: 5
environment:
- TZ=Asia/Shanghai
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 3. 游戏服务器 (Nakama)
# ----------------------------------------------------
nakama:
image: zfc931912343/bindbox-saolei:v1.3
image: zfc931912343/bindbox-saolei:v1.6
container_name: nakama-server
environment:
# 使用 Docker 内部网络访问后端服务 (需确保在同一网络下)
- MINESWEEPER_BACKEND_URL=http://blindbox-mms-api:9991/api/internal
- MINESWEEPER_BACKEND_URL=http://bindbox-game:9991/api/internal
- TZ=Asia/Shanghai
entrypoint:
- "/bin/sh"
- "-ecx"
- "/nakama/nakama migrate up --database.address root@nakama-db:26257 && exec /nakama/nakama --name nakama1 --database.address root@nakama-db:26257 --logger.level DEBUG --session.token_expiry_sec 7200 --metrics.prometheus_port 9100 --runtime.path /nakama/modules"
- "/nakama/nakama migrate up --database.address root@nakama-db:26257 && exec /nakama/nakama --name nakama1 --database.address root@nakama-db:26257 --logger.level DEBUG --session.token_expiry_sec 7200 --metrics.prometheus_port 9100 --runtime.path /nakama/modules --matchmaker.interval_sec 1 --matchmaker.max_intervals 5"
restart: always
depends_on:
nakama-db:
condition: service_healthy
bindbox-game:
condition: service_started
volumes:
- nakama-data:/nakama/data
ports:
- "7350:7350"
- "7351:7351"
- "9100:9100"
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:7350/" ]
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://bindbox-game:9991/" ]
interval: 10s
timeout: 5s
retries: 5
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 4. MySQL Database
# ----------------------------------------------------
mysql:
image: mysql:8.0
container_name: bindbox-mysql
restart: always
ports:
- "3306:3306" # 临时开放外部访问,用完记得关闭!
environment:
MYSQL_ROOT_PASSWORD: "bindbox2025kdy"
MYSQL_DATABASE: "bindbox_game"
TZ: Asia/Shanghai
command: --default-authentication-plugin=mysql_native_password
volumes:
- mysql_data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d # 初始化脚本
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 5. Redis
# ----------------------------------------------------
redis:
image: redis:7.0
container_name: bindbox-redis
restart: always
volumes:
- redis_data:/data
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 6. Nginx Gateway (入口)
# ----------------------------------------------------
nginx:
image: nginx:latest
container_name: bindbox-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/ssl:/etc/nginx/ssl
- ./dist:/usr/share/nginx/html/admin
depends_on:
- bindbox-game
- nakama
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 7. Loki (日志存储)
# ----------------------------------------------------
loki:
image: grafana/loki:3.0.0
container_name: bindbox-loki
restart: always
volumes:
# 必须上传 loki 目录到服务器
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 8. Promtail (日志采集)
# ----------------------------------------------------
promtail:
image: grafana/promtail:3.0.0
container_name: bindbox-promtail
restart: always
volumes:
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock
# 采集当前目录下的 logs 文件夹
- ./logs:/var/log/bindbox-game:ro
command: -config.file=/etc/promtail/config.yaml
networks:
- bindbox_net
depends_on:
- loki
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 9. Grafana (日志界面)
# ----------------------------------------------------
grafana:
image: grafana/grafana:latest
container_name: bindbox-grafana
restart: always
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
networks:
- bindbox_net
depends_on:
- loki
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 10. Prometheus (指标采集)
# ----------------------------------------------------
prometheus:
image: prom/prometheus:latest
container_name: bindbox-prometheus
restart: always
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 11. Nginx Exporter (Nginx指标导出)
# ----------------------------------------------------
nginx-exporter:
image: nginx/nginx-prometheus-exporter:latest
container_name: bindbox-nginx-exporter
restart: always
command:
- -nginx.scrape-uri=http://nginx:80/nginx_status
networks:
- bindbox_net
depends_on:
- nginx
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 12. Redis Exporter (Redis指标导出)
# ----------------------------------------------------
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: bindbox-redis-exporter
restart: always
environment:
- REDIS_ADDR=redis://redis:6379
networks:
- bindbox_net
depends_on:
- redis
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 13. MySQL Exporter (MySQL指标导出)
# ----------------------------------------------------
mysql-exporter:
image: prom/mysqld-exporter:latest
container_name: bindbox-mysql-exporter
restart: always
command:
- --config.my-cnf=/etc/.my.cnf
volumes:
- ./mysql/.my.cnf:/etc/.my.cnf:ro
networks:
- bindbox_net
depends_on:
- mysql
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 14. Tempo (链路追踪)
# ----------------------------------------------------
tempo:
image: grafana/tempo:latest
container_name: bindbox-tempo
restart: always
command: [ "-config.file=/etc/tempo/tempo-config.yaml" ]
volumes:
- ./tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml
- tempo_data:/var/tempo
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
nakama-db-data:
nakama-data:
mysql_data:
redis_data:
loki_data:
grafana_data:
prometheus_data:
tempo_data:
# 必须加入后端服务所在的网络才能通过 service_name 访问
networks:
default:
name: ${DOCKER_NETWORK_NAME:-bindbox_default}
external: true
bindbox_net:
name: bindbox_net
driver: bridge

38
loki/loki-config.yaml Normal file
View File

@ -0,0 +1,38 @@
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 0
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
ruler:
alertmanager_url: http://localhost:9093
# Limit settings to prevent issues with large logs
limits_config:
reject_old_samples: true
reject_old_samples_max_age: 168h
ingestion_rate_mb: 20
ingestion_burst_size_mb: 30
volume_enabled: true

36
loki/promtail-config.yaml Normal file
View File

@ -0,0 +1,36 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
# 1. Scrape logs from Docker containers via socket (stdout/stderr)
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
# filters:
# - name: name
# values: ["bindbox-game"] # Optional: Filter specifically if desired, but user said "all monitor"
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
- source_labels: ['__meta_docker_container_log_stream']
target_label: 'logstream'
- source_labels: ['__meta_docker_container_label_logging_jobname']
target_label: 'job'
# 2. Scrape logs from mounted log files (application logs)
- job_name: file_logs
static_configs:
- targets:
- localhost
labels:
job: bindbox_app_logs
__path__: /var/log/bindbox-game/*.log

BIN
mysql/.DS_Store vendored Normal file

Binary file not shown.

5
mysql/.my.cnf Normal file
View File

@ -0,0 +1,5 @@
[client]
user = exporter
password = exporter123
host = mysql
port = 3306

View File

@ -0,0 +1,4 @@
-- 创建 MySQL Exporter 监控账号
CREATE USER IF NOT EXISTS 'exporter'@'%' IDENTIFIED BY 'exporter123';
GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'%';
FLUSH PRIVILEGES;

78
nginx/conf.d/default.conf Normal file
View File

@ -0,0 +1,78 @@
server {
listen 80;
server_name kdy.1024tool.vip;
# Nginx 状态监控端点 (HTTP)
location /nginx_status {
stub_status on;
access_log off;
allow 172.0.0.0/8;
allow 192.168.0.0/16; # Docker bridge network
allow 127.0.0.1;
deny all;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS Server
server {
listen 443 ssl;
server_name kdy.1024tool.vip;
# SSL Config
ssl_certificate /etc/nginx/ssl/kdy.1024tool.vip.pem;
ssl_certificate_key /etc/nginx/ssl/kdy.1024tool.vip.key;
ssl_session_timeout 5m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_prefer_server_ciphers on;
# 1. Admin Frontend (Static Dist)
location / {
root /usr/share/nginx/html/admin;
index index.html index.htm;
try_files $uri $uri/ /index.html; # SPA Support
}
# 2. Backend API
location /api/ {
proxy_pass http://bindbox-game:9991/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 3. Nakama API (HTTP / GRPC / WebSocket)
location /v2/ {
proxy_pass http://nakama:7350/v2/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Nakama WebSocket
location /ws {
proxy_pass http://nakama:7350/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Nginx 状态监控端点(仅内网访问)
location /nginx_status {
stub_status on;
access_log off;
allow 172.0.0.0/8; # Docker 内网
allow 10.0.0.0/8; # 内网
allow 127.0.0.1;
deny all;
}
}

33
prometheus/prometheus.yml Normal file
View File

@ -0,0 +1,33 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# Prometheus 自身监控
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Nginx 监控
- job_name: 'nginx'
static_configs:
- targets: ['nginx-exporter:9113']
metrics_path: /metrics
# Redis 监控
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
metrics_path: /metrics
# MySQL 监控
- job_name: 'mysql'
static_configs:
- targets: ['mysql-exporter:9104']
metrics_path: /metrics
# Nakama 游戏服务监控 (如果开启了 metrics)
- job_name: 'nakama'
static_configs:
- targets: ['nakama:9100']
metrics_path: /metrics

Binary file not shown.

View File

@ -72,7 +72,8 @@ type GameState struct {
TurnOrder []string `json:"turnOrder"`
CurrentTurnIndex int `json:"currentTurnIndex"`
Round int `json:"round"`
GlobalTurnCount int `json:"globalTurnCount"` // 总回合数(用于狗狗技能)
GlobalTurnCount int `json:"globalTurnCount"` // 总回合数(用于狗狗技能)
LastDeadPlayerID string `json:"lastDeadPlayerId"` // 最后一个死亡的玩家ID用于平局结算
WinnerID string `json:"winnerId"`
GameStarted bool `json:"gameStarted"`
LastMoveTimestamp int64 `json:"lastMoveTimestamp"` // Unix时间戳

View File

@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/heroiclabs/nakama-common/runtime"
)
@ -74,67 +73,36 @@ func RpcFindMyMatch(ctx context.Context, logger runtime.Logger, db *sql.DB, nk r
return readObjects[0].Value, nil
}
// RpcGetOnlineCount 返回当前在线玩家数量(基于心跳)
// RpcGetOnlineCount 返回当前在线玩家数量
func RpcGetOnlineCount(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
if !ok || userID == "" {
return "", runtime.NewError("user not authenticated", 16)
}
// 1. 更新当前用户的心跳时间戳
now := time.Now().Unix()
heartbeatData, _ := json.Marshal(map[string]int64{"ts": now})
_, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{
{
Collection: "game_lobby",
Key: "heartbeat",
UserID: userID,
Value: string(heartbeatData),
PermissionRead: 0, // 不可读
PermissionWrite: 0, // 仅服务器可写
},
})
// 获取所有活跃比赛中的玩家数
matches, err := nk.MatchList(ctx, 100, true, "", nil, nil, "*")
if err != nil {
logger.Warn("Failed to write heartbeat: %v", err)
logger.Warn("Failed to list matches: %v", err)
}
// 2. 读取所有用户的心跳记录
cursor := ""
onlineCount := 0
expireThreshold := now - 60 // 60秒内有心跳的算在线
for {
// StorageList(ctx, callerID, collection, userID, limit, cursor)
objects, nextCursor, err := nk.StorageList(ctx, "", "game_lobby", "", 100, cursor)
if err != nil {
logger.Error("Failed to list heartbeats: %v", err)
break
}
for _, obj := range objects {
var data map[string]int64
if err := json.Unmarshal([]byte(obj.Value), &data); err != nil {
continue
}
if ts, ok := data["ts"]; ok && ts >= expireThreshold {
onlineCount++
}
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
// 3. 获取比赛中的玩家数
matches, _ := nk.MatchList(ctx, 100, true, "", nil, nil, "*")
inGameCount := 0
for _, m := range matches {
inGameCount += int(m.GetSize())
}
// 使用 Nakama 的 StreamCount 获取当前所有 WebSocket 连接数
// Mode 0 = Notifications stream, 所有已认证的 WebSocket 连接都会加入这个流
onlineCount, err := nk.StreamCount(0, "", "", "")
if err != nil {
logger.Warn("Failed to get stream count: %v", err)
onlineCount = inGameCount // 降级为比赛中玩家数
}
if onlineCount < 1 {
onlineCount = 1 // 至少自己在线
}
result := map[string]interface{}{
"online_count": onlineCount,
"match_count": len(matches),

View File

@ -55,6 +55,7 @@ func (s *PoisonStrategy) Use(state *core.GameState, user *core.Player, ctx ItemC
})
} else {
target.Poisoned = true
target.PoisonSteps = 0
ctx.Logic.BroadcastEvent(core.GameEvent{
Type: "item", PlayerID: user.UserID, PlayerName: user.Username, TargetID: target.UserID, TargetName: target.Username, ItemID: "poison",
Message: fmt.Sprintf("☠️ %s 中毒了!", target.Username),
@ -80,14 +81,6 @@ func (s *ShieldStrategy) Use(state *core.GameState, user *core.Player, ctx ItemC
type SkipStrategy struct{}
func (s *SkipStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool {
if user.Character == "elephant" {
ctx.Logger.Info("Elephant refused skip")
ctx.Logic.BroadcastEvent(core.GameEvent{
Type: "ability", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "skip",
Message: "🐘 大象无法使用该道具!",
})
return false
}
user.SkipTurn = true
user.Shield = true
ctx.Logic.BroadcastEvent(core.GameEvent{

View File

@ -65,6 +65,10 @@ func (m *MockGameLogic) BroadcastEvent(event core.GameEvent) {
m.LastEvent = &event
}
func (m *MockGameLogic) SendPrivateEvent(targetID string, event core.GameEvent) {
m.LastEvent = &event
}
// --- Helper ---
func createTestContext(logic GameLogic) ItemContext {
@ -217,8 +221,11 @@ func TestSkip(t *testing.T) {
user.Character = "elephant"
user.SkipTurn = false
consumed = strategy.Use(state, user, ctx)
if consumed {
t.Error("Elephant should refuse skip")
if !consumed {
t.Error("Elephant should now be able to use skip")
}
if !user.SkipTurn {
t.Error("Elephant skip turn should be true")
}
}

View File

@ -93,8 +93,9 @@ func (e *GameEngine) ApplyDamage(state *core.GameState, target *core.Player, amo
}
}
if target.HP < 0 {
if target.HP <= 0 {
target.HP = 0
state.LastDeadPlayerID = target.UserID
}
}

View File

@ -226,11 +226,12 @@ func TestItem_Skip_Effect(t *testing.T) {
})
}
func TestItem_Skip_ElephantCannotUse(t *testing.T) {
func TestItem_Skip_ElephantCanUse(t *testing.T) {
RunScenario(t, GameScenario{
Name: "好人卡-大象无法使用",
Name: "好人卡-大象现在可以使用",
Players: []PlayerSetup{
{ID: "elephant", Character: "elephant", HP: 5},
{ID: "p2", Character: "dog", HP: 4},
},
Grid: []CellSetup{
{Index: 0, Type: "item", ItemID: "skip"},
@ -239,8 +240,8 @@ func TestItem_Skip_ElephantCannotUse(t *testing.T) {
{Type: "move", PlayerID: "elephant", Value: 0},
},
Checks: []ScenarioCheck{
{PlayerID: "elephant", Field: "skip_turn", Expected: false, Message: "大象无法使用好人卡"},
{PlayerID: "elephant", Field: "shield", Expected: false, Message: "大象不应该获得护盾"},
{PlayerID: "elephant", Field: "skip_turn", Expected: true, Message: "大象现在应该可以使用好人卡"},
{PlayerID: "elephant", Field: "shield", Expected: true, Message: "大象现在应该获得护盾"},
},
})
}
@ -488,18 +489,18 @@ func TestCharacter_Dog_MagnifierAbility(t *testing.T) {
}
state.Grid[99].Type = "bomb" // 放一个未揭示的炸弹
// 直接设置 GlobalTurnCount 为 5下一次操作将使其变为 6
state.GlobalTurnCount = 5
// 狗狗独立步数设为 5下一次操作将使其变为 6
state.Players["dog"].DogStepCount = 5
// 狗狗操作,GlobalTurnCount 变为 66 % 6 == 0应该触发
// 狗狗操作,DogStepCount 变为 66 % 6 == 0应该触发
engine.HandleMove(state, "dog", 0)
t.Logf("操作后 GlobalTurnCount=%d, RevealedCells=%d",
state.GlobalTurnCount, len(state.Players["dog"].RevealedCells))
t.Logf("操作后 DogStepCount=%d, RevealedCells=%d",
state.Players["dog"].DogStepCount, len(state.Players["dog"].RevealedCells))
// 检查是否触发了放大镜
if len(state.Players["dog"].RevealedCells) == 0 {
t.Errorf("狗狗应该在 GlobalTurnCount=6 时触发放大镜能力")
t.Errorf("狗狗应该在 DogStepCount=6 时触发放大镜能力")
} else {
t.Logf("狗狗放大镜触发成功,揭示了 %d 个格子", len(state.Players["dog"].RevealedCells))
}
@ -667,6 +668,30 @@ func TestGameFlow_GameOver(t *testing.T) {
}
}
func TestGameFlow_DrawLastManStanding(t *testing.T) {
engine, state := createScenarioState(GameScenario{
Players: []PlayerSetup{
{ID: "p1", Character: "dog", HP: 1},
{ID: "p2", Character: "cat", HP: 1},
},
Grid: []CellSetup{
{Index: 0, Type: "item", ItemID: "lightning"}, // 闪电对所有人造成1点伤害
},
})
// p1 使用闪电,所有人同时死亡
// 按照逻辑,闪电会依次调用 ApplyDamage 给 p1, p2
// p2 是最后一个被处理的(也是最后一个 HP 归零的),所以应该是 LastDeadPlayerID
engine.HandleMove(state, "p1", 0)
if state.WinnerID != "p2" {
t.Errorf("平局时由于p2是处理列表最后一个死亡的应该获胜。got winner=%s", state.WinnerID)
}
if state.GameStarted {
t.Error("游戏应该结束")
}
}
func TestGameFlow_SafeAreaExpansion(t *testing.T) {
engine, state := createScenarioState(GameScenario{
Players: []PlayerSetup{

View File

@ -88,7 +88,7 @@ func (e *GameEngine) CheckGameOver(state *core.GameState) bool {
if len(alive) == 1 {
winnerID = alive[0]
} else if len(alive) == 0 {
winnerID = "draw"
winnerID = state.LastDeadPlayerID
}
state.WinnerID = winnerID
state.GameStarted = false
@ -102,7 +102,8 @@ func (e *GameEngine) CheckGameOver(state *core.GameState) bool {
}
if winnerPlayer != nil && winnerPlayer.RealUserID > 0 {
config.SettleGameWithBackend(e.Logger, winnerPlayer.RealUserID, winnerPlayer.Ticket, "", true, 100)
// 分数参数暂时传宝箱数,后端奖励由配置决定
config.SettleGameWithBackend(e.Logger, winnerPlayer.RealUserID, winnerPlayer.Ticket, "", true, winnerPlayer.ChestCount)
} else {
e.Logger.Error("Winner player %s has no RealUserID, cannot settle", winnerID)
}

View File

@ -38,7 +38,7 @@ func createTestEngine() (*GameEngine, *core.GameState) {
charMgr := characters.NewCharacterManager(nil)
itemMgr := items.NewItemManager()
engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr)
engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr, nil, nil)
// Create simplified state
p1 := &core.Player{UserID: "p1", Username: "P1", HP: 4, MaxHP: 4, Character: "dog", RevealedCells: make(map[int]string)}

View File

@ -81,7 +81,7 @@ func createScenarioState(scenario GameScenario) (*GameEngine, *core.GameState) {
dispatcher := &MockDispatcher{}
charMgr := characters.NewCharacterManager(nil)
itemMgr := items.NewItemManager()
engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr)
engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr, nil, nil)
// 创建玩家
players := make(map[string]*core.Player)
@ -406,7 +406,7 @@ func TestScenario_SafeAreaExpansion(t *testing.T) {
dispatcher := &MockDispatcher{}
charMgr := characters.NewCharacterManager(nil)
itemMgr := items.NewItemManager()
engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr)
engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr, nil, nil)
// 创建简单网格测试扩散
// 布局 (3x3):

38
tempo/tempo-config.yaml Normal file
View File

@ -0,0 +1,38 @@
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
trace_idle_period: 10s
max_block_bytes: 1_000_000
max_block_duration: 5m
compactor:
compaction:
compaction_window: 1h
max_block_bytes: 100_000_000
block_retention: 48h
compacted_block_retention: 10m
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
metrics_generator:
registry:
external_labels:
source: tempo
storage:
path: /var/tempo/generator/wal