2026-06-12 19:40:21 +08:00

999 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}