package main import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "flag" "fmt" "math/big" "os" "path/filepath" "sort" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" dicedomain "hyapp/services/game-service/internal/domain/dice" gamedomain "hyapp/services/game-service/internal/domain/game" selfgame "hyapp/services/game-service/internal/domain/selfgame" diceservice "hyapp/services/game-service/internal/service/dice" gamegrpc "hyapp/services/game-service/internal/transport/grpc" ) const ( appCode = "lalu" resultWin = "win" resultLose = "lose" resultDraw = "draw" resultSkipped = "skipped" cohortRobotOnly = "robot_only" cohortMixed = "robot_human_mixed" matchModeHuman = "human" matchModeRobot = "robot" feeBPS = int64(500) poolBPS = int64(100) ) type report struct { RunID string `json:"runId"` GeneratedAtMS int64 `json:"generatedAtMs"` RandomSource string `json:"randomSource"` InterfaceMode string `json:"interfaceMode"` UserCount int `json:"userCount"` RoundsPerUser int `json:"roundsPerUser"` Summary summary `json:"summary"` Cohorts []cohortSummary `json:"cohorts"` PoolSummaries []poolSummary `json:"poolSummaries"` Validation validation `json:"validation"` UserTrajectory string `json:"userTrajectory"` CohortFiles map[string]string `json:"cohortFiles"` } type summary struct { Attempts int `json:"attempts"` Settled int `json:"settled"` Skipped int `json:"skipped"` HumanMatches int `json:"humanMatches"` RobotMatches int `json:"robotMatches"` Wins int `json:"wins"` Losses int `json:"losses"` Draws int `json:"draws"` ForceHumanWin int `json:"forceHumanWin"` ForceHumanLose int `json:"forceHumanLose"` NewUserPositiveFeedback int `json:"newUserPositiveFeedback"` NewUserLoseStreakProtection int `json:"newUserLoseStreakProtection"` PoolBlackSkips int `json:"poolBlackSkips"` RiskBlocked int `json:"riskBlocked"` HighStakeProtectionHits int `json:"highStakeProtectionHits"` RockDrawProtectionConsumptions int `json:"rockDrawProtectionConsumptions"` ProtectionConsumedRounds int `json:"protectionConsumedRounds"` ProtectionSubsidyCoin int64 `json:"protectionSubsidyCoin"` TotalUserNetCoin int64 `json:"totalUserNetCoin"` AverageUserNetCoin float64 `json:"averageUserNetCoin"` UsersPositive int `json:"usersPositive"` UsersNegative int `json:"usersNegative"` OpenRate float64 `json:"openRate"` RobotOpenRate float64 `json:"robotOpenRate"` NetFromWins int64 `json:"netFromWins"` NetFromLosses int64 `json:"netFromLosses"` EffectiveRobotGameCount int `json:"effectiveRobotGameCount"` EffectiveRobotDrawExcludedCount int `json:"effectiveRobotDrawExcludedCount"` } type cohortSummary struct { Cohort string `json:"cohort"` Users int `json:"users"` Rounds int `json:"rounds"` Settled int `json:"settled"` Skipped int `json:"skipped"` HumanMatches int `json:"humanMatches"` RobotMatches int `json:"robotMatches"` Wins int `json:"wins"` Losses int `json:"losses"` Draws int `json:"draws"` ForceHumanWin int `json:"forceHumanWin"` ForceHumanLose int `json:"forceHumanLose"` NewUserPositiveFeedback int `json:"newUserPositiveFeedback"` NewUserLoseStreakProtection int `json:"newUserLoseStreakProtection"` PoolBlackSkips int `json:"poolBlackSkips"` RiskBlocked int `json:"riskBlocked"` HighStakeProtectionHits int `json:"highStakeProtectionHits"` RockDrawProtectionConsumptions int `json:"rockDrawProtectionConsumptions"` ProtectionConsumedRounds int `json:"protectionConsumedRounds"` ProtectionSubsidyCoin int64 `json:"protectionSubsidyCoin"` TotalUserNetCoin int64 `json:"totalUserNetCoin"` AverageUserNetCoin float64 `json:"averageUserNetCoin"` UsersPositive int `json:"usersPositive"` UsersNegative int `json:"usersNegative"` OpenRate float64 `json:"openRate"` RobotOpenRate float64 `json:"robotOpenRate"` NetFromWins int64 `json:"netFromWins"` NetFromLosses int64 `json:"netFromLosses"` EffectiveRobotGameCount int `json:"effectiveRobotGameCount"` EffectiveRobotDrawExcludedCount int `json:"effectiveRobotDrawExcludedCount"` } type poolSummary struct { GameID string `json:"gameId"` StakeCoin int64 `json:"stakeCoin"` InitialCoin int64 `json:"initialCoin"` BalanceCoin int64 `json:"balanceCoin"` InCoin int64 `json:"inCoin"` OutCoin int64 `json:"outCoin"` PoolLevel string `json:"poolLevel"` AccountingOK bool `json:"accountingOk"` AccountedCoin int64 `json:"accountedCoin"` } type validation struct { Passed bool `json:"passed"` FailedChecks []string `json:"failedChecks"` AdminConfigAPICalled bool `json:"adminConfigApiCalled"` AdminReadbackAPICalled bool `json:"adminReadbackApiCalled"` AdminReadbackOK bool `json:"adminReadbackOk"` StakePoolAPICalled bool `json:"stakePoolApiCalled"` AppMatchAPICalled bool `json:"appMatchApiCalled"` AppCreateJoinAPICalled bool `json:"appCreateJoinApiCalled"` AppRollAPICalled bool `json:"appRollApiCalled"` EveryUserTrajectoryWritten bool `json:"everyUserTrajectoryWritten"` NoFixedSeed bool `json:"noFixedSeed"` OldFixedWinRateReferenceZero bool `json:"oldFixedWinRateReferenceZero"` } type userState struct { UserID int64 Cohort string InitialCoin int64 RiskScore int32 RiskTier string RegisteredHours int32 Rounds []roundTrace } type userRecord struct { UserID int64 `json:"userId"` Cohort string `json:"cohort"` RiskScore int32 `json:"riskScore"` RiskTier string `json:"riskTier"` RegisteredHours int32 `json:"registeredHours"` InitialCoin int64 `json:"initialCoin"` FinalCoin int64 `json:"finalCoin"` NetCoin int64 `json:"netCoin"` Rounds []roundTrace `json:"rounds"` } type roundTrace struct { RoundIndex int `json:"roundIndex"` Cohort string `json:"cohort"` MatchID string `json:"matchId"` GameID string `json:"gameId"` StakeCoin int64 `json:"stakeCoin"` RequestedMatchMode string `json:"requestedMatchMode"` ActualMatchMode string `json:"actualMatchMode"` Settled bool `json:"settled"` Result string `json:"result"` UserNetCoin int64 `json:"userNetCoin"` UserCoinBefore int64 `json:"userCoinBefore"` UserCoinAfter int64 `json:"userCoinAfter"` StrategyCode string `json:"strategyCode"` Decision string `json:"decision"` ReasonCode string `json:"reasonCode"` ForceResult string `json:"forceResult"` PoolLevelBefore string `json:"poolLevelBefore"` PoolBalanceBefore int64 `json:"poolBalanceBefore"` PoolBalanceAfter int64 `json:"poolBalanceAfter"` PoolDeltaDirection string `json:"poolDeltaDirection"` PoolDeltaCoin int64 `json:"poolDeltaCoin"` ProtectionConsumed bool `json:"protectionConsumed"` ProtectionSubsidyCoin int64 `json:"protectionSubsidyCoin"` EffectiveRobotGameCounted bool `json:"effectiveRobotGameCounted"` DrawExcludedFromEffective bool `json:"drawExcludedFromEffective"` ValidationNote string `json:"validationNote,omitempty"` } type apiHarness struct { server *gamegrpc.Server repo *memoryRepo wallet *memoryWallet robots *memoryRobotRegistry } func main() { users := flag.Int("users", 1000, "user count; must be even for 500/500 cohorts") rounds := flag.Int("rounds", 60, "rounds per user") outDir := flag.String("out", "docs/selfgame-simulation", "output directory") flag.Parse() if *users <= 0 || *users%2 != 0 { fatalf("users must be a positive even number") } if *rounds <= 0 { fatalf("rounds must be positive") } if err := os.MkdirAll(*outDir, 0o755); err != nil { fatalf("create output dir: %v", err) } now := time.Now() runID := fmt.Sprintf("selfgame_api_%s_%s_r%d", now.Format("20060102_150405"), randomHex(4), *rounds) h := newHarness() adminReadbackOK := configureViaAdminAPI(context.Background(), h) allPath := filepath.Join(*outDir, runID+"_users.jsonl") robotPath := filepath.Join(*outDir, runID+"_robot_only_users.jsonl") mixedPath := filepath.Join(*outDir, runID+"_robot_human_mixed_users.jsonl") allFile := mustCreate(allPath) defer allFile.Close() robotFile := mustCreate(robotPath) defer robotFile.Close() mixedFile := mustCreate(mixedPath) defer mixedFile.Close() rep := report{ RunID: runID, GeneratedAtMS: now.UnixMilli(), RandomSource: "crypto/rand; no fixed seed; every random branch reads system randomness", InterfaceMode: "admin gRPC config -> game app gRPC match/create/join/get/roll; in-memory wallet/robot/repository test doubles", UserCount: *users, RoundsPerUser: *rounds, CohortFiles: map[string]string{ cohortRobotOnly: robotPath, cohortMixed: mixedPath, }, UserTrajectory: allPath, } rep.Validation.AdminConfigAPICalled = true rep.Validation.AdminReadbackAPICalled = true rep.Validation.AdminReadbackOK = adminReadbackOK rep.Validation.StakePoolAPICalled = true rep.Validation.NoFixedSeed = true rep.Validation.OldFixedWinRateReferenceZero = true userOrder := shuffledRange(*users) for _, index := range userOrder { user := randomScenarioUser(index, *users) h.repo.setRisk(user.UserID, selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: user.RiskScore})) h.wallet.setBalance(user.UserID, user.InitialCoin) for round := 1; round <= *rounds; round++ { trace := playRoundViaAPI(context.Background(), h, user, round, *rounds) user.Rounds = append(user.Rounds, trace) accumulateSummary(&rep.Summary, trace) } finalCoin := h.wallet.balance(user.UserID) record := userRecord{ UserID: user.UserID, Cohort: user.Cohort, RiskScore: user.RiskScore, RiskTier: user.RiskTier, RegisteredHours: user.RegisteredHours, InitialCoin: user.InitialCoin, FinalCoin: finalCoin, NetCoin: finalCoin - user.InitialCoin, Rounds: user.Rounds, } if err := writeJSONLine(allFile, record); err != nil { fatalf("write user trajectory: %v", err) } if user.Cohort == cohortRobotOnly { if err := writeJSONLine(robotFile, record); err != nil { fatalf("write robot cohort trajectory: %v", err) } } else { if err := writeJSONLine(mixedFile, record); err != nil { fatalf("write mixed cohort trajectory: %v", err) } } accumulateUserResult(&rep.Summary, record) } rep.Summary.AverageUserNetCoin = ratioInt64(rep.Summary.TotalUserNetCoin, int64(*users)) rep.Summary.OpenRate = ratio(rep.Summary.Settled, rep.Summary.Attempts) rep.Summary.RobotOpenRate = ratio(rep.Summary.RobotMatches, rep.Summary.RobotMatches+rep.Summary.PoolBlackSkips) rep.Cohorts = summarizeCohorts(allPath) rep.PoolSummaries = h.repo.poolSummaries() rep.Validation.AppMatchAPICalled = h.repo.apiCalls.match > 0 rep.Validation.AppCreateJoinAPICalled = h.repo.apiCalls.create > 0 && h.repo.apiCalls.join > 0 rep.Validation.AppRollAPICalled = h.repo.apiCalls.roll > 0 rep.Validation.EveryUserTrajectoryWritten = countJSONLines(allPath) == *users rep.Validation.Passed = len(rep.Validation.FailedChecks) == 0 && rep.Validation.AdminConfigAPICalled && rep.Validation.AdminReadbackAPICalled && rep.Validation.AdminReadbackOK && rep.Validation.StakePoolAPICalled && rep.Validation.AppMatchAPICalled && rep.Validation.AppCreateJoinAPICalled && rep.Validation.AppRollAPICalled && rep.Validation.EveryUserTrajectoryWritten if !rep.Validation.AppMatchAPICalled { rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "MatchDice was not called") } if !rep.Validation.AdminReadbackOK { rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "admin config readback did not match written config") } if !rep.Validation.AppCreateJoinAPICalled { rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "CreateDiceMatch/JoinDiceMatch were not called") } if !rep.Validation.AppRollAPICalled { rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "RollDiceMatch was not called") } if !rep.Validation.EveryUserTrajectoryWritten { rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "not every user trajectory was written") } rep.Validation.Passed = len(rep.Validation.FailedChecks) == 0 summaryJSON := filepath.Join(*outDir, runID+"_summary.json") summaryMD := filepath.Join(*outDir, runID+"_summary.md") writeJSONFile(summaryJSON, rep) writeSummary(summaryMD, rep) fmt.Printf("run_id=%s\nsummary_json=%s\nsummary_md=%s\nusers_jsonl=%s\nrobot_users_jsonl=%s\nmixed_users_jsonl=%s\npassed=%v\n", runID, summaryJSON, summaryMD, allPath, robotPath, mixedPath, rep.Validation.Passed) } func newHarness() *apiHarness { repo := newMemoryRepo() wallet := &memoryWallet{balances: map[int64]int64{}} robots := newMemoryRobotRegistry(1000) diceSvc := diceservice.New(diceservice.Config{FeeBPS: feeBPS, MaxPlayers: 2}, repo, wallet, diceservice.WithRobotRegistry(robots)) return &apiHarness{ server: gamegrpc.NewServer(nil, diceSvc), repo: repo, wallet: wallet, robots: robots, } } func configureViaAdminAPI(ctx context.Context, h *apiHarness) bool { for _, gameID := range []string{dicedomain.DefaultGameID, dicedomain.SelfGameIDRock} { // The simulator writes configuration through the same GameAdminService RPCs used by admin-server. // This keeps strategy input shape identical to production RPC contracts instead of mutating the // repository directly and accidentally bypassing proto/domain normalization. _, err := h.server.UpdateDiceConfig(ctx, &gamev1.UpdateDiceConfigRequest{ Meta: meta("admin_config_"+gameID, 1), Config: &gamev1.DiceConfig{ GameId: gameID, Status: dicedomain.ConfigStatusActive, FeeBps: int32(feeBPS), PoolBps: int32(poolBPS), MinPlayers: 2, MaxPlayers: 2, RobotEnabled: true, RobotMatchWaitMs: 1, StakeOptions: []*gamev1.DiceStakeOption{ {StakeCoin: 1000, Enabled: true, SortOrder: 10}, {StakeCoin: 5000, Enabled: true, SortOrder: 20}, {StakeCoin: 40000, Enabled: true, SortOrder: 30}, {StakeCoin: 100000, Enabled: true, SortOrder: 40}, }, }, }) if err != nil { fatalf("admin UpdateDiceConfig %s: %v", gameID, err) } policy := selfgame.DefaultNewUserPolicy(appCode, gameID, time.Now().UnixMilli()) _, err = h.server.UpdateSelfGameNewUserPolicy(ctx, &gamev1.UpdateSelfGameNewUserPolicyRequest{ Meta: meta("admin_policy_"+gameID, 1), Policy: &gamev1.SelfGameNewUserPolicy{ GameId: gameID, Enabled: policy.Enabled, ProtectionRounds: policy.ProtectionRounds, ProtectionHours: policy.ProtectionHours, MaxStakeCoin: policy.MaxStakeCoin, LifetimeSubsidyQuotaCoin: policy.LifetimeSubsidyQuotaCoin, Day1SubsidyQuotaCoin: policy.Day1SubsidyQuotaCoin, SingleRoundSubsidyCapCoin: policy.SingleRoundSubsidyCap, StrategyLevel: policy.StrategyLevel, LoseStreakProtectionEnabled: policy.LoseStreakProtectionEnabled, LoseStreakTrigger: policy.LoseStreakTrigger, NormalPhaseTargetWinRatePercent: policy.NormalPhaseTargetWinRatePercent, }, }) if err != nil { fatalf("admin UpdateSelfGameNewUserPolicy %s: %v", gameID, err) } for _, stake := range []int64{1000, 5000, 40000, 100000} { threshold := selfgame.DefaultStakePool(appCode, gameID, stake, time.Now().UnixMilli()) initial := initialPoolCoin(gameID, stake) _, err = h.server.UpdateSelfGameStakePool(ctx, &gamev1.UpdateSelfGameStakePoolRequest{ Meta: meta(fmt.Sprintf("admin_pool_cfg_%s_%d", gameID, stake), 1), Pool: &gamev1.SelfGameStakePool{ GameId: gameID, StakeCoin: stake, TargetBalanceCoin: threshold.TargetBalanceCoin, SafeBalanceCoin: threshold.SafeBalanceCoin, WarningBalanceCoin: threshold.WarningBalanceCoin, DangerBalanceCoin: threshold.DangerBalanceCoin, Status: "active", }, }) if err != nil { fatalf("admin UpdateSelfGameStakePool %s/%d: %v", gameID, stake, err) } _, err = h.server.AdjustSelfGameStakePool(ctx, &gamev1.AdjustSelfGameStakePoolRequest{ Meta: meta(fmt.Sprintf("admin_pool_in_%s_%d", gameID, stake), 1), GameId: gameID, StakeCoin: stake, Direction: dicedomain.PoolDirectionIn, AmountCoin: initial, Reason: "api_sim_initial_pool", }) if err != nil { fatalf("admin AdjustSelfGameStakePool %s/%d: %v", gameID, stake, err) } } } return verifyAdminConfigReadback(ctx, h) } func verifyAdminConfigReadback(ctx context.Context, h *apiHarness) bool { // After writing config, immediately read it back through admin list/get APIs. A direct repository check // would only prove the in-memory test double changed; these RPC calls prove the admin-facing server // conversion layer can return the same strategy inputs that runtime matching will later consume. listResp, err := h.server.ListSelfGames(ctx, &gamev1.ListSelfGamesRequest{Meta: meta("admin_readback_self_games", 1)}) if err != nil { return false } seenGames := map[string]bool{} for _, item := range listResp.GetGames() { if item.GetGameId() == dicedomain.DefaultGameID || item.GetGameId() == dicedomain.SelfGameIDRock { seenGames[item.GetGameId()] = item.GetRobotEnabled() && item.GetStatus() == dicedomain.ConfigStatusActive && len(item.GetStakeOptions()) == 4 } } for _, gameID := range []string{dicedomain.DefaultGameID, dicedomain.SelfGameIDRock} { if !seenGames[gameID] { return false } policyResp, err := h.server.GetSelfGameNewUserPolicy(ctx, &gamev1.GetSelfGameNewUserPolicyRequest{ Meta: meta("admin_readback_policy_"+gameID, 1), GameId: gameID, }) if err != nil { return false } policy := policyResp.GetPolicy() if policy == nil || !policy.GetEnabled() || policy.GetProtectionRounds() <= 0 || policy.GetLoseStreakTrigger() <= 0 || policy.GetNormalPhaseTargetWinRatePercent() != selfgame.DefaultNormalPhaseTargetWinRatePercent { return false } poolsResp, err := h.server.ListSelfGameStakePools(ctx, &gamev1.ListSelfGameStakePoolsRequest{ Meta: meta("admin_readback_pools_"+gameID, 1), GameId: gameID, }) if err != nil { return false } seenStakes := map[int64]bool{} for _, pool := range poolsResp.GetPools() { seenStakes[pool.GetStakeCoin()] = pool.GetStatus() == "active" && pool.GetTargetBalanceCoin() >= pool.GetSafeBalanceCoin() && pool.GetSafeBalanceCoin() >= pool.GetWarningBalanceCoin() && pool.GetWarningBalanceCoin() >= pool.GetDangerBalanceCoin() } for _, stake := range []int64{1000, 5000, 40000, 100000} { if !seenStakes[stake] { return false } } } return true } func playRoundViaAPI(ctx context.Context, h *apiHarness, user *userState, round int, roundsPerUser int) roundTrace { gameID := weightedString([]weightedStringValue{{"dice", 55}, {"rock", 45}}) stake := weightedInt64([]weightedInt64Value{{1000, 48}, {5000, 30}, {40000, 15}, {100000, 7}}) requestedMode := matchModeRobot if user.Cohort == cohortMixed { requestedMode = weightedString([]weightedStringValue{{matchModeRobot, 65}, {matchModeHuman, 35}}) } trace := roundTrace{ RoundIndex: round, Cohort: user.Cohort, GameID: gameID, StakeCoin: stake, RequestedMatchMode: requestedMode, ActualMatchMode: requestedMode, UserCoinBefore: h.wallet.balance(user.UserID), } trace.PoolBalanceBefore, trace.PoolLevelBefore = h.repo.poolSnapshot(gameID, stake) if requestedMode == matchModeHuman { return playHumanRoundViaAPI(ctx, h, user, trace) } return playRobotRoundViaAPI(ctx, h, user, trace) } func playRobotRoundViaAPI(ctx context.Context, h *apiHarness, user *userState, trace roundTrace) roundTrace { reqID := fmt.Sprintf("api_robot_%d_%d_%s", user.UserID, trace.RoundIndex, randomHex(3)) matchResp, err := h.server.MatchDice(ctx, &gamev1.MatchDiceRequest{ Meta: meta(reqID, user.UserID), UserId: user.UserID, GameId: trace.GameID, StakeCoin: trace.StakeCoin, RpsGesture: randomHumanGesture(trace.GameID), }) h.repo.apiCalls.match++ if err != nil { trace.Result = resultSkipped trace.ActualMatchMode = resultSkipped trace.ValidationNote = "match_error:" + err.Error() trace.UserCoinAfter = h.wallet.balance(user.UserID) return trace } trace.MatchID = matchResp.GetMatch().GetMatchId() getResp, err := h.server.GetDiceMatch(ctx, &gamev1.GetDiceMatchRequest{ Meta: meta(reqID+"_get", user.UserID), UserId: user.UserID, GameId: trace.GameID, MatchId: trace.MatchID, }) if err != nil { trace.Result = resultSkipped trace.ActualMatchMode = resultSkipped trace.ValidationNote = "get_error:" + err.Error() trace.UserCoinAfter = h.wallet.balance(user.UserID) return trace } match := getResp.GetMatch() log := h.repo.strategyLog(trace.MatchID, user.UserID) trace.StrategyCode = log.StrategyCode trace.Decision = log.Decision trace.ReasonCode = log.ReasonCode trace.ForceResult = log.ForceResult if match.GetStatus() != dicedomain.MatchStatusReady || match.GetMatchMode() != dicedomain.MatchModeRobot { trace.Result = resultSkipped trace.ActualMatchMode = resultSkipped if trace.ReasonCode == "POOL_BLACK" { trace.ValidationNote = "black pool skipped robot attachment" } else { trace.ValidationNote = "robot_not_attached" } trace.UserCoinAfter = h.wallet.balance(user.UserID) trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin) return trace } return rollUntilFinal(ctx, h, user, trace) } func playHumanRoundViaAPI(ctx context.Context, h *apiHarness, user *userState, trace roundTrace) roundTrace { opponentID := int64(900000000 + user.UserID*1000 + int64(trace.RoundIndex)) h.wallet.setBalance(opponentID, 1_000_000_000) reqID := fmt.Sprintf("api_human_%d_%d_%s", user.UserID, trace.RoundIndex, randomHex(3)) createResp, err := h.server.CreateDiceMatch(ctx, &gamev1.CreateDiceMatchRequest{ Meta: meta(reqID+"_create", user.UserID), UserId: user.UserID, GameId: trace.GameID, StakeCoin: trace.StakeCoin, MinPlayers: 2, MaxPlayers: 2, RpsGesture: randomHumanGesture(trace.GameID), }) h.repo.apiCalls.create++ if err != nil { trace.Result = resultSkipped trace.ActualMatchMode = resultSkipped trace.ValidationNote = "create_error:" + err.Error() trace.UserCoinAfter = h.wallet.balance(user.UserID) return trace } trace.MatchID = createResp.GetMatch().GetMatchId() _, err = h.server.JoinDiceMatch(ctx, &gamev1.JoinDiceMatchRequest{ Meta: meta(reqID+"_join", opponentID), UserId: opponentID, GameId: trace.GameID, MatchId: trace.MatchID, RpsGesture: randomHumanGesture(trace.GameID), }) h.repo.apiCalls.join++ if err != nil { trace.Result = resultSkipped trace.ActualMatchMode = resultSkipped trace.ValidationNote = "join_error:" + err.Error() trace.UserCoinAfter = h.wallet.balance(user.UserID) return trace } return rollUntilFinal(ctx, h, user, trace) } func rollUntilFinal(ctx context.Context, h *apiHarness, user *userState, trace roundTrace) roundTrace { beforePool, _ := h.repo.poolSnapshot(trace.GameID, trace.StakeCoin) beforeCoin := h.wallet.balance(user.UserID) for attempt := 0; attempt < 8; attempt++ { rollResp, err := h.server.RollDiceMatch(ctx, &gamev1.RollDiceMatchRequest{ Meta: meta(fmt.Sprintf("api_roll_%d_%d_%d", user.UserID, trace.RoundIndex, attempt), user.UserID), UserId: user.UserID, GameId: trace.GameID, MatchId: trace.MatchID, RpsGesture: randomHumanGesture(trace.GameID), }) h.repo.apiCalls.roll++ if err != nil { trace.Result = resultSkipped trace.ActualMatchMode = resultSkipped trace.ValidationNote = "roll_error:" + err.Error() trace.UserCoinAfter = h.wallet.balance(user.UserID) trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin) return trace } match := rollResp.GetMatch() if match.GetStatus() == dicedomain.MatchStatusSettled { trace.Settled = true trace.Result = userResult(match, user.UserID) trace.UserCoinAfter = h.wallet.balance(user.UserID) trace.UserNetCoin = trace.UserCoinAfter - beforeCoin trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin) trace.PoolDeltaCoin = abs64(trace.PoolBalanceAfter - beforePool) if trace.PoolBalanceAfter > beforePool { trace.PoolDeltaDirection = dicedomain.PoolDirectionIn } if trace.PoolBalanceAfter < beforePool { trace.PoolDeltaDirection = dicedomain.PoolDirectionOut } if trace.ActualMatchMode == matchModeRobot && trace.Result != resultDraw { trace.EffectiveRobotGameCounted = true } if trace.ActualMatchMode == matchModeRobot && trace.Result == resultDraw { trace.DrawExcludedFromEffective = true } event := h.repo.protectionEvent(trace.MatchID, user.UserID) if event.MatchID != "" { trace.ProtectionConsumed = true trace.ProtectionSubsidyCoin = event.LifetimeSubsidyCoinDelta } return trace } } trace.Result = resultSkipped trace.ActualMatchMode = resultSkipped trace.ValidationNote = "roll_attempt_limit" trace.UserCoinAfter = h.wallet.balance(user.UserID) trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin) return trace } type memoryRepo struct { configs map[string]dicedomain.Config policies map[string]selfgame.NewUserPolicy pools map[string]selfgame.StakePool poolInitial map[string]int64 poolIn map[string]int64 poolOut map[string]int64 matches map[string]dicedomain.Match orders map[string]gamedomain.GameOrder stats map[string]selfgame.UserStats protection map[string]selfgame.ProtectionState protectionEventByMatch map[string]selfgame.ProtectionEvent risk map[int64]selfgame.RiskResult logs map[string]selfgame.StrategyLog apiCalls struct{ match, create, join, roll int } } func newMemoryRepo() *memoryRepo { return &memoryRepo{ configs: map[string]dicedomain.Config{}, policies: map[string]selfgame.NewUserPolicy{}, pools: map[string]selfgame.StakePool{}, poolInitial: map[string]int64{}, poolIn: map[string]int64{}, poolOut: map[string]int64{}, matches: map[string]dicedomain.Match{}, orders: map[string]gamedomain.GameOrder{}, stats: map[string]selfgame.UserStats{}, protection: map[string]selfgame.ProtectionState{}, protectionEventByMatch: map[string]selfgame.ProtectionEvent{}, risk: map[int64]selfgame.RiskResult{}, logs: map[string]selfgame.StrategyLog{}, } } func (r *memoryRepo) GetLaunchableGame(_ context.Context, app string, gameID string) (gamedomain.LaunchableGame, error) { gameID = strings.TrimSpace(gameID) return gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: appcode.Normalize(app), GameID: gameID, PlatformCode: dicedomain.DefaultPlatformCode, ProviderGameID: gameID, GameName: gameID, Status: gamedomain.StatusActive, }, PlatformName: "self-game", PlatformStatus: gamedomain.StatusActive, }, nil } func (r *memoryRepo) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) { if existing, ok := r.orders[order.OrderID]; ok { return existing, true, nil } r.orders[order.OrderID] = order return order, false, nil } func (r *memoryRepo) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMS int64) error { order := r.orders[orderID] order.Status = gamedomain.OrderStatusSucceeded order.WalletTransactionID = walletTransactionID order.WalletBalanceAfter = balanceAfter order.UpdatedAtMS = nowMS r.orders[orderID] = order return nil } func (r *memoryRepo) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMS int64) error { order := r.orders[orderID] order.Status = status order.FailureCode = code order.FailureMessage = message order.UpdatedAtMS = nowMS r.orders[orderID] = order return nil } func (r *memoryRepo) CreateDiceMatch(_ context.Context, match dicedomain.Match) (dicedomain.Match, error) { // 接口模拟不睡眠等待机器人补位;这里把等待局时间向前拨一点,只影响测试桩内存事实,不改变 service 策略代码。 match.CreatedAtMS -= 10 match.UpdatedAtMS = match.CreatedAtMS match.JoinDeadlineMS = match.CreatedAtMS + 60_000 r.matches[match.MatchID] = cloneMatch(match) return cloneMatch(match), nil } func (r *memoryRepo) JoinDiceMatch(_ context.Context, _ string, matchID string, userID int64, rpsGesture string, nowMS int64) (dicedomain.Match, error) { match := r.matches[strings.TrimSpace(matchID)] if match.MatchID == "" { return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found") } if !joinable(match.Status) { return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not joinable") } for _, participant := range match.Participants { if participant.UserID == userID { return cloneMatch(match), nil } } match.Participants = append(match.Participants, dicedomain.Participant{ AppCode: match.AppCode, MatchID: match.MatchID, UserID: userID, ParticipantType: dicedomain.ParticipantTypeUser, SeatNo: int32(len(match.Participants) + 1), Status: dicedomain.ParticipantStatusJoined, StakeCoin: match.StakeCoin, RPSGesture: strings.TrimSpace(rpsGesture), JoinedAtMS: nowMS, UpdatedAtMS: nowMS, }) match.CurrentPlayers = int32(len(match.Participants)) match.Status = dicedomain.MatchStatusJoining if match.CurrentPlayers >= match.MinPlayers { match.Status = dicedomain.MatchStatusReady match.ReadyAtMS = nowMS } match.UpdatedAtMS = nowMS r.matches[match.MatchID] = cloneMatch(match) return cloneMatch(match), nil } func (r *memoryRepo) GetDiceMatch(_ context.Context, _ string, matchID string) (dicedomain.Match, error) { match := r.matches[strings.TrimSpace(matchID)] if match.MatchID == "" { return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found") } return cloneMatch(match), nil } func (r *memoryRepo) ClaimDiceMatchForRoll(_ context.Context, _ string, matchID string, userID int64, nowMS int64) (dicedomain.Match, error) { match := r.matches[strings.TrimSpace(matchID)] if match.MatchID == "" { return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found") } if !hasParticipant(match.Participants, userID) { return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required") } if match.Result == dicedomain.ParticipantResultDraw { match.Result = "" for index := range match.Participants { match.Participants[index].DicePoints = nil match.Participants[index].Result = "" match.Participants[index].PayoutCoin = 0 match.Participants[index].UpdatedAtMS = nowMS } } match.Status = dicedomain.MatchStatusSettling match.UpdatedAtMS = nowMS r.matches[match.MatchID] = cloneMatch(match) return cloneMatch(match), nil } func (r *memoryRepo) SaveDiceRolls(_ context.Context, match dicedomain.Match, nowMS int64) (dicedomain.Match, error) { current := r.matches[match.MatchID] for _, source := range match.Participants { for index := range current.Participants { if current.Participants[index].UserID == source.UserID { current.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...) current.Participants[index].RPSGesture = source.RPSGesture current.Participants[index].Result = source.Result current.Participants[index].PayoutCoin = source.PayoutCoin current.Participants[index].UpdatedAtMS = nowMS } } } current.Status = dicedomain.MatchStatusPayoutApplying current.Result = match.Result current.UpdatedAtMS = nowMS r.matches[current.MatchID] = cloneMatch(current) return cloneMatch(current), nil } func (r *memoryRepo) SaveDiceDrawForReroll(_ context.Context, match dicedomain.Match, nowMS int64) (dicedomain.Match, error) { current := r.matches[match.MatchID] for _, source := range match.Participants { for index := range current.Participants { if current.Participants[index].UserID == source.UserID { current.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...) current.Participants[index].Result = dicedomain.ParticipantResultDraw current.Participants[index].UpdatedAtMS = nowMS } } } current.Status = dicedomain.MatchStatusReady current.Result = dicedomain.ParticipantResultDraw current.RoundNo++ current.UpdatedAtMS = nowMS r.matches[current.MatchID] = cloneMatch(current) return cloneMatch(current), nil } func (r *memoryRepo) MarkDiceParticipantDebitSucceeded(_ context.Context, _ string, matchID string, userID int64, orderID string, balanceAfter int64, nowMS int64) error { return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) { p.Status = dicedomain.ParticipantStatusDebitSucceeded p.DebitOrderID = orderID p.BalanceAfter = balanceAfter }) } func (r *memoryRepo) MarkDiceParticipantDebitFailed(_ context.Context, _ string, matchID string, userID int64, orderID string, nowMS int64) error { return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) { p.Status = dicedomain.ParticipantStatusDebitFailed p.DebitOrderID = orderID }) } func (r *memoryRepo) MarkDiceParticipantPayoutSucceeded(_ context.Context, _ string, matchID string, userID int64, orderID string, balanceAfter int64, nowMS int64) error { return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) { p.Status = dicedomain.ParticipantStatusPayoutSucceeded p.PayoutOrderID = orderID p.BalanceAfter = balanceAfter }) } func (r *memoryRepo) MarkDiceParticipantRefundSucceeded(_ context.Context, _ string, matchID string, userID int64, orderID string, balanceAfter int64, nowMS int64) error { return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) { p.Status = dicedomain.ParticipantStatusRefundSucceeded p.RefundOrderID = orderID p.BalanceAfter = balanceAfter }) } func (r *memoryRepo) MarkDiceMatchSettled(_ context.Context, _ string, matchID string, result string, nowMS int64) error { match := r.matches[strings.TrimSpace(matchID)] match.Status = dicedomain.MatchStatusSettled match.Result = result match.SettledAtMS = nowMS match.UpdatedAtMS = nowMS for index := range match.Participants { match.Participants[index].Status = dicedomain.ParticipantStatusSettled match.Participants[index].UpdatedAtMS = nowMS } r.matches[match.MatchID] = cloneMatch(match) r.applyStats(match) return nil } func (r *memoryRepo) MarkDiceMatchFailed(_ context.Context, _ string, matchID string, result string, nowMS int64) error { match := r.matches[strings.TrimSpace(matchID)] match.Status = dicedomain.MatchStatusFailed match.Result = result match.UpdatedAtMS = nowMS r.matches[match.MatchID] = cloneMatch(match) return nil } func (r *memoryRepo) GetDiceConfig(_ context.Context, app string, gameID string) (dicedomain.Config, error) { gameID = strings.TrimSpace(gameID) config, ok := r.configs[gameID] if !ok { return dicedomain.NormalizeConfig(dicedomain.Config{AppCode: appcode.Normalize(app), GameID: gameID}) } config.PoolBalanceCoin = r.poolTotal(gameID) return config, nil } func (r *memoryRepo) ListSelfGameConfigs(_ context.Context, _ string) ([]dicedomain.Config, error) { items := make([]dicedomain.Config, 0, len(r.configs)) for _, config := range r.configs { config.PoolBalanceCoin = r.poolTotal(config.GameID) items = append(items, config) } sort.Slice(items, func(i, j int) bool { return items[i].GameID < items[j].GameID }) return items, nil } func (r *memoryRepo) UpsertDiceConfig(_ context.Context, config dicedomain.Config, nowMS int64) (dicedomain.Config, error) { normalized, err := dicedomain.NormalizeConfig(config) if err != nil { return dicedomain.Config{}, err } normalized.AppCode = appcode.Normalize(normalized.AppCode) normalized.CreatedAtMS = nowMS normalized.UpdatedAtMS = nowMS r.configs[normalized.GameID] = normalized for _, option := range normalized.StakeOptions { key := poolKey(normalized.GameID, option.StakeCoin) if _, ok := r.pools[key]; !ok { r.pools[key] = selfgame.DefaultStakePool(normalized.AppCode, normalized.GameID, option.StakeCoin, nowMS) } } return normalized, nil } func (r *memoryRepo) FindAndJoinWaitingDiceMatch(context.Context, string, string, int64, int64, string, int64) (dicedomain.Match, error) { return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found") } func (r *memoryRepo) JoinDiceRobot(_ context.Context, _ string, matchID string, robotUserID int64, forcedResult string, rpsGesture string, nowMS int64) (dicedomain.Match, error) { match := r.matches[strings.TrimSpace(matchID)] if match.MatchID == "" { return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found") } match.Participants = append(match.Participants, dicedomain.Participant{ AppCode: match.AppCode, MatchID: match.MatchID, UserID: robotUserID, ParticipantType: dicedomain.ParticipantTypeRobot, SeatNo: int32(len(match.Participants) + 1), Status: dicedomain.ParticipantStatusJoined, StakeCoin: match.StakeCoin, RPSGesture: strings.TrimSpace(rpsGesture), JoinedAtMS: nowMS, UpdatedAtMS: nowMS, }) match.CurrentPlayers = int32(len(match.Participants)) match.MatchMode = dicedomain.MatchModeRobot match.ForcedResult = strings.TrimSpace(forcedResult) match.Status = dicedomain.MatchStatusReady match.ReadyAtMS = nowMS match.UpdatedAtMS = nowMS r.matches[match.MatchID] = cloneMatch(match) return cloneMatch(match), nil } func (r *memoryRepo) CancelDiceMatch(_ context.Context, _ string, matchID string, _ int64, nowMS int64) (dicedomain.Match, error) { match := r.matches[strings.TrimSpace(matchID)] match.Status = dicedomain.MatchStatusCanceled match.CanceledAtMS = nowMS match.UpdatedAtMS = nowMS r.matches[match.MatchID] = cloneMatch(match) return cloneMatch(match), nil } func (r *memoryRepo) PickActiveDiceRobot(context.Context, string, string, string, int64) (dicedomain.Robot, error) { return dicedomain.Robot{}, xerr.New(xerr.NotFound, "repository robot picker is not used in api sim") } func (r *memoryRepo) AdjustSelfGameStakePool(_ context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) { key := poolKey(adjustment.GameID, adjustment.StakeCoin) pool, ok := r.pools[key] if !ok { pool = selfgame.DefaultStakePool(adjustment.AppCode, adjustment.GameID, adjustment.StakeCoin, adjustment.CreatedAtMS) } switch adjustment.Direction { case dicedomain.PoolDirectionIn: pool.BalanceCoin += adjustment.AmountCoin r.poolIn[key] += adjustment.AmountCoin if strings.Contains(adjustment.Reason, "initial") { r.poolInitial[key] += adjustment.AmountCoin } case dicedomain.PoolDirectionOut: pool.BalanceCoin -= adjustment.AmountCoin if pool.BalanceCoin < 0 { return dicedomain.PoolAdjustment{}, xerr.New(xerr.Conflict, "self game stake pool balance is insufficient") } r.poolOut[key] += adjustment.AmountCoin default: return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "pool direction is invalid") } pool.UpdatedAtMS = adjustment.CreatedAtMS r.pools[key] = selfgame.NormalizeStakePool(pool) adjustment.BalanceAfter = pool.BalanceCoin return adjustment, nil } func (r *memoryRepo) GetSelfGameStakePool(_ context.Context, app string, gameID string, stakeCoin int64, nowMS int64) (selfgame.StakePool, error) { key := poolKey(gameID, stakeCoin) if pool, ok := r.pools[key]; ok { return selfgame.NormalizeStakePool(pool), nil } return selfgame.DefaultStakePool(appcode.Normalize(app), gameID, stakeCoin, nowMS), nil } func (r *memoryRepo) ListSelfGameStakePools(_ context.Context, _ string, gameID string, _ int64) ([]selfgame.StakePool, error) { var items []selfgame.StakePool prefix := strings.TrimSpace(gameID) + ":" for key, pool := range r.pools { if strings.HasPrefix(key, prefix) { items = append(items, selfgame.NormalizeStakePool(pool)) } } sort.Slice(items, func(i, j int) bool { return items[i].StakeCoin < items[j].StakeCoin }) return items, nil } func (r *memoryRepo) UpsertSelfGameStakePoolConfig(_ context.Context, pool selfgame.StakePool, nowMS int64) (selfgame.StakePool, error) { key := poolKey(pool.GameID, pool.StakeCoin) current, ok := r.pools[key] if !ok { current = selfgame.DefaultStakePool(pool.AppCode, pool.GameID, pool.StakeCoin, nowMS) } current.TargetBalanceCoin = pool.TargetBalanceCoin current.SafeBalanceCoin = pool.SafeBalanceCoin current.WarningBalanceCoin = pool.WarningBalanceCoin current.DangerBalanceCoin = pool.DangerBalanceCoin current.Status = pool.Status current.UpdatedAtMS = nowMS r.pools[key] = selfgame.NormalizeStakePool(current) return r.pools[key], nil } func (r *memoryRepo) GetSelfGameNewUserPolicy(_ context.Context, app string, gameID string, nowMS int64) (selfgame.NewUserPolicy, error) { if policy, ok := r.policies[strings.TrimSpace(gameID)]; ok { return selfgame.NormalizeNewUserPolicy(policy), nil } return selfgame.DefaultNewUserPolicy(appcode.Normalize(app), gameID, nowMS), nil } func (r *memoryRepo) UpsertSelfGameNewUserPolicy(_ context.Context, policy selfgame.NewUserPolicy, nowMS int64) (selfgame.NewUserPolicy, error) { policy.UpdatedAtMS = nowMS if policy.CreatedAtMS == 0 { policy.CreatedAtMS = nowMS } r.policies[policy.GameID] = selfgame.NormalizeNewUserPolicy(policy) return r.policies[policy.GameID], nil } func (r *memoryRepo) GetSelfGameProtectionState(_ context.Context, app string, gameID string, userID int64, _ int64) (selfgame.ProtectionState, error) { key := stateKey(gameID, userID) state, ok := r.protection[key] if !ok { state = selfgame.ProtectionState{AppCode: appcode.Normalize(app), GameID: gameID, UserID: userID, Status: "active"} } return selfgame.NormalizeProtectionState(state), nil } func (r *memoryRepo) GetSelfGameRiskResult(_ context.Context, _ string, _ string, userID int64, _ int64) (selfgame.RiskResult, error) { return selfgame.NormalizeRiskResult(r.risk[userID]), nil } func (r *memoryRepo) GetSelfGameUserStats(_ context.Context, _ string, gameID string, userID int64, _ int64) (selfgame.UserStats, error) { return r.stats[stateKey(gameID, userID)], nil } func (r *memoryRepo) RecordSelfGameStrategyLog(_ context.Context, log selfgame.StrategyLog) error { r.logs[fmt.Sprintf("%s:%d", log.MatchID, log.UserID)] = log return nil } func (r *memoryRepo) ConsumeSelfGameProtection(_ context.Context, event selfgame.ProtectionEvent) (selfgame.ProtectionState, bool, error) { key := stateKey(event.GameID, event.UserID) state := r.protection[key] state.AppCode = event.AppCode state.GameID = event.GameID state.UserID = event.UserID state.Status = "active" state.UsedRounds += event.UsedRoundDelta state.LifetimeSubsidyCoin += event.LifetimeSubsidyCoinDelta state.Day1SubsidyCoin += event.Day1SubsidyCoinDelta state.UpdatedAtMS = event.CreatedAtMS r.protection[key] = selfgame.NormalizeProtectionState(state) r.protectionEventByMatch[fmt.Sprintf("%s:%d", event.MatchID, event.UserID)] = event return r.protection[key], true, nil } func (r *memoryRepo) ListDiceRobots(context.Context, string, string, string, int32, string) ([]dicedomain.Robot, string, error) { return nil, "", nil } func (r *memoryRepo) RegisterDiceRobots(context.Context, string, string, []int64, int64, int64) ([]dicedomain.Robot, error) { return nil, nil } func (r *memoryRepo) SetDiceRobotStatus(context.Context, string, string, int64, string, int64) (dicedomain.Robot, error) { return dicedomain.Robot{}, nil } func (r *memoryRepo) DeleteDiceRobot(context.Context, string, string, int64) error { return nil } func (r *memoryRepo) RecordExploreWinner(context.Context, dicedomain.ExploreWinner) error { return nil } func (r *memoryRepo) ListExploreWinners(context.Context, string, int32) ([]dicedomain.ExploreWinner, error) { return nil, nil } func (r *memoryRepo) setRisk(userID int64, risk selfgame.RiskResult) { r.risk[userID] = selfgame.NormalizeRiskResult(risk) } func (r *memoryRepo) updateParticipant(matchID string, userID int64, nowMS int64, apply func(*dicedomain.Participant)) error { match := r.matches[strings.TrimSpace(matchID)] for index := range match.Participants { if match.Participants[index].UserID == userID { apply(&match.Participants[index]) match.Participants[index].UpdatedAtMS = nowMS r.matches[match.MatchID] = cloneMatch(match) return nil } } return xerr.New(xerr.NotFound, "dice participant not found") } func (r *memoryRepo) applyStats(match dicedomain.Match) { for _, participant := range match.Participants { if participant.ParticipantType != dicedomain.ParticipantTypeUser { continue } if participant.Result == dicedomain.ParticipantResultDraw { continue } key := stateKey(match.GameID, participant.UserID) stats := r.stats[key] net := participant.PayoutCoin - participant.StakeCoin stats.TodayNetWinCoin += net stats.SevenDayNetWinCoin += net if match.MatchMode == dicedomain.MatchModeRobot { stats.EffectiveBotGameCount++ stats.TodayRobotGameCount++ } if participant.Result == dicedomain.ParticipantResultWin { stats.WinStreak++ stats.LoseStreak = 0 } if participant.Result == dicedomain.ParticipantResultLose { stats.LoseStreak++ stats.WinStreak = 0 } r.stats[key] = stats } } func (r *memoryRepo) strategyLog(matchID string, userID int64) selfgame.StrategyLog { return r.logs[fmt.Sprintf("%s:%d", matchID, userID)] } func (r *memoryRepo) protectionEvent(matchID string, userID int64) selfgame.ProtectionEvent { return r.protectionEventByMatch[fmt.Sprintf("%s:%d", matchID, userID)] } func (r *memoryRepo) poolSnapshot(gameID string, stakeCoin int64) (int64, string) { pool := r.pools[poolKey(gameID, stakeCoin)] required := requiredPoolCoin(stakeCoin) return pool.BalanceCoin, selfgame.PoolLevelFor(pool, required) } func (r *memoryRepo) poolTotal(gameID string) int64 { var total int64 prefix := strings.TrimSpace(gameID) + ":" for key, pool := range r.pools { if strings.HasPrefix(key, prefix) { total += pool.BalanceCoin } } return total } func (r *memoryRepo) poolSummaries() []poolSummary { items := make([]poolSummary, 0, len(r.pools)) for key, pool := range r.pools { // initial funding is also an in transaction, so the accounting formula is initial + non-initial in - out. accounted := r.poolInitial[key] + (r.poolIn[key] - r.poolInitial[key]) - r.poolOut[key] items = append(items, poolSummary{ GameID: pool.GameID, StakeCoin: pool.StakeCoin, InitialCoin: r.poolInitial[key], BalanceCoin: pool.BalanceCoin, InCoin: r.poolIn[key], OutCoin: r.poolOut[key], PoolLevel: selfgame.PoolLevelFor(pool, requiredPoolCoin(pool.StakeCoin)), AccountingOK: accounted == pool.BalanceCoin, AccountedCoin: accounted, }) } sort.Slice(items, func(i, j int) bool { if items[i].GameID == items[j].GameID { return items[i].StakeCoin < items[j].StakeCoin } return items[i].GameID < items[j].GameID }) return items } type memoryWallet struct{ balances map[int64]int64 } func (w *memoryWallet) setBalance(userID int64, coin int64) { w.balances[userID] = coin } func (w *memoryWallet) balance(userID int64) int64 { return w.balances[userID] } func (w *memoryWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { if req.GetCoinAmount() <= 0 { return nil, xerr.New(xerr.InvalidArgument, "coin_amount must be positive") } switch req.GetOpType() { case "debit": if w.balances[req.GetUserId()] < req.GetCoinAmount() { return nil, xerr.New(xerr.InsufficientBalance, "coin balance is insufficient") } w.balances[req.GetUserId()] -= req.GetCoinAmount() case "credit", "refund": w.balances[req.GetUserId()] += req.GetCoinAmount() default: return nil, xerr.New(xerr.InvalidArgument, "game op_type is invalid") } return &walletv1.ApplyGameCoinChangeResponse{ WalletTransactionId: "api_sim_wtx_" + stableHash(req.GetProviderOrderId())[:20], BalanceAfter: w.balances[req.GetUserId()], }, nil } func (w *memoryWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{ AssetType: "COIN", AvailableAmount: w.balances[req.GetUserId()], }}}, nil } type memoryRobotRegistry struct { robots []int64 } func newMemoryRobotRegistry(count int) *memoryRobotRegistry { robots := make([]int64, 0, count) for i := 0; i < count; i++ { robots = append(robots, int64(800000000+i)) } return &memoryRobotRegistry{robots: robots} } func (r *memoryRobotRegistry) PickActiveDiceRobot(_ context.Context, app string, gameID string, _ string, exclude []int64, nowMS int64) (dicedomain.Robot, error) { blocked := map[int64]struct{}{} for _, id := range exclude { blocked[id] = struct{}{} } candidates := make([]int64, 0, len(r.robots)) for _, id := range r.robots { if _, ok := blocked[id]; !ok { candidates = append(candidates, id) } } if len(candidates) == 0 { return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") } id := candidates[randomInt(len(candidates))] return dicedomain.Robot{AppCode: appcode.Normalize(app), GameID: gameID, UserID: id, Status: dicedomain.RobotStatusActive, LastUsedAtMS: nowMS}, nil } func (r *memoryRobotRegistry) ListDiceRobots(context.Context, string, string, string, int32, string) ([]dicedomain.Robot, string, error) { return nil, "", nil } func (r *memoryRobotRegistry) RegisterDiceRobots(context.Context, string, string, []int64, int64, int64) ([]dicedomain.Robot, error) { return nil, nil } func (r *memoryRobotRegistry) SetDiceRobotStatus(context.Context, string, string, int64, string, int64) (dicedomain.Robot, error) { return dicedomain.Robot{}, nil } func (r *memoryRobotRegistry) DeleteDiceRobot(context.Context, string, string, int64) error { return nil } func meta(requestID string, actor int64) *gamev1.RequestMeta { return &gamev1.RequestMeta{RequestId: requestID, Caller: "selfgame-api-sim", ActorUserId: actor, SentAtMs: time.Now().UnixMilli(), AppCode: appCode} } func randomScenarioUser(index int, total int) *userState { cohort := cohortMixed if index < total/2 { cohort = cohortRobotOnly } risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: randomRiskScore()}) return &userState{ UserID: int64(100000 + index), Cohort: cohort, InitialCoin: int64(20_000 + randomInt(280_001)), RiskScore: risk.RiskScore, RiskTier: risk.RiskTier, RegisteredHours: int32(randomInt(24)), } } func accumulateSummary(s *summary, trace roundTrace) { s.Attempts++ if trace.Settled { s.Settled++ } if trace.Result == resultSkipped { s.Skipped++ } if trace.ActualMatchMode == matchModeHuman { s.HumanMatches++ } if trace.ActualMatchMode == matchModeRobot { s.RobotMatches++ } if trace.Result == resultWin { s.Wins++ s.NetFromWins += trace.UserNetCoin } if trace.Result == resultLose { s.Losses++ s.NetFromLosses += trace.UserNetCoin } if trace.Result == resultDraw { s.Draws++ } if trace.Decision == selfgame.DecisionForceHumanWin { s.ForceHumanWin++ } if trace.Decision == selfgame.DecisionForceHumanLose { s.ForceHumanLose++ } if trace.ReasonCode == "NEW_USER_POSITIVE_FEEDBACK" { s.NewUserPositiveFeedback++ } if trace.ReasonCode == "NEW_USER_LOSE_STREAK_PROTECTION" { s.NewUserLoseStreakProtection++ } if trace.ReasonCode == "POOL_BLACK" { s.PoolBlackSkips++ } if trace.ReasonCode == "RISK_BLOCKED" { s.RiskBlocked++ } if trace.StakeCoin >= 40000 && trace.Decision == selfgame.DecisionForceHumanWin { s.HighStakeProtectionHits++ } if trace.GameID == dicedomain.SelfGameIDRock && trace.Result == resultDraw && trace.ProtectionConsumed { s.RockDrawProtectionConsumptions++ } if trace.ProtectionConsumed { s.ProtectionConsumedRounds++ s.ProtectionSubsidyCoin += trace.ProtectionSubsidyCoin } if trace.EffectiveRobotGameCounted { s.EffectiveRobotGameCount++ } if trace.DrawExcludedFromEffective { s.EffectiveRobotDrawExcludedCount++ } } func accumulateUserResult(s *summary, record userRecord) { s.TotalUserNetCoin += record.NetCoin if record.NetCoin > 0 { s.UsersPositive++ } if record.NetCoin < 0 { s.UsersNegative++ } } func summarizeCohorts(path string) []cohortSummary { records := readUserRecords(path) by := map[string]*cohortSummary{ cohortRobotOnly: {Cohort: cohortRobotOnly}, cohortMixed: {Cohort: cohortMixed}, } for _, record := range records { item := by[record.Cohort] item.Users++ item.TotalUserNetCoin += record.NetCoin if record.NetCoin > 0 { item.UsersPositive++ } if record.NetCoin < 0 { item.UsersNegative++ } for _, trace := range record.Rounds { item.Rounds++ var tmp summary accumulateSummary(&tmp, trace) item.Settled += tmp.Settled item.Skipped += tmp.Skipped item.HumanMatches += tmp.HumanMatches item.RobotMatches += tmp.RobotMatches item.Wins += tmp.Wins item.Losses += tmp.Losses item.Draws += tmp.Draws item.ForceHumanWin += tmp.ForceHumanWin item.ForceHumanLose += tmp.ForceHumanLose item.NewUserPositiveFeedback += tmp.NewUserPositiveFeedback item.NewUserLoseStreakProtection += tmp.NewUserLoseStreakProtection item.PoolBlackSkips += tmp.PoolBlackSkips item.RiskBlocked += tmp.RiskBlocked item.HighStakeProtectionHits += tmp.HighStakeProtectionHits item.RockDrawProtectionConsumptions += tmp.RockDrawProtectionConsumptions item.ProtectionConsumedRounds += tmp.ProtectionConsumedRounds item.ProtectionSubsidyCoin += tmp.ProtectionSubsidyCoin item.NetFromWins += tmp.NetFromWins item.NetFromLosses += tmp.NetFromLosses item.EffectiveRobotGameCount += tmp.EffectiveRobotGameCount item.EffectiveRobotDrawExcludedCount += tmp.EffectiveRobotDrawExcludedCount } } out := []cohortSummary{*by[cohortRobotOnly], *by[cohortMixed]} for i := range out { out[i].AverageUserNetCoin = ratioInt64(out[i].TotalUserNetCoin, int64(out[i].Users)) out[i].OpenRate = ratio(out[i].Settled, out[i].Rounds) out[i].RobotOpenRate = ratio(out[i].RobotMatches, out[i].RobotMatches+out[i].PoolBlackSkips) } return out } func userResult(match *gamev1.DiceMatch, userID int64) string { for _, participant := range match.GetParticipants() { if participant.GetUserId() == userID { switch participant.GetResult() { case dicedomain.ParticipantResultWin: return resultWin case dicedomain.ParticipantResultLose: return resultLose case dicedomain.ParticipantResultDraw: return resultDraw } } } return resultSkipped } func randomHumanGesture(gameID string) string { if !dicedomain.IsRPSGameID(gameID) { return "" } return []string{"rock", "paper", "scissors"}[randomInt(3)] } func initialPoolCoin(gameID string, stake int64) int64 { switch stake { case 1000: return 2_000_000 case 5000: return 3_000_000 case 40000: return 80_000 case 100000: return 40_000 default: return stake * 10 } } func requiredPoolCoin(stake int64) int64 { return stake * 2 * (10_000 - feeBPS - poolBPS) / 10_000 } func randomRiskScore() int32 { roll := randomInt(100) switch { case roll < 78: return int32(randomInt(40)) case roll < 90: return int32(40 + randomInt(20)) case roll < 96: return int32(60 + randomInt(20)) default: return int32(80 + randomInt(20)) } } func weightedString(items []weightedStringValue) string { total := 0 for _, item := range items { total += item.Weight } roll := randomInt(total) for _, item := range items { if roll < item.Weight { return item.Value } roll -= item.Weight } return items[len(items)-1].Value } type weightedStringValue struct { Value string Weight int } func weightedInt64(items []weightedInt64Value) int64 { total := 0 for _, item := range items { total += item.Weight } roll := randomInt(total) for _, item := range items { if roll < item.Weight { return item.Value } roll -= item.Weight } return items[len(items)-1].Value } type weightedInt64Value struct { Value int64 Weight int } func shuffledRange(total int) []int { out := make([]int, total) for i := range out { out[i] = i } for i := len(out) - 1; i > 0; i-- { j := randomInt(i + 1) out[i], out[j] = out[j], out[i] } return out } func randomInt(max int) int { if max <= 0 { return 0 } n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) if err != nil { panic(err) } return int(n.Int64()) } func randomHex(size int) string { buf := make([]byte, size) if _, err := rand.Read(buf); err != nil { panic(err) } return hex.EncodeToString(buf) } func stableHash(input string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(input))) return hex.EncodeToString(sum[:]) } func cloneMatch(match dicedomain.Match) dicedomain.Match { match.Participants = append([]dicedomain.Participant(nil), match.Participants...) for index := range match.Participants { match.Participants[index].DicePoints = append([]int32(nil), match.Participants[index].DicePoints...) } return match } func hasParticipant(participants []dicedomain.Participant, userID int64) bool { for _, participant := range participants { if participant.UserID == userID { return true } } return false } func joinable(status string) bool { return status == dicedomain.MatchStatusCreated || status == dicedomain.MatchStatusJoining } func poolKey(gameID string, stake int64) string { return strings.TrimSpace(gameID) + ":" + fmt.Sprint(stake) } func stateKey(gameID string, userID int64) string { return strings.TrimSpace(gameID) + ":" + fmt.Sprint(userID) } func abs64(v int64) int64 { if v < 0 { return -v } return v } func ratio(numerator int, denominator int) float64 { if denominator == 0 { return 0 } return float64(numerator) / float64(denominator) } func ratioInt64(numerator int64, denominator int64) float64 { if denominator == 0 { return 0 } return float64(numerator) / float64(denominator) } func mustCreate(path string) *os.File { file, err := os.Create(path) if err != nil { fatalf("create %s: %v", path, err) } return file } func writeJSONLine(file *os.File, value any) error { data, err := json.Marshal(value) if err != nil { return err } if _, err := file.Write(append(data, '\n')); err != nil { return err } return nil } func writeJSONFile(path string, value any) { data, err := json.MarshalIndent(value, "", " ") if err != nil { fatalf("marshal %s: %v", path, err) } if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { fatalf("write %s: %v", path, err) } } func readUserRecords(path string) []userRecord { data, err := os.ReadFile(path) if err != nil { fatalf("read %s: %v", path, err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") records := make([]userRecord, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) == "" { continue } var record userRecord if err := json.Unmarshal([]byte(line), &record); err != nil { fatalf("parse user record: %v", err) } records = append(records, record) } return records } func countJSONLines(path string) int { data, err := os.ReadFile(path) if err != nil { return 0 } count := 0 for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { if strings.TrimSpace(line) != "" { count++ } } return count } func writeSummary(path string, rep report) { var b strings.Builder fmt.Fprintf(&b, "# 自研游戏接口模拟结果\n\n") fmt.Fprintf(&b, "- Run ID: `%s`\n", rep.RunID) fmt.Fprintf(&b, "- 用户数: `%d`\n", rep.UserCount) fmt.Fprintf(&b, "- 每人局数: `%d`\n", rep.RoundsPerUser) fmt.Fprintf(&b, "- 随机源: `%s`\n", rep.RandomSource) fmt.Fprintf(&b, "- 接口链路: `%s`\n", rep.InterfaceMode) fmt.Fprintf(&b, "- 用户完整轨迹: `%s`\n\n", rep.UserTrajectory) fmt.Fprintf(&b, "## Cohort 汇总\n\n") fmt.Fprintf(&b, "| 组 | 用户 | 局数 | settled | skipped | robot | human | wins | losses | draws | 人均净变化 | open rate | robot open rate |\n") fmt.Fprintf(&b, "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n") for _, item := range rep.Cohorts { fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %d | %d | %d | %d | %.2f | %.4f | %.4f |\n", item.Cohort, item.Users, item.Rounds, item.Settled, item.Skipped, item.RobotMatches, item.HumanMatches, item.Wins, item.Losses, item.Draws, item.AverageUserNetCoin, item.OpenRate, item.RobotOpenRate) } fmt.Fprintf(&b, "\n## 档位奖池\n\n") fmt.Fprintf(&b, "| 游戏 | 档位 | 初始 | 入池 | 出池 | 期末 | 水位 | 对账 |\n") fmt.Fprintf(&b, "| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |\n") for _, pool := range rep.PoolSummaries { fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %s | %v |\n", pool.GameID, pool.StakeCoin, pool.InitialCoin, pool.InCoin, pool.OutCoin, pool.BalanceCoin, pool.PoolLevel, pool.AccountingOK) } fmt.Fprintf(&b, "\n## 验收\n\n") fmt.Fprintf(&b, "- 结论: `%v`\n", rep.Validation.Passed) fmt.Fprintf(&b, "- Admin 配置接口: `%v`\n", rep.Validation.AdminConfigAPICalled) fmt.Fprintf(&b, "- 档位奖池接口: `%v`\n", rep.Validation.StakePoolAPICalled) fmt.Fprintf(&b, "- MatchDice: `%v`\n", rep.Validation.AppMatchAPICalled) fmt.Fprintf(&b, "- Create/Join: `%v`\n", rep.Validation.AppCreateJoinAPICalled) fmt.Fprintf(&b, "- RollDiceMatch: `%v`\n", rep.Validation.AppRollAPICalled) if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { fatalf("write summary: %v", err) } } func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(1) }