999 lines
34 KiB
Go
999 lines
34 KiB
Go
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)
|
||
}
|