package mysql import ( "context" "fmt" "strings" "testing" "time" "hyapp/internal/testutil/mysqlschema" "hyapp/pkg/appcode" "hyapp/pkg/xerr" dicedomain "hyapp/services/game-service/internal/domain/dice" gamedomain "hyapp/services/game-service/internal/domain/game" gameservice "hyapp/services/game-service/internal/service/game" ) func TestDiceConfigPersistsRobotUserWinRatePercent(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() }) config := dicedomain.Config{ AppCode: "lalu", GameID: dicedomain.DefaultGameID, Status: dicedomain.ConfigStatusActive, StakeOptions: dicedomain.NormalizeStakeOptions(nil), FeeBPS: int32(dicedomain.DefaultFeeBPS), PoolBPS: int32(dicedomain.DefaultPoolBPS), MinPlayers: dicedomain.DefaultMinPlayers, MaxPlayers: dicedomain.DefaultMaxPlayers, RobotEnabled: true, RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS, RobotUserWinRatePercent: 70, RobotUserWinRatePercentSet: true, } saved, err := repo.UpsertDiceConfig(ctx, config, 1700000000000) if err != nil { t.Fatalf("upsert dice config failed: %v", err) } if saved.RobotUserWinRatePercent != 70 { t.Fatalf("saved robot win rate = %d, want 70", saved.RobotUserWinRatePercent) } fetched, err := repo.GetDiceConfig(ctx, "lalu", dicedomain.DefaultGameID) if err != nil { t.Fatalf("get dice config failed: %v", err) } if fetched.RobotUserWinRatePercent != 70 { t.Fatalf("fetched robot win rate = %d, want 70", fetched.RobotUserWinRatePercent) } items, err := repo.ListSelfGameConfigs(ctx, "lalu") if err != nil { t.Fatalf("list self game configs failed: %v", err) } if len(items) != 1 || items[0].RobotUserWinRatePercent != 70 { t.Fatalf("listed robot win rate mismatch: %+v", items) } config.RobotUserWinRatePercent = 0 saved, err = repo.UpsertDiceConfig(ctx, config, 1700000001000) if err != nil { t.Fatalf("upsert zero robot win rate failed: %v", err) } if saved.RobotUserWinRatePercent != 0 { t.Fatalf("explicit zero robot win rate should be persisted, got %d", saved.RobotUserWinRatePercent) } } func TestAppGameListsExcludeBuiltInSelfGames(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(1781200000000) for _, platform := range []gamedomain.Platform{ {AppCode: "lalu", PlatformCode: "dice", PlatformName: "Dice Internal", Status: gamedomain.StatusActive, AdapterType: "dice_internal", CreatedAtMS: nowMS, UpdatedAtMS: nowMS}, {AppCode: "lalu", PlatformCode: "demo", PlatformName: "Demo", Status: gamedomain.StatusActive, AdapterType: gamedomain.AdapterDemo, CreatedAtMS: nowMS, UpdatedAtMS: nowMS}, } { if _, err := repo.UpsertPlatform(ctx, platform); err != nil { t.Fatalf("upsert platform %s failed: %v", platform.PlatformCode, err) } } for _, item := range []gamedomain.CatalogItem{ {AppCode: "lalu", GameID: dicedomain.DefaultGameID, PlatformCode: "dice", ProviderGameID: "dice", GameName: "Dice", Category: "self", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 1}, {AppCode: "lalu", GameID: dicedomain.SelfGameIDRock, PlatformCode: "dice", ProviderGameID: "rock", GameName: "Rock Paper Scissors", Category: "self", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 2}, {AppCode: "lalu", GameID: "demo_slot_001", PlatformCode: "demo", ProviderGameID: "slot_001", GameName: "Demo Slot", Category: "slot", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 3}, } { if _, err := repo.UpsertCatalog(ctx, item); err != nil { t.Fatalf("upsert catalog %s failed: %v", item.GameID, err) } } for _, session := range []gamedomain.LaunchSession{ {AppCode: "lalu", SessionID: "sess_dice", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "dice", GameID: dicedomain.DefaultGameID, ProviderGameID: "dice", LaunchTokenHash: "hash_dice", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 10, UpdatedAtMS: nowMS + 10}, {AppCode: "lalu", SessionID: "sess_rock", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "dice", GameID: dicedomain.SelfGameIDRock, ProviderGameID: "rock", LaunchTokenHash: "hash_rock", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 20, UpdatedAtMS: nowMS + 20}, {AppCode: "lalu", SessionID: "sess_demo", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "demo", GameID: "demo_slot_001", ProviderGameID: "slot_001", LaunchTokenHash: "hash_demo", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 30, UpdatedAtMS: nowMS + 30}, } { if err := repo.CreateLaunchSession(ctx, session); err != nil { t.Fatalf("create launch session %s failed: %v", session.SessionID, err) } } query := gameservice.ListGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom} games, err := repo.ListGames(ctx, query) if err != nil { t.Fatalf("list games failed: %v", err) } if len(games) != 1 || games[0].GameID != "demo_slot_001" { t.Fatalf("app game list should hide built-in self games, got %+v", games) } recent, err := repo.ListRecentGames(ctx, gameservice.ListRecentGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PageSize: 10}) if err != nil { t.Fatalf("list recent games failed: %v", err) } if len(recent) != 1 || recent[0].GameID != "demo_slot_001" { t.Fatalf("recent game list should hide built-in self games, got %+v", recent) } if _, err := repo.GetLaunchableGame(ctx, "lalu", dicedomain.DefaultGameID); err != nil { t.Fatalf("dice catalog must remain launchable for dedicated endpoints: %v", err) } if _, err := repo.GetLaunchableGame(ctx, "lalu", dicedomain.SelfGameIDRock); err != nil { t.Fatalf("rock catalog must remain launchable for dedicated endpoints: %v", err) } } func TestMarkOrderSucceededCreatesClaimableLevelOutboxEvent(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() }) order := gamedomain.GameOrder{ AppCode: "lalu", OrderID: "order_1", PlatformCode: "demo", ProviderOrderID: "provider_order_1", GameID: "demo_rocket_001", ProviderGameID: "rocket_001", UserID: 42, RoomID: "room_1", OpType: "debit", CoinAmount: 120, Status: gamedomain.OrderStatusWalletApplying, RequestHash: "hash_1", CreatedAtMS: 1700000000000, UpdatedAtMS: 1700000000000, } if _, _, err := repo.CreateGameOrder(ctx, order); err != nil { t.Fatalf("create order failed: %v", err) } if err := repo.MarkOrderSucceeded(ctx, "lalu", "order_1", "wtx-game-1", 880, 1700000001000); err != nil { t.Fatalf("mark order succeeded failed: %v", err) } events, err := repo.ClaimPendingLevelEvents(ctx, "worker_1", 1700000002000, 30*time.Second, 10) if err != nil { t.Fatalf("claim level events failed: %v", err) } if len(events) != 1 { t.Fatalf("expected one level event, got %+v", events) } event := events[0] if event.EventID != "game_level:order_1" || event.UserID != 42 || event.CoinAmount != 120 { t.Fatalf("level event mismatch: %+v", event) } if event.PayloadJSON == "" || event.WalletTransactionID != "wtx-game-1" { t.Fatalf("level event payload mismatch: %+v", event) } } func TestClaimPendingLevelEventsPreservesCreatedOrderAcrossStatuses(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() }) insertLevelEvent := func(eventID string, orderID string, status string, nextRetryAtMS int64, createdAtMS int64, payload string) { t.Helper() if _, err := repo.db.ExecContext(ctx, ` INSERT INTO game_level_event_outbox ( app_code, event_id, order_id, user_id, game_id, provider_order_id, provider_round_id, coin_amount, wallet_transaction_id, payload_json, status, attempt_count, next_retry_at_ms, locked_by, locked_until_ms, failure_reason, occurred_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 42, 'game_1', ?, '', 100, 'wallet_tx_1', ?, ?, 0, ?, '', 0, '', ?, ?, ?)`, "lalu", eventID, orderID, "provider_"+orderID, payload, status, nextRetryAtMS, createdAtMS, createdAtMS, createdAtMS, ); err != nil { t.Fatalf("insert level event %s failed: %v", eventID, err) } } insertLevelEvent("event_failed_old", "order_failed_old", "failed", 1000, 1000, `{"source":"failed"}`) insertLevelEvent("event_failed_future", "order_failed_future", "failed", 999999, 1500, `{"source":"future"}`) insertLevelEvent("event_pending_new", "order_pending_new", "pending", 0, 2000, `{"source":"pending"}`) events, err := repo.ClaimPendingLevelEvents(ctx, "worker_1", 3000, 30*time.Second, 2) if err != nil { t.Fatalf("claim level events failed: %v", err) } if len(events) != 2 { t.Fatalf("expected two claimed events, got %+v", events) } if events[0].EventID != "event_failed_old" || events[0].Status != "failed" || !strings.Contains(events[0].PayloadJSON, `"source": "failed"`) { t.Fatalf("first claimed event should be the older failed payload, got %+v", events[0]) } if events[1].EventID != "event_pending_new" || events[1].Status != "pending" || !strings.Contains(events[1].PayloadJSON, `"source": "pending"`) { t.Fatalf("second claimed event should be the pending payload, got %+v", events[1]) } var runningCount int if err := repo.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM game_level_event_outbox WHERE app_code = 'lalu' AND status = 'running' AND locked_by = 'worker_1' AND locked_until_ms = 33000`, ).Scan(&runningCount); err != nil { t.Fatalf("query running events failed: %v", err) } if runningCount != 2 { t.Fatalf("expected two running claimed events, got %d", runningCount) } var futureStatus string if err := repo.db.QueryRowContext(ctx, ` SELECT status FROM game_level_event_outbox WHERE app_code = 'lalu' AND event_id = 'event_failed_future'`, ).Scan(&futureStatus); err != nil { t.Fatalf("query future retry event failed: %v", err) } if futureStatus != "failed" { t.Fatalf("future retry event should stay failed, got %s", futureStatus) } } func TestLevelEventClaimKeyQueryUsesOrderIndexWithoutFilesort(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() }) var plan string if err := repo.db.QueryRowContext(ctx, ` EXPLAIN FORMAT=JSON SELECT event_id, created_at_ms FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim_order) WHERE app_code = ? AND status = ? AND locked_until_ms = 0 AND next_retry_at_ms <= ? ORDER BY created_at_ms ASC, event_id ASC LIMIT 100`, "lalu", "pending", int64(1780542672724), ).Scan(&plan); err != nil { t.Fatalf("explain level event claim key query failed: %v", err) } if !strings.Contains(plan, `"key": "idx_game_level_event_claim_order"`) { t.Fatalf("claim key query did not use idx_game_level_event_claim_order:\n%s", plan) } if strings.Contains(strings.ToLower(plan), `"using_filesort": true`) { t.Fatalf("claim key query should avoid filesort:\n%s", plan) } } func TestPickActiveDiceRobotRandomlySamplesWholeActivePool(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) userIDs := []int64{101, 102} for index, userID := range userIDs { usedCount := int64(0) if userID == 101 { usedCount = 999 } 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, last_used_at_ms, used_count ) VALUES (?, ?, ?, ?, 7, ?, ?, 0, ?)`, "lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index), usedCount, ); err != nil { t.Fatalf("insert robot %d failed: %v", userID, err) } } picked := map[int64]bool{} for attempt := 0; attempt < 48; attempt++ { robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_random_%d", attempt), nowMS+100+int64(attempt)) if err != nil { t.Fatalf("pick active dice robot failed: %v", err) } if !containsInt64(userIDs, robot.UserID) { t.Fatalf("random pick %d returned unknown robot: %+v", attempt, robot) } picked[robot.UserID] = true if robot.LastUsedAtMS != nowMS+100+int64(attempt) { t.Fatalf("picked robot should return updated usage time, got %+v", robot) } } if len(picked) != len(userIDs) { t.Fatalf("full random must keep high used_count robots in the candidate pool, got picked=%+v", picked) } if _, err := repo.db.ExecContext(ctx, `INSERT INTO game_dice_participants ( app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, joined_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 2, ?, 100, ?, ?)`, "lalu", "match_random_robot", int64(101), dicedomain.ParticipantTypeRobot, dicedomain.ParticipantStatusJoined, nowMS, nowMS, ); err != nil { t.Fatalf("insert existing robot participant failed: %v", err) } excluded, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_random_robot", nowMS+300) if err != nil { t.Fatalf("pick active dice robot with existing participant failed: %v", err) } if excluded.UserID == 101 { t.Fatalf("picked robot already in this match: %+v", excluded) } rockRobot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.SelfGameIDRock, "match_random_robot_rock", nowMS+400) 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(®isteredGameID); 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 TestHasSelfGameUserWinOnlyCountsSettledHumanWinsInSameGame(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 { matchID string gameID string status string userID int64 participantType string result string }{ {matchID: "settled_human_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusSettled, userID: 301, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin}, {matchID: "rock_human_win", gameID: dicedomain.SelfGameIDRock, status: dicedomain.MatchStatusSettled, userID: 302, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin}, {matchID: "robot_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusSettled, userID: 303, participantType: dicedomain.ParticipantTypeRobot, result: dicedomain.ParticipantResultWin}, {matchID: "pending_human_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusPayoutApplying, userID: 304, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin}, } for index, row := range rows { if _, err := repo.db.ExecContext(ctx, `INSERT INTO game_dice_matches ( app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, min_players, max_players, current_players, stake_coin, round_no, status, result, join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms, fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin ) VALUES (?, ?, ?, 'dice', ?, '', 0, 2, 2, 2, 100, 1, ?, ?, 0, ?, ?, ?, ?, 0, 500, 100, 'robot', '', 0)`, "lalu", row.matchID, row.gameID, row.gameID, row.status, row.result, nowMS+int64(index), nowMS+int64(index), nowMS+int64(index), nowMS+int64(index), ); err != nil { t.Fatalf("insert match %+v failed: %v", row, err) } if _, err := repo.db.ExecContext(ctx, `INSERT INTO game_dice_participants ( app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, rps_gesture, result, payout_coin, joined_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 1, ?, 100, CAST('[6]' AS JSON), '', ?, 188, ?, ?)`, "lalu", row.matchID, row.userID, row.participantType, dicedomain.ParticipantStatusSettled, row.result, nowMS+int64(index), nowMS+int64(index), ); err != nil { t.Fatalf("insert participant %+v failed: %v", row, err) } } hasWin, err := repo.HasSelfGameUserWin(ctx, "lalu", dicedomain.DefaultGameID, 301) if err != nil { t.Fatalf("query settled human win failed: %v", err) } if !hasWin { t.Fatalf("settled human win in same game should count") } for _, tt := range []struct { name string gameID string userID int64 }{ {name: "other game", gameID: dicedomain.DefaultGameID, userID: 302}, {name: "robot win", gameID: dicedomain.DefaultGameID, userID: 303}, {name: "unfinished win", gameID: dicedomain.DefaultGameID, userID: 304}, {name: "missing user", gameID: dicedomain.DefaultGameID, userID: 305}, } { t.Run(tt.name, func(t *testing.T) { hasWin, err := repo.HasSelfGameUserWin(ctx, "lalu", tt.gameID, tt.userID) if err != nil { t.Fatalf("query win failed: %v", err) } if hasWin { t.Fatalf("%s should not count as existing win", tt.name) } }) } } 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) { 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() }) item := gamedomain.CatalogItem{ AppCode: "lalu", GameID: "sync_game_001", PlatformCode: "demo", ProviderGameID: "provider_sync_001", GameName: "Sync Game", Category: "casino", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 70, Tags: []string{"sync"}, } if _, err := repo.UpsertCatalog(ctx, item); err != nil { t.Fatalf("upsert catalog failed: %v", err) } var scene string var visible, enabled bool var sortOrder int32 if err := repo.db.QueryRowContext(ctx, `SELECT scene, visible, enabled, sort_order FROM game_display_rules WHERE app_code = ? AND rule_id = ? AND game_id = ?`, "lalu", defaultVoiceRoomRuleID(item.GameID), item.GameID, ).Scan(&scene, &visible, &enabled, &sortOrder); err != nil { t.Fatalf("query display rule failed: %v", err) } if scene != gamedomain.SceneVoiceRoom || !visible || !enabled || sortOrder != item.SortOrder { t.Fatalf("display rule mismatch: scene=%s visible=%v enabled=%v sort=%d", scene, visible, enabled, sortOrder) } } func TestDeleteCatalogRemovesGameAndDisplayRule(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() }) item := gamedomain.CatalogItem{ AppCode: "lalu", GameID: "delete_game_001", PlatformCode: "demo", ProviderGameID: "provider_delete_001", GameName: "Delete Game", Category: "casino", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 80, } if _, err := repo.UpsertCatalog(ctx, item); err != nil { t.Fatalf("upsert catalog failed: %v", err) } if err := repo.DeleteCatalog(ctx, "lalu", item.GameID); err != nil { t.Fatalf("delete catalog failed: %v", err) } var gameCount int if err := repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM game_catalog WHERE app_code = ? AND game_id = ?`, "lalu", item.GameID, ).Scan(&gameCount); err != nil { t.Fatalf("query catalog count failed: %v", err) } if gameCount != 0 { t.Fatalf("game_catalog count = %d, want 0", gameCount) } var ruleCount int if err := repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM game_display_rules WHERE app_code = ? AND game_id = ?`, "lalu", item.GameID, ).Scan(&ruleCount); err != nil { t.Fatalf("query display rule count failed: %v", err) } if ruleCount != 0 { t.Fatalf("game_display_rules count = %d, want 0", ruleCount) } } func TestDeleteCatalogRejectsBuiltInSelfGames(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() }) for _, gameID := range []string{dicedomain.DefaultGameID, dicedomain.SelfGameIDRock} { err := repo.DeleteCatalog(ctx, "lalu", gameID) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("delete built-in self game %s should be rejected, got %v", gameID, err) } } }