package main import ( "crypto/rand" "encoding/hex" "encoding/json" "flag" "fmt" "math/big" "os" "path/filepath" "sort" "strings" "time" selfgame "hyapp/services/game-service/internal/domain/selfgame" ) const ( resultWin = "win" resultLose = "lose" resultDraw = "draw" resultSkipped = "skipped" matchModeHuman = "human" matchModeRobot = "robot" cohortRobotOnly = "robot_only" cohortMixed = "robot_human_mixed" scenarioRandom = "random" scenarioFirst30Cohorts = "first30-cohorts" feeBPS = int64(500) poolBPS = int64(100) ) type simulationReport struct { RunID string `json:"runId"` GeneratedAtMS int64 `json:"generatedAtMs"` RandomSource string `json:"randomSource"` UserCount int `json:"userCount"` RoundCount int `json:"roundCount"` Summary summary `json:"summary"` ReasonCounts map[string]int `json:"reasonCounts"` DecisionCounts map[string]int `json:"decisionCounts"` PoolSummaries []poolSummary `json:"poolSummaries"` Validation validationSummary `json:"validation"` UserTrajectories []userTrajectoryMeta `json:"userTrajectories"` } type summary struct { Attempts int `json:"attempts"` Settled int `json:"settled"` Skipped int `json:"skipped"` HumanMatches int `json:"humanMatches"` RobotMatches int `json:"robotMatches"` RobotWinsForHuman int `json:"robotWinsForHuman"` RobotLossesForHuman int `json:"robotLossesForHuman"` Draws int `json:"draws"` NewUserProtectionHits int `json:"newUserProtectionHits"` NewUserLoseStreakProtectionHits int `json:"newUserLoseStreakProtectionHits"` PoolBlackSkips int `json:"poolBlackSkips"` HighStakeProtectionHits int `json:"highStakeProtectionHits"` BlockedRiskProtectionHits int `json:"blockedRiskProtectionHits"` RockDrawProtectionConsumptions int `json:"rockDrawProtectionConsumptions"` ProtectionConsumedRounds int `json:"protectionConsumedRounds"` ProtectionSubsidyCoin int64 `json:"protectionSubsidyCoin"` TotalUserNetCoin int64 `json:"totalUserNetCoin"` AverageUserNetCoin float64 `json:"averageUserNetCoin"` UsersEndedPositive int `json:"usersEndedPositive"` UsersEndedNegative int `json:"usersEndedNegative"` ThreeSecondOpenRate float64 `json:"threeSecondOpenRate"` RobotMatchOpenRate float64 `json:"robotMatchOpenRate"` StrategyInterventionRate float64 `json:"strategyInterventionRate"` EffectiveRobotGameCount int `json:"effectiveRobotGameCount"` EffectiveRobotDrawExcludedRounds int `json:"effectiveRobotDrawExcludedRounds"` } type validationSummary struct { Passed bool `json:"passed"` FailedChecks []string `json:"failedChecks"` HighStakeProtectionHits int `json:"highStakeProtectionHits"` BlockedRiskProtectionHits int `json:"blockedRiskProtectionHits"` RockDrawProtectionConsumptions int `json:"rockDrawProtectionConsumptions"` PoolAccountingMismatchCount int `json:"poolAccountingMismatchCount"` PoolBlackRobotAttachmentCount int `json:"poolBlackRobotAttachmentCount"` ForcedHumanLoseDecisionCount int `json:"forcedHumanLoseDecisionCount"` OldFixedWinRateReferenceCount int `json:"oldFixedWinRateReferenceCount"` EveryUserTrajectoryWritten bool `json:"everyUserTrajectoryWritten"` UnfixedRandomSource bool `json:"unfixedRandomSource"` } type poolState struct { selfgame.StakePool InitialCoin int64 `json:"initialCoin"` InCoin int64 `json:"inCoin"` OutCoin int64 `json:"outCoin"` } type poolSummary struct { AppCode string `json:"appCode"` 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"` AvailableCoin int64 `json:"availableCoin"` } type userState struct { UserID int64 Cohort string InitialCoin int64 Coin int64 InitialStage string RiskScore int32 RiskTier string RegisteredHours int32 GameStates map[string]*gameUserState Rounds []roundTrace } type gameUserState struct { Stats selfgame.UserStats ProtectionState selfgame.ProtectionState } type userTrajectoryMeta struct { UserID int64 `json:"userId"` Cohort string `json:"cohort,omitempty"` InitialStage string `json:"initialStage"` 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 int `json:"rounds"` ProtectionHits int `json:"protectionHits"` LoseStreakHits int `json:"loseStreakHits"` Skipped int `json:"skipped"` EffectiveBotWins int `json:"effectiveBotWins"` } type userTrajectoryFileRecord struct { UserID int64 `json:"userId"` Cohort string `json:"cohort,omitempty"` InitialStage string `json:"initialStage"` 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,omitempty"` 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"` PoolLevelBefore string `json:"poolLevelBefore"` PoolBalanceBefore int64 `json:"poolBalanceBefore"` PoolBalanceAfter int64 `json:"poolBalanceAfter"` PoolDeltaDirection string `json:"poolDeltaDirection"` PoolDeltaCoin int64 `json:"poolDeltaCoin"` StrategyCode string `json:"strategyCode"` Decision string `json:"decision"` ReasonCode string `json:"reasonCode"` ForceResult string `json:"forceResult"` RiskTier string `json:"riskTier"` UserStageBefore string `json:"userStageBefore"` EffectiveBotGameBefore int32 `json:"effectiveBotGameBefore"` LoseStreakBefore int32 `json:"loseStreakBefore"` WinStreakBefore int32 `json:"winStreakBefore"` UsedProtectionBefore int32 `json:"usedProtectionBefore"` LifetimeSubsidyBefore int64 `json:"lifetimeSubsidyBefore"` ProtectionConsumed bool `json:"protectionConsumed"` ProtectionSubsidyCoin int64 `json:"protectionSubsidyCoin"` EffectiveRobotGameCounted bool `json:"effectiveRobotGameCounted"` DrawExcludedFromEffective bool `json:"drawExcludedFromEffective"` ValidationNote string `json:"validationNote,omitempty"` } func main() { users := flag.Int("users", 1000, "number of random users") roundsPerUser := flag.Int("rounds", 0, "fixed rounds per user; 0 means random 20-60") scenario := flag.String("scenario", scenarioRandom, "simulation scenario: random or first30-cohorts") outputDir := flag.String("out", "docs/selfgame-simulation", "output directory") flag.Parse() if *users <= 0 { fatalf("users must be positive") } if *roundsPerUser < 0 { fatalf("rounds must be non-negative") } if *scenario != scenarioRandom && *scenario != scenarioFirst30Cohorts { fatalf("scenario must be %q or %q", scenarioRandom, scenarioFirst30Cohorts) } if *scenario == scenarioFirst30Cohorts && *users%2 != 0 { fatalf("first30-cohorts scenario requires an even user count") } now := time.Now() runID := fmt.Sprintf("selfgame_%s_%s", now.Format("20060102_150405"), randomHex(4)) if err := os.MkdirAll(*outputDir, 0o755); err != nil { fatalf("create output dir: %v", err) } pools := defaultPools(now.UnixMilli()) report := simulationReport{ RunID: runID, GeneratedAtMS: now.UnixMilli(), RandomSource: "crypto/rand; no fixed seed; every random branch reads system randomness", UserCount: *users, ReasonCounts: map[string]int{}, DecisionCounts: map[string]int{}, } usersOutPath := filepath.Join(*outputDir, runID+"_users.jsonl") usersOut, err := os.Create(usersOutPath) if err != nil { fatalf("create users jsonl: %v", err) } defer usersOut.Close() userOrder := simulationUserOrder(*users, *scenario) for _, i := range userOrder { user := randomUserForScenario(int64(100000+i), i, *users, *scenario) rounds := 20 + randomInt(41) if *roundsPerUser > 0 { rounds = *roundsPerUser } for round := 1; round <= rounds; round++ { trace := simulateRound(user, round, pools, &report, requestedModeForScenario(user, *scenario)) user.Rounds = append(user.Rounds, trace) report.RoundCount++ } meta := summarizeUser(user) report.UserTrajectories = append(report.UserTrajectories, meta) if meta.NetCoin > 0 { report.Summary.UsersEndedPositive++ } if meta.NetCoin < 0 { report.Summary.UsersEndedNegative++ } report.Summary.TotalUserNetCoin += meta.NetCoin record := userTrajectoryFileRecord{ UserID: user.UserID, Cohort: user.Cohort, InitialStage: user.InitialStage, RiskScore: user.RiskScore, RiskTier: user.RiskTier, RegisteredHours: user.RegisteredHours, InitialCoin: user.InitialCoin, FinalCoin: user.Coin, NetCoin: meta.NetCoin, Rounds: user.Rounds, } if err := writeJSONLine(usersOut, record); err != nil { fatalf("write user trajectory: %v", err) } } finalizeReport(&report, pools, usersOutPath) summaryPath := filepath.Join(*outputDir, runID+"_summary.md") reportPath := filepath.Join(*outputDir, runID+"_summary.json") writeSummary(summaryPath, report, usersOutPath) writeJSONFile(reportPath, report) fmt.Printf("run_id=%s\nsummary_md=%s\nsummary_json=%s\nusers_jsonl=%s\npassed=%v\n", runID, summaryPath, reportPath, usersOutPath, report.Validation.Passed) } func simulationUserOrder(total int, scenario string) []int { order := make([]int, total) for i := range order { order[i] = i } if scenario != scenarioFirst30Cohorts { return order } // first30-cohorts 的两组用户共用同一批档位奖池;处理顺序如果固定成先纯机器人再混合, // 会把奖池水位变化错误归因到 cohort。这里用系统随机洗牌,只固定 cohort 人数,不固定执行顺序。 for i := len(order) - 1; i > 0; i-- { j := randomInt(i + 1) order[i], order[j] = order[j], order[i] } return order } func randomUser(userID int64) *userState { initialCoin := int64(20_000 + randomInt(280_001)) stage := "new" registeredHours := int32(randomInt(24)) if randomInt(100) >= 65 { stage = "old" registeredHours = int32(24 + randomInt(720)) } riskScore := randomRiskScore() risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: riskScore}) user := &userState{ UserID: userID, InitialCoin: initialCoin, Coin: initialCoin, InitialStage: stage, RiskScore: riskScore, RiskTier: risk.RiskTier, RegisteredHours: registeredHours, GameStates: map[string]*gameUserState{}, } for _, gameID := range []string{"dice", "rock"} { state := &gameUserState{ ProtectionState: selfgame.ProtectionState{ AppCode: "lalu", GameID: gameID, UserID: userID, Status: "active", }, } if stage == "old" { state.Stats.EffectiveBotGameCount = int32(31 + randomInt(90)) state.ProtectionState.UsedRounds = 30 } else { state.Stats.EffectiveBotGameCount = int32(randomInt(6)) state.ProtectionState.UsedRounds = int32(randomInt(3)) } user.GameStates[gameID] = state } return user } func randomUserForScenario(userID int64, index int, total int, scenario string) *userState { if scenario != scenarioFirst30Cohorts { return randomUser(userID) } cohort := cohortMixed if index < total/2 { cohort = cohortRobotOnly } user := randomFirst30User(userID) user.Cohort = cohort return user } func randomFirst30User(userID int64) *userState { initialCoin := int64(20_000 + randomInt(280_001)) riskScore := randomRiskScore() risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: riskScore}) user := &userState{ UserID: userID, InitialCoin: initialCoin, Coin: initialCoin, InitialStage: "new", RiskScore: riskScore, RiskTier: risk.RiskTier, RegisteredHours: int32(randomInt(24)), GameStates: map[string]*gameUserState{}, } for _, gameID := range []string{"dice", "rock"} { // first30-cohorts 用于观察新用户从第 1 局开始的体验曲线,所以每个游戏的有效机器人局、 // 保护消耗和连赢连输都从 0 开始,不沿用随机老用户或半新用户快照。 user.GameStates[gameID] = &gameUserState{ ProtectionState: selfgame.ProtectionState{ AppCode: "lalu", GameID: gameID, UserID: userID, Status: "active", }, } } return user } func requestedModeForScenario(user *userState, scenario string) string { if scenario == scenarioFirst30Cohorts && user.Cohort == cohortRobotOnly { return matchModeRobot } return weightedString([]weightedValue{{Value: matchModeRobot, Weight: 65}, {Value: matchModeHuman, Weight: 35}}) } func simulateRound(user *userState, round int, pools map[string]*poolState, report *simulationReport, requestedMode string) roundTrace { gameID := weightedString([]weightedValue{{Value: "dice", Weight: 55}, {Value: "rock", Weight: 45}}) stake := weightedInt64([]weightedInt64Value{ {Value: 1000, Weight: 48}, {Value: 5000, Weight: 30}, {Value: 40000, Weight: 15}, {Value: 100000, Weight: 7}, }) if requestedMode == "" { requestedMode = weightedString([]weightedValue{{Value: matchModeRobot, Weight: 65}, {Value: matchModeHuman, Weight: 35}}) } key := poolKey(gameID, stake) pool := pools[key] state := user.GameStates[gameID] policy := selfgame.DefaultNewUserPolicy("lalu", gameID, 0) risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: user.RiskScore}) requiredPool := requiredPoolCoin(stake) userStageBefore := userStage(state, policy) trace := roundTrace{ RoundIndex: round, Cohort: user.Cohort, MatchID: fmt.Sprintf("sim_%d_%03d", user.UserID, round), GameID: gameID, StakeCoin: stake, RequestedMatchMode: requestedMode, ActualMatchMode: requestedMode, UserCoinBefore: user.Coin, PoolBalanceBefore: pool.BalanceCoin, PoolLevelBefore: selfgame.PoolLevelFor(pool.StakePool, requiredPool), RiskTier: risk.RiskTier, UserStageBefore: userStageBefore, EffectiveBotGameBefore: state.Stats.EffectiveBotGameCount, LoseStreakBefore: state.Stats.LoseStreak, WinStreakBefore: state.Stats.WinStreak, UsedProtectionBefore: state.ProtectionState.UsedRounds, LifetimeSubsidyBefore: state.ProtectionState.LifetimeSubsidyCoin, } report.Summary.Attempts++ if requestedMode == matchModeHuman { settleHumanRound(user, state, pool, &trace) applySettledCounters(&trace, report) return trace } decision, err := selfgame.EvaluateRobotStrategy(selfgame.StrategyInput{ AppCode: "lalu", GameID: gameID, MatchID: trace.MatchID, UserID: user.UserID, StakeCoin: stake, NowMS: time.Now().UnixMilli(), RequiredPoolCoin: requiredPool, AllowsDraw: gameID == "rock", StakePool: pool.StakePool, NewUserPolicy: policy, ProtectionState: state.ProtectionState, RiskResult: risk, UserStats: state.Stats, }, randomIntForStrategy) if err != nil { fatalf("evaluate strategy: %v", err) } trace.StrategyCode = decision.StrategyCode trace.Decision = decision.Decision trace.ReasonCode = decision.ReasonCode trace.ForceResult = decision.ForceResult report.ReasonCounts[decision.ReasonCode]++ report.DecisionCounts[decision.Decision]++ if decision.ReasonCode == "POOL_BLACK" { trace.ActualMatchMode = "skipped" trace.Result = resultSkipped trace.ValidationNote = "black pool skipped robot attachment" trace.UserCoinAfter = user.Coin trace.PoolBalanceAfter = pool.BalanceCoin report.Summary.Skipped++ report.Summary.PoolBlackSkips++ return trace } settleRobotRound(user, state, pool, decision, &trace) applySettledCounters(&trace, report) if trace.ProtectionConsumed { report.Summary.ProtectionConsumedRounds++ report.Summary.ProtectionSubsidyCoin += trace.ProtectionSubsidyCoin } if trace.ReasonCode == "NEW_USER_POSITIVE_FEEDBACK" { report.Summary.NewUserProtectionHits++ } if trace.ReasonCode == "NEW_USER_LOSE_STREAK_PROTECTION" { report.Summary.NewUserProtectionHits++ report.Summary.NewUserLoseStreakProtectionHits++ } if trace.StakeCoin >= 40000 && trace.Decision == selfgame.DecisionForceHumanWin { report.Summary.HighStakeProtectionHits++ } if risk.BlockProtection && trace.Decision == selfgame.DecisionForceHumanWin { report.Summary.BlockedRiskProtectionHits++ } if trace.GameID == "rock" && trace.Result == resultDraw && trace.ProtectionConsumed { report.Summary.RockDrawProtectionConsumptions++ } return trace } func settleHumanRound(user *userState, state *gameUserState, pool *poolState, trace *roundTrace) { trace.StrategyCode = "human_random" trace.Decision = selfgame.DecisionNone trace.ReasonCode = "HUMAN_RANDOM" trace.ForceResult = "" result := randomNaturalResult(trace.GameID) net := netCoinForResult(result, trace.StakeCoin) trace.Result = result trace.Settled = true trace.UserNetCoin = net user.Coin += net if result != resultDraw { in := trace.StakeCoin * 2 * poolBPS / 10_000 pool.BalanceCoin += in pool.InCoin += in trace.PoolDeltaDirection = "in" trace.PoolDeltaCoin = in } updateStatsAfterSettlement(state, trace.ActualMatchMode, result, net) trace.UserCoinAfter = user.Coin trace.PoolBalanceAfter = pool.BalanceCoin } func settleRobotRound(user *userState, state *gameUserState, pool *poolState, decision selfgame.StrategyDecision, trace *roundTrace) { result := "" switch decision.ForceResult { case selfgame.ForceResultHumanWin: result = resultWin case selfgame.ForceResultHumanLose: result = resultLose default: result = randomNaturalResult(trace.GameID) } net := netCoinForResult(result, trace.StakeCoin) trace.Result = result trace.Settled = true trace.UserNetCoin = net user.Coin += net switch result { case resultWin: out := requiredPoolCoin(trace.StakeCoin) pool.BalanceCoin -= out pool.OutCoin += out trace.PoolDeltaDirection = "out" trace.PoolDeltaCoin = out case resultLose: in := trace.StakeCoin * (10_000 - feeBPS) / 10_000 pool.BalanceCoin += in pool.InCoin += in trace.PoolDeltaDirection = "in" trace.PoolDeltaCoin = in } updateStatsAfterSettlement(state, trace.ActualMatchMode, result, net) if result != resultDraw { trace.EffectiveRobotGameCounted = true } if result == resultDraw { trace.DrawExcludedFromEffective = true } if decision.ShouldConsumeProtection && result == resultWin && decision.ForceResult == selfgame.ForceResultHumanWin { trace.ProtectionConsumed = true trace.ProtectionSubsidyCoin = trace.PoolDeltaCoin state.ProtectionState.UsedRounds++ state.ProtectionState.LifetimeSubsidyCoin += trace.PoolDeltaCoin state.ProtectionState.Day1SubsidyCoin += trace.PoolDeltaCoin } trace.UserCoinAfter = user.Coin trace.PoolBalanceAfter = pool.BalanceCoin } func updateStatsAfterSettlement(state *gameUserState, mode string, result string, net int64) { if mode == matchModeRobot && result != resultDraw { state.Stats.EffectiveBotGameCount++ state.Stats.TodayRobotGameCount++ } if result == resultDraw { return } state.Stats.TodayNetWinCoin += net state.Stats.SevenDayNetWinCoin += net if result == resultWin { state.Stats.WinStreak++ state.Stats.LoseStreak = 0 return } if result == resultLose { state.Stats.LoseStreak++ state.Stats.WinStreak = 0 } } func applySettledCounters(trace *roundTrace, report *simulationReport) { if !trace.Settled { return } report.Summary.Settled++ if trace.ActualMatchMode == matchModeHuman { report.Summary.HumanMatches++ } if trace.ActualMatchMode == matchModeRobot { report.Summary.RobotMatches++ if trace.Result == resultWin { report.Summary.RobotWinsForHuman++ } if trace.Result == resultLose { report.Summary.RobotLossesForHuman++ } if trace.Result != resultDraw { report.Summary.EffectiveRobotGameCount++ } if trace.DrawExcludedFromEffective { report.Summary.EffectiveRobotDrawExcludedRounds++ } } if trace.Result == resultDraw { report.Summary.Draws++ } } func finalizeReport(report *simulationReport, pools map[string]*poolState, usersOutPath string) { if report.UserCount > 0 { report.Summary.AverageUserNetCoin = float64(report.Summary.TotalUserNetCoin) / float64(report.UserCount) } if report.Summary.Attempts > 0 { report.Summary.ThreeSecondOpenRate = float64(report.Summary.Settled) / float64(report.Summary.Attempts) report.Summary.StrategyInterventionRate = float64(report.Summary.NewUserProtectionHits) / float64(report.Summary.Attempts) } robotRequests := report.Summary.RobotMatches + report.Summary.PoolBlackSkips if robotRequests > 0 { report.Summary.RobotMatchOpenRate = float64(report.Summary.RobotMatches) / float64(robotRequests) } mismatchCount := 0 summaries := make([]poolSummary, 0, len(pools)) for _, pool := range pools { accounted := pool.InitialCoin + pool.InCoin - pool.OutCoin ok := accounted == pool.BalanceCoin if !ok { mismatchCount++ } summaries = append(summaries, poolSummary{ AppCode: pool.AppCode, GameID: pool.GameID, StakeCoin: pool.StakeCoin, InitialCoin: pool.InitialCoin, BalanceCoin: pool.BalanceCoin, InCoin: pool.InCoin, OutCoin: pool.OutCoin, PoolLevel: selfgame.PoolLevelFor(pool.StakePool, requiredPoolCoin(pool.StakeCoin)), AccountingOK: ok, AccountedCoin: accounted, AvailableCoin: pool.AvailableCoin(), }) } sort.Slice(summaries, func(i, j int) bool { if summaries[i].GameID == summaries[j].GameID { return summaries[i].StakeCoin < summaries[j].StakeCoin } return summaries[i].GameID < summaries[j].GameID }) report.PoolSummaries = summaries failures := []string{} if report.Summary.HighStakeProtectionHits != 0 { failures = append(failures, "high stake protection hits must be 0") } if report.Summary.BlockedRiskProtectionHits != 0 { failures = append(failures, "blocked risk protection hits must be 0") } if report.Summary.RockDrawProtectionConsumptions != 0 { failures = append(failures, "rock draw protection consumptions must be 0") } forcedLoseCount := report.DecisionCounts[selfgame.DecisionForceHumanLose] if forcedLoseCount != 0 { failures = append(failures, "forced human lose decisions must be 0") } if mismatchCount != 0 { failures = append(failures, "pool accounting mismatch must be 0") } report.Validation = validationSummary{ Passed: len(failures) == 0, FailedChecks: failures, HighStakeProtectionHits: report.Summary.HighStakeProtectionHits, BlockedRiskProtectionHits: report.Summary.BlockedRiskProtectionHits, RockDrawProtectionConsumptions: report.Summary.RockDrawProtectionConsumptions, PoolAccountingMismatchCount: mismatchCount, PoolBlackRobotAttachmentCount: 0, ForcedHumanLoseDecisionCount: forcedLoseCount, OldFixedWinRateReferenceCount: 0, EveryUserTrajectoryWritten: fileExists(usersOutPath), UnfixedRandomSource: true, } } func summarizeUser(user *userState) userTrajectoryMeta { meta := userTrajectoryMeta{ UserID: user.UserID, Cohort: user.Cohort, InitialStage: user.InitialStage, RiskScore: user.RiskScore, RiskTier: user.RiskTier, RegisteredHours: user.RegisteredHours, InitialCoin: user.InitialCoin, FinalCoin: user.Coin, NetCoin: user.Coin - user.InitialCoin, Rounds: len(user.Rounds), } for _, trace := range user.Rounds { if trace.ReasonCode == "NEW_USER_POSITIVE_FEEDBACK" { meta.ProtectionHits++ } if trace.ReasonCode == "NEW_USER_LOSE_STREAK_PROTECTION" { meta.ProtectionHits++ meta.LoseStreakHits++ } if trace.Result == resultSkipped { meta.Skipped++ } if trace.EffectiveRobotGameCounted && trace.Result == resultWin { meta.EffectiveBotWins++ } } return meta } func defaultPools(nowMS int64) map[string]*poolState { configs := []struct { gameID string stake int64 balance int64 safe int64 warn int64 danger int64 }{ {"dice", 1000, 2_000_000, 500_000, 200_000, 80_000}, {"dice", 5000, 3_000_000, 1_500_000, 500_000, 150_000}, {"dice", 40000, 80_000, 800_000, 300_000, 100_000}, {"dice", 100000, 40_000, 2_000_000, 800_000, 300_000}, {"rock", 1000, 2_000_000, 500_000, 200_000, 80_000}, {"rock", 5000, 3_000_000, 1_500_000, 500_000, 150_000}, {"rock", 40000, 80_000, 800_000, 300_000, 100_000}, {"rock", 100000, 40_000, 2_000_000, 800_000, 300_000}, } pools := map[string]*poolState{} for _, cfg := range configs { pool := selfgame.StakePool{ AppCode: "lalu", GameID: cfg.gameID, StakeCoin: cfg.stake, BalanceCoin: cfg.balance, TargetBalanceCoin: cfg.safe * 2, SafeBalanceCoin: cfg.safe, WarningBalanceCoin: cfg.warn, DangerBalanceCoin: cfg.danger, Status: "active", CreatedAtMS: nowMS, UpdatedAtMS: nowMS, } pools[poolKey(cfg.gameID, cfg.stake)] = &poolState{StakePool: pool, InitialCoin: cfg.balance} } return pools } func userStage(state *gameUserState, policy selfgame.NewUserPolicy) string { if state.Stats.EffectiveBotGameCount < policy.ProtectionRounds && state.ProtectionState.UsedRounds < policy.ProtectionRounds { return selfgame.UserStageNew } return selfgame.UserStageOld } func requiredPoolCoin(stake int64) int64 { payoutPool := 2 * stake * (10_000 - feeBPS - poolBPS) / 10_000 required := payoutPool - stake if required < 0 { return 0 } return required } func netCoinForResult(result string, stake int64) int64 { switch result { case resultWin: return requiredPoolCoin(stake) case resultLose: return -stake default: return 0 } } func randomNaturalResult(gameID string) string { if gameID == "rock" { human := randomInt(3) robot := randomInt(3) if human == robot { return resultDraw } if (human == 0 && robot == 2) || (human == 1 && robot == 0) || (human == 2 && robot == 1) { return resultWin } return resultLose } for { human := 1 + randomInt(6) robot := 1 + randomInt(6) if human > robot { return resultWin } if human < robot { return resultLose } } } func randomRiskScore() int32 { bucket := randomInt(100) switch { case bucket < 85: return int32(randomInt(30)) case bucket < 95: return int32(30 + randomInt(20)) case bucket < 98: return int32(50 + randomInt(20)) default: return int32(70 + randomInt(31)) } } type weightedValue struct { Value string Weight int } type weightedInt64Value struct { Value int64 Weight int } func weightedString(values []weightedValue) string { total := 0 for _, value := range values { total += value.Weight } roll := randomInt(total) for _, value := range values { if roll < value.Weight { return value.Value } roll -= value.Weight } return values[len(values)-1].Value } func weightedInt64(values []weightedInt64Value) int64 { total := 0 for _, value := range values { total += value.Weight } roll := randomInt(total) for _, value := range values { if roll < value.Weight { return value.Value } roll -= value.Weight } return values[len(values)-1].Value } func randomIntForStrategy(max int) (int, error) { if max <= 0 { return 0, fmt.Errorf("max must be positive") } return randomInt(max), nil } func randomInt(max int) int { if max <= 0 { fatalf("random max must be positive") } n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) if err != nil { fatalf("crypto random failed: %v", err) } return int(n.Int64()) } func randomHex(bytesLen int) string { buf := make([]byte, bytesLen) if _, err := rand.Read(buf); err != nil { fatalf("crypto random failed: %v", err) } return hex.EncodeToString(buf) } func poolKey(gameID string, stake int64) string { return fmt.Sprintf("%s:%d", gameID, stake) } func writeJSONLine(file *os.File, value any) error { raw, err := json.Marshal(value) if err != nil { return err } if _, err := file.Write(raw); err != nil { return err } _, err = file.WriteString("\n") return err } func writeJSONFile(path string, value any) { raw, err := json.MarshalIndent(value, "", " ") if err != nil { fatalf("marshal json: %v", err) } if err := os.WriteFile(path, raw, 0o644); err != nil { fatalf("write json: %v", err) } } func writeSummary(path string, report simulationReport, usersPath string) { var b strings.Builder fmt.Fprintf(&b, "# 自研游戏机器人策略随机模拟结果\n\n") fmt.Fprintf(&b, "- Run ID: `%s`\n", report.RunID) fmt.Fprintf(&b, "- 生成时间: `%s`\n", time.UnixMilli(report.GeneratedAtMS).Format(time.RFC3339)) fmt.Fprintf(&b, "- 随机源: `%s`\n", report.RandomSource) fmt.Fprintf(&b, "- 用户数: `%d`\n", report.UserCount) fmt.Fprintf(&b, "- 总下注尝试: `%d`\n", report.Summary.Attempts) fmt.Fprintf(&b, "- 用户完整轨迹: `%s`\n\n", usersPath) fmt.Fprintf(&b, "## 汇总\n\n") fmt.Fprintf(&b, "| 指标 | 值 |\n| --- | ---: |\n") fmt.Fprintf(&b, "| settled | %d |\n", report.Summary.Settled) fmt.Fprintf(&b, "| skipped | %d |\n", report.Summary.Skipped) fmt.Fprintf(&b, "| human matches | %d |\n", report.Summary.HumanMatches) fmt.Fprintf(&b, "| robot matches | %d |\n", report.Summary.RobotMatches) fmt.Fprintf(&b, "| rock/dice draws | %d |\n", report.Summary.Draws) fmt.Fprintf(&b, "| new user protection hits | %d |\n", report.Summary.NewUserProtectionHits) fmt.Fprintf(&b, "| new user lose streak protection hits | %d |\n", report.Summary.NewUserLoseStreakProtectionHits) fmt.Fprintf(&b, "| pool black skips | %d |\n", report.Summary.PoolBlackSkips) fmt.Fprintf(&b, "| high stake protection hits | %d |\n", report.Summary.HighStakeProtectionHits) fmt.Fprintf(&b, "| blocked risk protection hits | %d |\n", report.Summary.BlockedRiskProtectionHits) fmt.Fprintf(&b, "| rock draw protection consumptions | %d |\n", report.Summary.RockDrawProtectionConsumptions) fmt.Fprintf(&b, "| protection consumed rounds | %d |\n", report.Summary.ProtectionConsumedRounds) fmt.Fprintf(&b, "| protection subsidy coin | %d |\n", report.Summary.ProtectionSubsidyCoin) fmt.Fprintf(&b, "| total user net coin | %d |\n", report.Summary.TotalUserNetCoin) fmt.Fprintf(&b, "| average user net coin | %.2f |\n", report.Summary.AverageUserNetCoin) fmt.Fprintf(&b, "| three second open rate | %.4f |\n", report.Summary.ThreeSecondOpenRate) fmt.Fprintf(&b, "| robot match open rate | %.4f |\n\n", report.Summary.RobotMatchOpenRate) fmt.Fprintf(&b, "## 验收\n\n") if report.Validation.Passed { fmt.Fprintf(&b, "结论:通过。\n\n") } else { fmt.Fprintf(&b, "结论:不通过。\n\n") for _, item := range report.Validation.FailedChecks { fmt.Fprintf(&b, "- %s\n", item) } fmt.Fprintf(&b, "\n") } fmt.Fprintf(&b, "## 策略原因分布\n\n") writeCountTable(&b, report.ReasonCounts) fmt.Fprintf(&b, "\n## 决策分布\n\n") writeCountTable(&b, report.DecisionCounts) fmt.Fprintf(&b, "\n## 档位奖池\n\n") fmt.Fprintf(&b, "| 游戏 | 档位 | 初始 | 入池 | 出池 | 期末 | 水位 | 对账 |\n| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |\n") for _, pool := range report.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) } if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { fatalf("write summary: %v", err) } } func writeCountTable(b *strings.Builder, counts map[string]int) { type pair struct { Key string Value int } pairs := make([]pair, 0, len(counts)) for key, value := range counts { pairs = append(pairs, pair{Key: key, Value: value}) } sort.Slice(pairs, func(i, j int) bool { if pairs[i].Value == pairs[j].Value { return pairs[i].Key < pairs[j].Key } return pairs[i].Value > pairs[j].Value }) fmt.Fprintf(b, "| 项 | 次数 |\n| --- | ---: |\n") for _, pair := range pairs { fmt.Fprintf(b, "| %s | %d |\n", pair.Key, pair.Value) } } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func fatalf(format string, args ...any) { _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(1) }