通用机器人

This commit is contained in:
zhx 2026-06-11 21:34:13 +08:00
parent ff0e42e3d3
commit 25a3855454
5 changed files with 231 additions and 81 deletions

View File

@ -0,0 +1,31 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_game;
-- 清理旧初始化脚本写入的自研游戏展示入口不删除对局、钱包、outbox 和奖池流水,避免破坏历史业务事实。
DELETE FROM game_display_rules
WHERE app_code = 'lalu'
AND game_id IN ('dice', 'rock');
DELETE FROM game_self_game_configs
WHERE app_code = 'lalu'
AND game_id IN ('dice', 'rock');
DELETE FROM game_self_game_pools
WHERE app_code = 'lalu'
AND game_id IN ('dice', 'rock');
DELETE FROM game_catalog
WHERE app_code = 'lalu'
AND game_id IN ('dice', 'rock');
DELETE p
FROM game_platforms p
WHERE p.app_code = 'lalu'
AND p.platform_code = 'dice'
AND NOT EXISTS (
SELECT 1
FROM game_catalog c
WHERE c.app_code = p.app_code
AND c.platform_code = p.platform_code
);

View File

@ -178,7 +178,7 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) {
response.ServerError(c, "获取机器人用户资料失败")
return
}
h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "dice", fmt.Sprintf("count=%d", len(userIDs)))
h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "universal", fmt.Sprintf("count=%d", len(userIDs)))
response.OK(c, gin.H{"created": len(userIDs), "items": items})
}

View File

@ -4,45 +4,5 @@ CREATE DATABASE IF NOT EXISTS hyapp_game DEFAULT CHARACTER SET utf8mb4 COLLATE u
USE hyapp_game;
-- 自研游戏数据单独维护schema 初始化只建表,默认数据在这里保证 dice 和 rock 在新环境中同时可用。
SET @now_ms := CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED);
SET @self_game_stakes := CAST('[{"StakeCoin":100,"Enabled":true,"SortOrder":10},{"StakeCoin":500,"Enabled":true,"SortOrder":20},{"StakeCoin":1000,"Enabled":true,"SortOrder":30},{"StakeCoin":8000,"Enabled":true,"SortOrder":40}]' AS JSON);
INSERT INTO game_platforms (
app_code, platform_code, platform_name, status, api_base_url, adapter_type,
callback_secret_ciphertext, callback_ip_whitelist, adapter_config,
sort_order, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'dice', 'Dice Internal', 'active', '', 'dice_internal',
'', CAST('[]' AS JSON), CAST('{}' AS JSON),
0, @now_ms, @now_ms
) ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms;
INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, safe_height, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES
('lalu', 'dice', 'dice', 'dice', 'Dice', 'self', '', '', 'h5_popup', 'portrait', 0, 0, 'active', 0, CAST('["self_game"]' AS JSON), @now_ms, @now_ms),
('lalu', 'rock', 'dice', 'rock', 'Rock Paper Scissors', 'self', '', '', 'h5_popup', 'portrait', 0, 0, 'active', 1, CAST('["self_game"]' AS JSON), @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms;
INSERT INTO game_display_rules (
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
) VALUES
('lalu', 'dice_voice_default', 'dice', 'voice_room', 0, '', '', 1, 1, 0, @now_ms, @now_ms),
('lalu', 'rock_voice_default', 'rock', 'voice_room', 0, '', '', 1, 1, 1, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms;
INSERT INTO game_self_game_configs (
app_code, game_id, status, stake_options_json, fee_bps, pool_bps,
min_players, max_players, robot_enabled, robot_match_wait_ms, created_at_ms, updated_at_ms
) VALUES
('lalu', 'dice', 'active', @self_game_stakes, 500, 100, 2, 2, 1, 1000, @now_ms, @now_ms),
('lalu', 'rock', 'active', @self_game_stakes, 500, 100, 2, 2, 1, 1000, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms;
INSERT INTO game_self_game_pools (app_code, game_id, balance_coin, created_at_ms, updated_at_ms)
VALUES
('lalu', 'dice', 0, @now_ms, @now_ms),
('lalu', 'rock', 0, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms;
-- 自研游戏不再随数据库初始化自动写入;后台需要开放某个自研游戏时,应通过后台配置显式创建。
SET @no_default_self_games := 1;

View File

@ -267,7 +267,9 @@ func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchI
return r.GetDiceMatch(ctx, app, match.MatchID)
}
// PickActiveDiceRobot 从当前游戏的可用机器人池里随机挑选一个未加入本局的机器人。
// PickActiveDiceRobot 从当前 App 的通用机器人池里随机挑选一个未加入本局的机器人。
// game_self_game_robots 仍保留 game_id 字段是为了兼容旧数据和后台接口,但机器人账号本身不再按 dice/rock 隔离;
// 只要同一个 app_code 下存在 active 登记,这个真实用户就可以给任意自研游戏补位。
func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string) (dicedomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
@ -275,13 +277,17 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga
var candidateCount int64
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM game_self_game_robots r
WHERE r.app_code = ? AND r.game_id = ? AND r.status = ?
AND NOT EXISTS (
SELECT 1 FROM game_dice_participants p
WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id
)`,
app, gameID, dicedomain.RobotStatusActive, matchID,
FROM (
SELECT r.user_id
FROM game_self_game_robots r
WHERE r.app_code = ? AND r.status = ?
AND NOT EXISTS (
SELECT 1 FROM game_dice_participants p
WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id
)
GROUP BY r.user_id
) candidates`,
app, dicedomain.RobotStatusActive, matchID,
).Scan(&candidateCount); err != nil {
return dicedomain.Robot{}, err
}
@ -294,16 +300,18 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga
}
var robot dicedomain.Robot
err = r.db.QueryRowContext(ctx,
`SELECT r.app_code, r.game_id, r.user_id, r.status, r.created_by_admin_id, r.created_at_ms, r.updated_at_ms
`SELECT r.app_code, ? AS game_id, r.user_id, ? AS status,
MIN(r.created_by_admin_id), MIN(r.created_at_ms), MAX(r.updated_at_ms)
FROM game_self_game_robots r
WHERE r.app_code = ? AND r.game_id = ? AND r.status = ?
WHERE r.app_code = ? AND r.status = ?
AND NOT EXISTS (
SELECT 1 FROM game_dice_participants p
WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id
)
ORDER BY r.updated_at_ms ASC, r.user_id ASC
GROUP BY r.app_code, r.user_id
ORDER BY MAX(r.updated_at_ms) ASC, r.user_id ASC
LIMIT 1 OFFSET ?`,
app, gameID, dicedomain.RobotStatusActive, matchID, randomOffset,
gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID, randomOffset,
).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@ -413,9 +421,9 @@ func (r *Repository) AdjustDicePool(ctx context.Context, adjustment dicedomain.P
if _, err := tx.ExecContext(ctx, `
INSERT INTO game_outbox (
app_code, event_id, event_type, order_id, user_id, platform_code, game_id,
op_type, coin_amount, payload_json, status, attempts, next_retry_at_ms,
created_at_ms, updated_at_ms, published_at_ms
) VALUES (?, ?, ?, '', ?, 'self', ?, '', 0, CAST(? AS JSON), 'pending', 0, 0, ?, ?, 0)
op_type, coin_amount, payload_json, status, worker_id, lock_until_ms,
retry_count, next_retry_at_ms, last_error, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, '', ?, 'self', ?, '', 0, CAST(? AS JSON), 'pending', '', 0, 0, 0, '', ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
adjustment.AppCode, gamemq.EventTypeSelfGamePoolAdjusted+":"+adjustment.AdjustmentID, gamemq.EventTypeSelfGamePoolAdjusted,
adjustment.UserID, adjustment.GameID, string(payload), adjustment.CreatedAtMS, adjustment.CreatedAtMS,
@ -439,19 +447,27 @@ func (r *Repository) ListDiceRobots(ctx context.Context, appCode string, gameID
if strings.TrimSpace(cursor) != "" {
_, _ = fmt.Sscan(strings.TrimSpace(cursor), &cursorID)
}
args := []any{app, gameID}
// 机器人后台展示的是“账号池”而不是“游戏池”;同一个 user_id 即使历史上登记过多个 game_id也只展示一行。
// 聚合状态按 active 优先,避免旧的 dice active + rock disabled 脏数据让同一个账号在列表里出现两种状态。
args := []any{gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app}
query := `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
FROM game_self_game_robots
WHERE app_code = ? AND game_id = ?`
if status != "" {
query += ` AND status = ?`
args = append(args, status)
}
FROM (
SELECT app_code, ? AS game_id, user_id,
IF(SUM(status = ?) > 0, ?, ?) AS status,
MIN(created_by_admin_id) AS created_by_admin_id,
MIN(created_at_ms) AS created_at_ms,
MAX(updated_at_ms) AS updated_at_ms
FROM game_self_game_robots
WHERE app_code = ?`
if cursorID > 0 {
query += ` AND user_id > ?`
args = append(args, cursorID)
}
query += ` ORDER BY user_id ASC LIMIT ?`
query += ` GROUP BY app_code, user_id
) robots
WHERE (? = '' OR status = ?)
ORDER BY user_id ASC LIMIT ?`
args = append(args, status, status)
args = append(args, int(pageSize)+1)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
@ -581,7 +597,8 @@ func (r *Repository) ListExploreWinners(ctx context.Context, appCode string, pag
func (r *Repository) RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
// 新生成的机器人只写入默认 game_id 行;匹配和后台管理都已经按 app_code + user_id 聚合,这行只是兼容当前表主键。
gameID = dicedomain.DefaultGameID
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
@ -601,7 +618,7 @@ func (r *Repository) RegisterDiceRobots(ctx context.Context, appCode string, gam
if err := tx.Commit(); err != nil {
return nil, err
}
robots, _, err := r.ListDiceRobots(ctx, app, gameID, "", 200, "")
robots, _, err := r.ListDiceRobots(ctx, app, "", "", 200, "")
return robots, err
}
@ -615,8 +632,8 @@ func (r *Repository) SetDiceRobotStatus(ctx context.Context, appCode string, gam
res, err := r.db.ExecContext(ctx,
`UPDATE game_self_game_robots
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND game_id = ? AND user_id = ?`,
status, nowMs, app, gameID, userID,
WHERE app_code = ? AND user_id = ?`,
status, nowMs, app, userID,
)
if err != nil {
return dicedomain.Robot{}, err
@ -625,24 +642,17 @@ func (r *Repository) SetDiceRobotStatus(ctx context.Context, appCode string, gam
if affected == 0 {
return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
}
var robot dicedomain.Robot
err = r.db.QueryRowContext(ctx,
`SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
FROM game_self_game_robots
WHERE app_code = ? AND game_id = ? AND user_id = ?`,
app, gameID, userID,
).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS)
return robot, err
return r.getUniversalDiceRobot(ctx, app, gameID, userID)
}
// DeleteDiceRobot 只删除机器人登记关系,不删除 user-service 主账号,避免历史对局和钱包流水失去用户归属。
// 删除按 user_id 清理所有历史 game_id 行,保证后台“不区分 dice/rock”的操作不会留下另一个游戏的残余登记。
func (r *Repository) DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
res, err := r.db.ExecContext(ctx,
`DELETE FROM game_self_game_robots
WHERE app_code = ? AND game_id = ? AND user_id = ?`,
app, gameID, userID,
WHERE app_code = ? AND user_id = ?`,
app, userID,
)
if err != nil {
return err
@ -654,6 +664,28 @@ func (r *Repository) DeleteDiceRobot(ctx context.Context, appCode string, gameID
return nil
}
func (r *Repository) getUniversalDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) (dicedomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
var robot dicedomain.Robot
err := r.db.QueryRowContext(ctx,
`SELECT app_code, ? AS game_id, user_id,
IF(SUM(status = ?) > 0, ?, ?) AS status,
MIN(created_by_admin_id), MIN(created_at_ms), MAX(updated_at_ms)
FROM game_self_game_robots
WHERE app_code = ? AND user_id = ?
GROUP BY app_code, user_id`,
gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app, userID,
).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
}
return dicedomain.Robot{}, err
}
return robot, nil
}
func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, userID int64, participantType string, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
var seatNo int32
if err := tx.QueryRowContext(ctx,

View File

@ -216,6 +216,133 @@ func TestPickActiveDiceRobotSelectsRandomCandidateInsteadOfFixedFirstRow(t *test
if len(picked) < 2 {
t.Fatalf("robot picking still behaves like fixed first-row selection, got picked user ids: %+v", picked)
}
rockRobot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.SelfGameIDRock, "match_random_robot_rock")
if err != nil {
t.Fatalf("rock should reuse the app-wide robot pool: %v", err)
}
if rockRobot.GameID != dicedomain.SelfGameIDRock || !containsInt64(userIDs, rockRobot.UserID) {
t.Fatalf("rock robot should come from the shared app robot pool, got %+v", rockRobot)
}
}
func TestUniversalDiceRobotManagementIgnoresGameID(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
nowMS := int64(1780542672000)
rows := []struct {
gameID string
userID int64
status string
}{
{gameID: dicedomain.DefaultGameID, userID: 201, status: dicedomain.RobotStatusActive},
{gameID: dicedomain.SelfGameIDRock, userID: 201, status: dicedomain.RobotStatusDisabled},
{gameID: dicedomain.SelfGameIDRock, userID: 202, status: dicedomain.RobotStatusDisabled},
}
for index, row := range rows {
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO game_self_game_robots (
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 7, ?, ?)`,
"lalu", row.gameID, row.userID, row.status, nowMS+int64(index), nowMS+int64(index),
); err != nil {
t.Fatalf("insert robot row %+v failed: %v", row, err)
}
}
active, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusActive, 20, "")
if err != nil {
t.Fatalf("list active robots failed: %v", err)
}
if len(active) != 1 || active[0].UserID != 201 || active[0].Status != dicedomain.RobotStatusActive {
t.Fatalf("active list should aggregate user 201 once, got %+v", active)
}
disabled, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusDisabled, 20, "")
if err != nil {
t.Fatalf("list disabled robots failed: %v", err)
}
if len(disabled) != 1 || disabled[0].UserID != 202 || disabled[0].Status != dicedomain.RobotStatusDisabled {
t.Fatalf("disabled list should only include fully disabled users, got %+v", disabled)
}
updated, err := repo.SetDiceRobotStatus(ctx, "lalu", dicedomain.SelfGameIDRock, 201, dicedomain.RobotStatusDisabled, nowMS+100)
if err != nil {
t.Fatalf("set universal robot status failed: %v", err)
}
if updated.GameID != dicedomain.SelfGameIDRock || updated.Status != dicedomain.RobotStatusDisabled {
t.Fatalf("status response should keep requested game context while updating shared rows, got %+v", updated)
}
var activeRows int
if err := repo.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ? AND status = ?`,
"lalu", int64(201), dicedomain.RobotStatusActive,
).Scan(&activeRows); err != nil {
t.Fatalf("query active rows failed: %v", err)
}
if activeRows != 0 {
t.Fatalf("status update should affect all game_id rows for user 201, active rows=%d", activeRows)
}
if err := repo.DeleteDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, 201); err != nil {
t.Fatalf("delete universal robot failed: %v", err)
}
var remainingRows int
if err := repo.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`,
"lalu", int64(201),
).Scan(&remainingRows); err != nil {
t.Fatalf("query remaining rows failed: %v", err)
}
if remainingRows != 0 {
t.Fatalf("delete should remove every game_id row for user 201, rows=%d", remainingRows)
}
registered, err := repo.RegisterDiceRobots(ctx, "lalu", dicedomain.SelfGameIDRock, []int64{203}, 7, nowMS+200)
if err != nil {
t.Fatalf("register universal robot failed: %v", err)
}
if !robotListContains(registered, 203, dicedomain.RobotStatusActive) {
t.Fatalf("registered robot should be returned from shared list, got %+v", registered)
}
var registeredGameID string
if err := repo.db.QueryRowContext(ctx,
`SELECT game_id FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`,
"lalu", int64(203),
).Scan(&registeredGameID); err != nil {
t.Fatalf("query registered robot failed: %v", err)
}
if registeredGameID != dicedomain.DefaultGameID {
t.Fatalf("new universal robots should use default compatibility game_id, got %s", registeredGameID)
}
}
func containsInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func robotListContains(robots []dicedomain.Robot, userID int64, status string) bool {
for _, robot := range robots {
if robot.UserID == userID && robot.Status == status {
return true
}
}
return false
}
func TestUpsertCatalogCreatesDefaultVoiceRoomDisplayRule(t *testing.T) {