package main

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"math/big"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	gamev1 "hyapp.local/api/proto/game/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	dicedomain "hyapp/services/game-service/internal/domain/dice"
	gamedomain "hyapp/services/game-service/internal/domain/game"
	selfgame "hyapp/services/game-service/internal/domain/selfgame"
	diceservice "hyapp/services/game-service/internal/service/dice"
	gamegrpc "hyapp/services/game-service/internal/transport/grpc"
)

const (
	appCode = "lalu"

	resultWin     = "win"
	resultLose    = "lose"
	resultDraw    = "draw"
	resultSkipped = "skipped"

	cohortRobotOnly = "robot_only"
	cohortMixed     = "robot_human_mixed"

	matchModeHuman = "human"
	matchModeRobot = "robot"

	feeBPS  = int64(500)
	poolBPS = int64(100)
)

type report struct {
	RunID          string            `json:"runId"`
	GeneratedAtMS  int64             `json:"generatedAtMs"`
	RandomSource   string            `json:"randomSource"`
	InterfaceMode  string            `json:"interfaceMode"`
	UserCount      int               `json:"userCount"`
	RoundsPerUser  int               `json:"roundsPerUser"`
	Summary        summary           `json:"summary"`
	Cohorts        []cohortSummary   `json:"cohorts"`
	PoolSummaries  []poolSummary     `json:"poolSummaries"`
	Validation     validation        `json:"validation"`
	UserTrajectory string            `json:"userTrajectory"`
	CohortFiles    map[string]string `json:"cohortFiles"`
}

type summary struct {
	Attempts                        int     `json:"attempts"`
	Settled                         int     `json:"settled"`
	Skipped                         int     `json:"skipped"`
	HumanMatches                    int     `json:"humanMatches"`
	RobotMatches                    int     `json:"robotMatches"`
	Wins                            int     `json:"wins"`
	Losses                          int     `json:"losses"`
	Draws                           int     `json:"draws"`
	ForceHumanWin                   int     `json:"forceHumanWin"`
	ForceHumanLose                  int     `json:"forceHumanLose"`
	NewUserPositiveFeedback         int     `json:"newUserPositiveFeedback"`
	NewUserLoseStreakProtection     int     `json:"newUserLoseStreakProtection"`
	PoolBlackSkips                  int     `json:"poolBlackSkips"`
	RiskBlocked                     int     `json:"riskBlocked"`
	HighStakeProtectionHits         int     `json:"highStakeProtectionHits"`
	RockDrawProtectionConsumptions  int     `json:"rockDrawProtectionConsumptions"`
	ProtectionConsumedRounds        int     `json:"protectionConsumedRounds"`
	ProtectionSubsidyCoin           int64   `json:"protectionSubsidyCoin"`
	TotalUserNetCoin                int64   `json:"totalUserNetCoin"`
	AverageUserNetCoin              float64 `json:"averageUserNetCoin"`
	UsersPositive                   int     `json:"usersPositive"`
	UsersNegative                   int     `json:"usersNegative"`
	OpenRate                        float64 `json:"openRate"`
	RobotOpenRate                   float64 `json:"robotOpenRate"`
	NetFromWins                     int64   `json:"netFromWins"`
	NetFromLosses                   int64   `json:"netFromLosses"`
	EffectiveRobotGameCount         int     `json:"effectiveRobotGameCount"`
	EffectiveRobotDrawExcludedCount int     `json:"effectiveRobotDrawExcludedCount"`
}

type cohortSummary struct {
	Cohort                          string  `json:"cohort"`
	Users                           int     `json:"users"`
	Rounds                          int     `json:"rounds"`
	Settled                         int     `json:"settled"`
	Skipped                         int     `json:"skipped"`
	HumanMatches                    int     `json:"humanMatches"`
	RobotMatches                    int     `json:"robotMatches"`
	Wins                            int     `json:"wins"`
	Losses                          int     `json:"losses"`
	Draws                           int     `json:"draws"`
	ForceHumanWin                   int     `json:"forceHumanWin"`
	ForceHumanLose                  int     `json:"forceHumanLose"`
	NewUserPositiveFeedback         int     `json:"newUserPositiveFeedback"`
	NewUserLoseStreakProtection     int     `json:"newUserLoseStreakProtection"`
	PoolBlackSkips                  int     `json:"poolBlackSkips"`
	RiskBlocked                     int     `json:"riskBlocked"`
	HighStakeProtectionHits         int     `json:"highStakeProtectionHits"`
	RockDrawProtectionConsumptions  int     `json:"rockDrawProtectionConsumptions"`
	ProtectionConsumedRounds        int     `json:"protectionConsumedRounds"`
	ProtectionSubsidyCoin           int64   `json:"protectionSubsidyCoin"`
	TotalUserNetCoin                int64   `json:"totalUserNetCoin"`
	AverageUserNetCoin              float64 `json:"averageUserNetCoin"`
	UsersPositive                   int     `json:"usersPositive"`
	UsersNegative                   int     `json:"usersNegative"`
	OpenRate                        float64 `json:"openRate"`
	RobotOpenRate                   float64 `json:"robotOpenRate"`
	NetFromWins                     int64   `json:"netFromWins"`
	NetFromLosses                   int64   `json:"netFromLosses"`
	EffectiveRobotGameCount         int     `json:"effectiveRobotGameCount"`
	EffectiveRobotDrawExcludedCount int     `json:"effectiveRobotDrawExcludedCount"`
}

type poolSummary struct {
	GameID        string `json:"gameId"`
	StakeCoin     int64  `json:"stakeCoin"`
	InitialCoin   int64  `json:"initialCoin"`
	BalanceCoin   int64  `json:"balanceCoin"`
	InCoin        int64  `json:"inCoin"`
	OutCoin       int64  `json:"outCoin"`
	PoolLevel     string `json:"poolLevel"`
	AccountingOK  bool   `json:"accountingOk"`
	AccountedCoin int64  `json:"accountedCoin"`
}

type validation struct {
	Passed                       bool     `json:"passed"`
	FailedChecks                 []string `json:"failedChecks"`
	AdminConfigAPICalled         bool     `json:"adminConfigApiCalled"`
	AdminReadbackAPICalled       bool     `json:"adminReadbackApiCalled"`
	AdminReadbackOK              bool     `json:"adminReadbackOk"`
	StakePoolAPICalled           bool     `json:"stakePoolApiCalled"`
	AppMatchAPICalled            bool     `json:"appMatchApiCalled"`
	AppCreateJoinAPICalled       bool     `json:"appCreateJoinApiCalled"`
	AppRollAPICalled             bool     `json:"appRollApiCalled"`
	EveryUserTrajectoryWritten   bool     `json:"everyUserTrajectoryWritten"`
	NoFixedSeed                  bool     `json:"noFixedSeed"`
	OldFixedWinRateReferenceZero bool     `json:"oldFixedWinRateReferenceZero"`
}

type userState struct {
	UserID          int64
	Cohort          string
	InitialCoin     int64
	RiskScore       int32
	RiskTier        string
	RegisteredHours int32
	Rounds          []roundTrace
}

type userRecord struct {
	UserID          int64        `json:"userId"`
	Cohort          string       `json:"cohort"`
	RiskScore       int32        `json:"riskScore"`
	RiskTier        string       `json:"riskTier"`
	RegisteredHours int32        `json:"registeredHours"`
	InitialCoin     int64        `json:"initialCoin"`
	FinalCoin       int64        `json:"finalCoin"`
	NetCoin         int64        `json:"netCoin"`
	Rounds          []roundTrace `json:"rounds"`
}

type roundTrace struct {
	RoundIndex                int    `json:"roundIndex"`
	Cohort                    string `json:"cohort"`
	MatchID                   string `json:"matchId"`
	GameID                    string `json:"gameId"`
	StakeCoin                 int64  `json:"stakeCoin"`
	RequestedMatchMode        string `json:"requestedMatchMode"`
	ActualMatchMode           string `json:"actualMatchMode"`
	Settled                   bool   `json:"settled"`
	Result                    string `json:"result"`
	UserNetCoin               int64  `json:"userNetCoin"`
	UserCoinBefore            int64  `json:"userCoinBefore"`
	UserCoinAfter             int64  `json:"userCoinAfter"`
	StrategyCode              string `json:"strategyCode"`
	Decision                  string `json:"decision"`
	ReasonCode                string `json:"reasonCode"`
	ForceResult               string `json:"forceResult"`
	PoolLevelBefore           string `json:"poolLevelBefore"`
	PoolBalanceBefore         int64  `json:"poolBalanceBefore"`
	PoolBalanceAfter          int64  `json:"poolBalanceAfter"`
	PoolDeltaDirection        string `json:"poolDeltaDirection"`
	PoolDeltaCoin             int64  `json:"poolDeltaCoin"`
	ProtectionConsumed        bool   `json:"protectionConsumed"`
	ProtectionSubsidyCoin     int64  `json:"protectionSubsidyCoin"`
	EffectiveRobotGameCounted bool   `json:"effectiveRobotGameCounted"`
	DrawExcludedFromEffective bool   `json:"drawExcludedFromEffective"`
	ValidationNote            string `json:"validationNote,omitempty"`
}

type apiHarness struct {
	server *gamegrpc.Server
	repo   *memoryRepo
	wallet *memoryWallet
	robots *memoryRobotRegistry
}

func main() {
	users := flag.Int("users", 1000, "user count; must be even for 500/500 cohorts")
	rounds := flag.Int("rounds", 60, "rounds per user")
	outDir := flag.String("out", "docs/selfgame-simulation", "output directory")
	flag.Parse()
	if *users <= 0 || *users%2 != 0 {
		fatalf("users must be a positive even number")
	}
	if *rounds <= 0 {
		fatalf("rounds must be positive")
	}
	if err := os.MkdirAll(*outDir, 0o755); err != nil {
		fatalf("create output dir: %v", err)
	}

	now := time.Now()
	runID := fmt.Sprintf("selfgame_api_%s_%s_r%d", now.Format("20060102_150405"), randomHex(4), *rounds)
	h := newHarness()
	adminReadbackOK := configureViaAdminAPI(context.Background(), h)

	allPath := filepath.Join(*outDir, runID+"_users.jsonl")
	robotPath := filepath.Join(*outDir, runID+"_robot_only_users.jsonl")
	mixedPath := filepath.Join(*outDir, runID+"_robot_human_mixed_users.jsonl")
	allFile := mustCreate(allPath)
	defer allFile.Close()
	robotFile := mustCreate(robotPath)
	defer robotFile.Close()
	mixedFile := mustCreate(mixedPath)
	defer mixedFile.Close()

	rep := report{
		RunID:         runID,
		GeneratedAtMS: now.UnixMilli(),
		RandomSource:  "crypto/rand; no fixed seed; every random branch reads system randomness",
		InterfaceMode: "admin gRPC config -> game app gRPC match/create/join/get/roll; in-memory wallet/robot/repository test doubles",
		UserCount:     *users,
		RoundsPerUser: *rounds,
		CohortFiles: map[string]string{
			cohortRobotOnly: robotPath,
			cohortMixed:     mixedPath,
		},
		UserTrajectory: allPath,
	}
	rep.Validation.AdminConfigAPICalled = true
	rep.Validation.AdminReadbackAPICalled = true
	rep.Validation.AdminReadbackOK = adminReadbackOK
	rep.Validation.StakePoolAPICalled = true
	rep.Validation.NoFixedSeed = true
	rep.Validation.OldFixedWinRateReferenceZero = true

	userOrder := shuffledRange(*users)
	for _, index := range userOrder {
		user := randomScenarioUser(index, *users)
		h.repo.setRisk(user.UserID, selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: user.RiskScore}))
		h.wallet.setBalance(user.UserID, user.InitialCoin)
		for round := 1; round <= *rounds; round++ {
			trace := playRoundViaAPI(context.Background(), h, user, round, *rounds)
			user.Rounds = append(user.Rounds, trace)
			accumulateSummary(&rep.Summary, trace)
		}
		finalCoin := h.wallet.balance(user.UserID)
		record := userRecord{
			UserID:          user.UserID,
			Cohort:          user.Cohort,
			RiskScore:       user.RiskScore,
			RiskTier:        user.RiskTier,
			RegisteredHours: user.RegisteredHours,
			InitialCoin:     user.InitialCoin,
			FinalCoin:       finalCoin,
			NetCoin:         finalCoin - user.InitialCoin,
			Rounds:          user.Rounds,
		}
		if err := writeJSONLine(allFile, record); err != nil {
			fatalf("write user trajectory: %v", err)
		}
		if user.Cohort == cohortRobotOnly {
			if err := writeJSONLine(robotFile, record); err != nil {
				fatalf("write robot cohort trajectory: %v", err)
			}
		} else {
			if err := writeJSONLine(mixedFile, record); err != nil {
				fatalf("write mixed cohort trajectory: %v", err)
			}
		}
		accumulateUserResult(&rep.Summary, record)
	}
	rep.Summary.AverageUserNetCoin = ratioInt64(rep.Summary.TotalUserNetCoin, int64(*users))
	rep.Summary.OpenRate = ratio(rep.Summary.Settled, rep.Summary.Attempts)
	rep.Summary.RobotOpenRate = ratio(rep.Summary.RobotMatches, rep.Summary.RobotMatches+rep.Summary.PoolBlackSkips)
	rep.Cohorts = summarizeCohorts(allPath)
	rep.PoolSummaries = h.repo.poolSummaries()
	rep.Validation.AppMatchAPICalled = h.repo.apiCalls.match > 0
	rep.Validation.AppCreateJoinAPICalled = h.repo.apiCalls.create > 0 && h.repo.apiCalls.join > 0
	rep.Validation.AppRollAPICalled = h.repo.apiCalls.roll > 0
	rep.Validation.EveryUserTrajectoryWritten = countJSONLines(allPath) == *users
	rep.Validation.Passed = len(rep.Validation.FailedChecks) == 0 &&
		rep.Validation.AdminConfigAPICalled &&
		rep.Validation.AdminReadbackAPICalled &&
		rep.Validation.AdminReadbackOK &&
		rep.Validation.StakePoolAPICalled &&
		rep.Validation.AppMatchAPICalled &&
		rep.Validation.AppCreateJoinAPICalled &&
		rep.Validation.AppRollAPICalled &&
		rep.Validation.EveryUserTrajectoryWritten
	if !rep.Validation.AppMatchAPICalled {
		rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "MatchDice was not called")
	}
	if !rep.Validation.AdminReadbackOK {
		rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "admin config readback did not match written config")
	}
	if !rep.Validation.AppCreateJoinAPICalled {
		rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "CreateDiceMatch/JoinDiceMatch were not called")
	}
	if !rep.Validation.AppRollAPICalled {
		rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "RollDiceMatch was not called")
	}
	if !rep.Validation.EveryUserTrajectoryWritten {
		rep.Validation.FailedChecks = append(rep.Validation.FailedChecks, "not every user trajectory was written")
	}
	rep.Validation.Passed = len(rep.Validation.FailedChecks) == 0

	summaryJSON := filepath.Join(*outDir, runID+"_summary.json")
	summaryMD := filepath.Join(*outDir, runID+"_summary.md")
	writeJSONFile(summaryJSON, rep)
	writeSummary(summaryMD, rep)
	fmt.Printf("run_id=%s\nsummary_json=%s\nsummary_md=%s\nusers_jsonl=%s\nrobot_users_jsonl=%s\nmixed_users_jsonl=%s\npassed=%v\n",
		runID, summaryJSON, summaryMD, allPath, robotPath, mixedPath, rep.Validation.Passed)
}

func newHarness() *apiHarness {
	repo := newMemoryRepo()
	wallet := &memoryWallet{balances: map[int64]int64{}}
	robots := newMemoryRobotRegistry(1000)
	diceSvc := diceservice.New(diceservice.Config{FeeBPS: feeBPS, MaxPlayers: 2}, repo, wallet, diceservice.WithRobotRegistry(robots))
	return &apiHarness{
		server: gamegrpc.NewServer(nil, diceSvc),
		repo:   repo,
		wallet: wallet,
		robots: robots,
	}
}

func configureViaAdminAPI(ctx context.Context, h *apiHarness) bool {
	for _, gameID := range []string{dicedomain.DefaultGameID, dicedomain.SelfGameIDRock} {
		// The simulator writes configuration through the same GameAdminService RPCs used by admin-server.
		// This keeps strategy input shape identical to production RPC contracts instead of mutating the
		// repository directly and accidentally bypassing proto/domain normalization.
		_, err := h.server.UpdateDiceConfig(ctx, &gamev1.UpdateDiceConfigRequest{
			Meta: meta("admin_config_"+gameID, 1),
			Config: &gamev1.DiceConfig{
				GameId:           gameID,
				Status:           dicedomain.ConfigStatusActive,
				FeeBps:           int32(feeBPS),
				PoolBps:          int32(poolBPS),
				MinPlayers:       2,
				MaxPlayers:       2,
				RobotEnabled:     true,
				RobotMatchWaitMs: 1,
				StakeOptions: []*gamev1.DiceStakeOption{
					{StakeCoin: 1000, Enabled: true, SortOrder: 10},
					{StakeCoin: 5000, Enabled: true, SortOrder: 20},
					{StakeCoin: 40000, Enabled: true, SortOrder: 30},
					{StakeCoin: 100000, Enabled: true, SortOrder: 40},
				},
			},
		})
		if err != nil {
			fatalf("admin UpdateDiceConfig %s: %v", gameID, err)
		}
		policy := selfgame.DefaultNewUserPolicy(appCode, gameID, time.Now().UnixMilli())
		_, err = h.server.UpdateSelfGameNewUserPolicy(ctx, &gamev1.UpdateSelfGameNewUserPolicyRequest{
			Meta: meta("admin_policy_"+gameID, 1),
			Policy: &gamev1.SelfGameNewUserPolicy{
				GameId:                          gameID,
				Enabled:                         policy.Enabled,
				ProtectionRounds:                policy.ProtectionRounds,
				ProtectionHours:                 policy.ProtectionHours,
				MaxStakeCoin:                    policy.MaxStakeCoin,
				LifetimeSubsidyQuotaCoin:        policy.LifetimeSubsidyQuotaCoin,
				Day1SubsidyQuotaCoin:            policy.Day1SubsidyQuotaCoin,
				SingleRoundSubsidyCapCoin:       policy.SingleRoundSubsidyCap,
				StrategyLevel:                   policy.StrategyLevel,
				LoseStreakProtectionEnabled:     policy.LoseStreakProtectionEnabled,
				LoseStreakTrigger:               policy.LoseStreakTrigger,
				NormalPhaseTargetWinRatePercent: policy.NormalPhaseTargetWinRatePercent,
			},
		})
		if err != nil {
			fatalf("admin UpdateSelfGameNewUserPolicy %s: %v", gameID, err)
		}
		for _, stake := range []int64{1000, 5000, 40000, 100000} {
			threshold := selfgame.DefaultStakePool(appCode, gameID, stake, time.Now().UnixMilli())
			initial := initialPoolCoin(gameID, stake)
			_, err = h.server.UpdateSelfGameStakePool(ctx, &gamev1.UpdateSelfGameStakePoolRequest{
				Meta: meta(fmt.Sprintf("admin_pool_cfg_%s_%d", gameID, stake), 1),
				Pool: &gamev1.SelfGameStakePool{
					GameId:             gameID,
					StakeCoin:          stake,
					TargetBalanceCoin:  threshold.TargetBalanceCoin,
					SafeBalanceCoin:    threshold.SafeBalanceCoin,
					WarningBalanceCoin: threshold.WarningBalanceCoin,
					DangerBalanceCoin:  threshold.DangerBalanceCoin,
					Status:             "active",
				},
			})
			if err != nil {
				fatalf("admin UpdateSelfGameStakePool %s/%d: %v", gameID, stake, err)
			}
			_, err = h.server.AdjustSelfGameStakePool(ctx, &gamev1.AdjustSelfGameStakePoolRequest{
				Meta:       meta(fmt.Sprintf("admin_pool_in_%s_%d", gameID, stake), 1),
				GameId:     gameID,
				StakeCoin:  stake,
				Direction:  dicedomain.PoolDirectionIn,
				AmountCoin: initial,
				Reason:     "api_sim_initial_pool",
			})
			if err != nil {
				fatalf("admin AdjustSelfGameStakePool %s/%d: %v", gameID, stake, err)
			}
		}
	}
	return verifyAdminConfigReadback(ctx, h)
}

func verifyAdminConfigReadback(ctx context.Context, h *apiHarness) bool {
	// After writing config, immediately read it back through admin list/get APIs. A direct repository check
	// would only prove the in-memory test double changed; these RPC calls prove the admin-facing server
	// conversion layer can return the same strategy inputs that runtime matching will later consume.
	listResp, err := h.server.ListSelfGames(ctx, &gamev1.ListSelfGamesRequest{Meta: meta("admin_readback_self_games", 1)})
	if err != nil {
		return false
	}
	seenGames := map[string]bool{}
	for _, item := range listResp.GetGames() {
		if item.GetGameId() == dicedomain.DefaultGameID || item.GetGameId() == dicedomain.SelfGameIDRock {
			seenGames[item.GetGameId()] = item.GetRobotEnabled() && item.GetStatus() == dicedomain.ConfigStatusActive && len(item.GetStakeOptions()) == 4
		}
	}
	for _, gameID := range []string{dicedomain.DefaultGameID, dicedomain.SelfGameIDRock} {
		if !seenGames[gameID] {
			return false
		}
		policyResp, err := h.server.GetSelfGameNewUserPolicy(ctx, &gamev1.GetSelfGameNewUserPolicyRequest{
			Meta:   meta("admin_readback_policy_"+gameID, 1),
			GameId: gameID,
		})
		if err != nil {
			return false
		}
		policy := policyResp.GetPolicy()
		if policy == nil || !policy.GetEnabled() || policy.GetProtectionRounds() <= 0 || policy.GetLoseStreakTrigger() <= 0 || policy.GetNormalPhaseTargetWinRatePercent() != selfgame.DefaultNormalPhaseTargetWinRatePercent {
			return false
		}
		poolsResp, err := h.server.ListSelfGameStakePools(ctx, &gamev1.ListSelfGameStakePoolsRequest{
			Meta:   meta("admin_readback_pools_"+gameID, 1),
			GameId: gameID,
		})
		if err != nil {
			return false
		}
		seenStakes := map[int64]bool{}
		for _, pool := range poolsResp.GetPools() {
			seenStakes[pool.GetStakeCoin()] = pool.GetStatus() == "active" &&
				pool.GetTargetBalanceCoin() >= pool.GetSafeBalanceCoin() &&
				pool.GetSafeBalanceCoin() >= pool.GetWarningBalanceCoin() &&
				pool.GetWarningBalanceCoin() >= pool.GetDangerBalanceCoin()
		}
		for _, stake := range []int64{1000, 5000, 40000, 100000} {
			if !seenStakes[stake] {
				return false
			}
		}
	}
	return true
}

func playRoundViaAPI(ctx context.Context, h *apiHarness, user *userState, round int, roundsPerUser int) roundTrace {
	gameID := weightedString([]weightedStringValue{{"dice", 55}, {"rock", 45}})
	stake := weightedInt64([]weightedInt64Value{{1000, 48}, {5000, 30}, {40000, 15}, {100000, 7}})
	requestedMode := matchModeRobot
	if user.Cohort == cohortMixed {
		requestedMode = weightedString([]weightedStringValue{{matchModeRobot, 65}, {matchModeHuman, 35}})
	}
	trace := roundTrace{
		RoundIndex:         round,
		Cohort:             user.Cohort,
		GameID:             gameID,
		StakeCoin:          stake,
		RequestedMatchMode: requestedMode,
		ActualMatchMode:    requestedMode,
		UserCoinBefore:     h.wallet.balance(user.UserID),
	}
	trace.PoolBalanceBefore, trace.PoolLevelBefore = h.repo.poolSnapshot(gameID, stake)
	if requestedMode == matchModeHuman {
		return playHumanRoundViaAPI(ctx, h, user, trace)
	}
	return playRobotRoundViaAPI(ctx, h, user, trace)
}

func playRobotRoundViaAPI(ctx context.Context, h *apiHarness, user *userState, trace roundTrace) roundTrace {
	reqID := fmt.Sprintf("api_robot_%d_%d_%s", user.UserID, trace.RoundIndex, randomHex(3))
	matchResp, err := h.server.MatchDice(ctx, &gamev1.MatchDiceRequest{
		Meta:       meta(reqID, user.UserID),
		UserId:     user.UserID,
		GameId:     trace.GameID,
		StakeCoin:  trace.StakeCoin,
		RpsGesture: randomHumanGesture(trace.GameID),
	})
	h.repo.apiCalls.match++
	if err != nil {
		trace.Result = resultSkipped
		trace.ActualMatchMode = resultSkipped
		trace.ValidationNote = "match_error:" + err.Error()
		trace.UserCoinAfter = h.wallet.balance(user.UserID)
		return trace
	}
	trace.MatchID = matchResp.GetMatch().GetMatchId()
	getResp, err := h.server.GetDiceMatch(ctx, &gamev1.GetDiceMatchRequest{
		Meta:    meta(reqID+"_get", user.UserID),
		UserId:  user.UserID,
		GameId:  trace.GameID,
		MatchId: trace.MatchID,
	})
	if err != nil {
		trace.Result = resultSkipped
		trace.ActualMatchMode = resultSkipped
		trace.ValidationNote = "get_error:" + err.Error()
		trace.UserCoinAfter = h.wallet.balance(user.UserID)
		return trace
	}
	match := getResp.GetMatch()
	log := h.repo.strategyLog(trace.MatchID, user.UserID)
	trace.StrategyCode = log.StrategyCode
	trace.Decision = log.Decision
	trace.ReasonCode = log.ReasonCode
	trace.ForceResult = log.ForceResult
	if match.GetStatus() != dicedomain.MatchStatusReady || match.GetMatchMode() != dicedomain.MatchModeRobot {
		trace.Result = resultSkipped
		trace.ActualMatchMode = resultSkipped
		if trace.ReasonCode == "POOL_BLACK" {
			trace.ValidationNote = "black pool skipped robot attachment"
		} else {
			trace.ValidationNote = "robot_not_attached"
		}
		trace.UserCoinAfter = h.wallet.balance(user.UserID)
		trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin)
		return trace
	}
	return rollUntilFinal(ctx, h, user, trace)
}

func playHumanRoundViaAPI(ctx context.Context, h *apiHarness, user *userState, trace roundTrace) roundTrace {
	opponentID := int64(900000000 + user.UserID*1000 + int64(trace.RoundIndex))
	h.wallet.setBalance(opponentID, 1_000_000_000)
	reqID := fmt.Sprintf("api_human_%d_%d_%s", user.UserID, trace.RoundIndex, randomHex(3))
	createResp, err := h.server.CreateDiceMatch(ctx, &gamev1.CreateDiceMatchRequest{
		Meta:       meta(reqID+"_create", user.UserID),
		UserId:     user.UserID,
		GameId:     trace.GameID,
		StakeCoin:  trace.StakeCoin,
		MinPlayers: 2,
		MaxPlayers: 2,
		RpsGesture: randomHumanGesture(trace.GameID),
	})
	h.repo.apiCalls.create++
	if err != nil {
		trace.Result = resultSkipped
		trace.ActualMatchMode = resultSkipped
		trace.ValidationNote = "create_error:" + err.Error()
		trace.UserCoinAfter = h.wallet.balance(user.UserID)
		return trace
	}
	trace.MatchID = createResp.GetMatch().GetMatchId()
	_, err = h.server.JoinDiceMatch(ctx, &gamev1.JoinDiceMatchRequest{
		Meta:       meta(reqID+"_join", opponentID),
		UserId:     opponentID,
		GameId:     trace.GameID,
		MatchId:    trace.MatchID,
		RpsGesture: randomHumanGesture(trace.GameID),
	})
	h.repo.apiCalls.join++
	if err != nil {
		trace.Result = resultSkipped
		trace.ActualMatchMode = resultSkipped
		trace.ValidationNote = "join_error:" + err.Error()
		trace.UserCoinAfter = h.wallet.balance(user.UserID)
		return trace
	}
	return rollUntilFinal(ctx, h, user, trace)
}

func rollUntilFinal(ctx context.Context, h *apiHarness, user *userState, trace roundTrace) roundTrace {
	beforePool, _ := h.repo.poolSnapshot(trace.GameID, trace.StakeCoin)
	beforeCoin := h.wallet.balance(user.UserID)
	for attempt := 0; attempt < 8; attempt++ {
		rollResp, err := h.server.RollDiceMatch(ctx, &gamev1.RollDiceMatchRequest{
			Meta:       meta(fmt.Sprintf("api_roll_%d_%d_%d", user.UserID, trace.RoundIndex, attempt), user.UserID),
			UserId:     user.UserID,
			GameId:     trace.GameID,
			MatchId:    trace.MatchID,
			RpsGesture: randomHumanGesture(trace.GameID),
		})
		h.repo.apiCalls.roll++
		if err != nil {
			trace.Result = resultSkipped
			trace.ActualMatchMode = resultSkipped
			trace.ValidationNote = "roll_error:" + err.Error()
			trace.UserCoinAfter = h.wallet.balance(user.UserID)
			trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin)
			return trace
		}
		match := rollResp.GetMatch()
		if match.GetStatus() == dicedomain.MatchStatusSettled {
			trace.Settled = true
			trace.Result = userResult(match, user.UserID)
			trace.UserCoinAfter = h.wallet.balance(user.UserID)
			trace.UserNetCoin = trace.UserCoinAfter - beforeCoin
			trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin)
			trace.PoolDeltaCoin = abs64(trace.PoolBalanceAfter - beforePool)
			if trace.PoolBalanceAfter > beforePool {
				trace.PoolDeltaDirection = dicedomain.PoolDirectionIn
			}
			if trace.PoolBalanceAfter < beforePool {
				trace.PoolDeltaDirection = dicedomain.PoolDirectionOut
			}
			if trace.ActualMatchMode == matchModeRobot && trace.Result != resultDraw {
				trace.EffectiveRobotGameCounted = true
			}
			if trace.ActualMatchMode == matchModeRobot && trace.Result == resultDraw {
				trace.DrawExcludedFromEffective = true
			}
			event := h.repo.protectionEvent(trace.MatchID, user.UserID)
			if event.MatchID != "" {
				trace.ProtectionConsumed = true
				trace.ProtectionSubsidyCoin = event.LifetimeSubsidyCoinDelta
			}
			return trace
		}
	}
	trace.Result = resultSkipped
	trace.ActualMatchMode = resultSkipped
	trace.ValidationNote = "roll_attempt_limit"
	trace.UserCoinAfter = h.wallet.balance(user.UserID)
	trace.PoolBalanceAfter, _ = h.repo.poolSnapshot(trace.GameID, trace.StakeCoin)
	return trace
}

type memoryRepo struct {
	configs                map[string]dicedomain.Config
	policies               map[string]selfgame.NewUserPolicy
	pools                  map[string]selfgame.StakePool
	poolInitial            map[string]int64
	poolIn                 map[string]int64
	poolOut                map[string]int64
	matches                map[string]dicedomain.Match
	orders                 map[string]gamedomain.GameOrder
	stats                  map[string]selfgame.UserStats
	protection             map[string]selfgame.ProtectionState
	protectionEventByMatch map[string]selfgame.ProtectionEvent
	risk                   map[int64]selfgame.RiskResult
	logs                   map[string]selfgame.StrategyLog
	apiCalls               struct{ match, create, join, roll int }
}

func newMemoryRepo() *memoryRepo {
	return &memoryRepo{
		configs:                map[string]dicedomain.Config{},
		policies:               map[string]selfgame.NewUserPolicy{},
		pools:                  map[string]selfgame.StakePool{},
		poolInitial:            map[string]int64{},
		poolIn:                 map[string]int64{},
		poolOut:                map[string]int64{},
		matches:                map[string]dicedomain.Match{},
		orders:                 map[string]gamedomain.GameOrder{},
		stats:                  map[string]selfgame.UserStats{},
		protection:             map[string]selfgame.ProtectionState{},
		protectionEventByMatch: map[string]selfgame.ProtectionEvent{},
		risk:                   map[int64]selfgame.RiskResult{},
		logs:                   map[string]selfgame.StrategyLog{},
	}
}

func (r *memoryRepo) GetLaunchableGame(_ context.Context, app string, gameID string) (gamedomain.LaunchableGame, error) {
	gameID = strings.TrimSpace(gameID)
	return gamedomain.LaunchableGame{
		CatalogItem: gamedomain.CatalogItem{
			AppCode:        appcode.Normalize(app),
			GameID:         gameID,
			PlatformCode:   dicedomain.DefaultPlatformCode,
			ProviderGameID: gameID,
			GameName:       gameID,
			Status:         gamedomain.StatusActive,
		},
		PlatformName:   "self-game",
		PlatformStatus: gamedomain.StatusActive,
	}, nil
}

func (r *memoryRepo) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
	if existing, ok := r.orders[order.OrderID]; ok {
		return existing, true, nil
	}
	r.orders[order.OrderID] = order
	return order, false, nil
}

func (r *memoryRepo) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMS int64) error {
	order := r.orders[orderID]
	order.Status = gamedomain.OrderStatusSucceeded
	order.WalletTransactionID = walletTransactionID
	order.WalletBalanceAfter = balanceAfter
	order.UpdatedAtMS = nowMS
	r.orders[orderID] = order
	return nil
}

func (r *memoryRepo) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMS int64) error {
	order := r.orders[orderID]
	order.Status = status
	order.FailureCode = code
	order.FailureMessage = message
	order.UpdatedAtMS = nowMS
	r.orders[orderID] = order
	return nil
}

func (r *memoryRepo) CreateDiceMatch(_ context.Context, match dicedomain.Match) (dicedomain.Match, error) {
	// 接口模拟不睡眠等待机器人补位；这里把等待局时间向前拨一点，只影响测试桩内存事实，不改变 service 策略代码。
	match.CreatedAtMS -= 10
	match.UpdatedAtMS = match.CreatedAtMS
	match.JoinDeadlineMS = match.CreatedAtMS + 60_000
	r.matches[match.MatchID] = cloneMatch(match)
	return cloneMatch(match), nil
}

func (r *memoryRepo) JoinDiceMatch(_ context.Context, _ string, matchID string, userID int64, rpsGesture string, nowMS int64) (dicedomain.Match, error) {
	match := r.matches[strings.TrimSpace(matchID)]
	if match.MatchID == "" {
		return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
	}
	if !joinable(match.Status) {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not joinable")
	}
	for _, participant := range match.Participants {
		if participant.UserID == userID {
			return cloneMatch(match), nil
		}
	}
	match.Participants = append(match.Participants, dicedomain.Participant{
		AppCode:         match.AppCode,
		MatchID:         match.MatchID,
		UserID:          userID,
		ParticipantType: dicedomain.ParticipantTypeUser,
		SeatNo:          int32(len(match.Participants) + 1),
		Status:          dicedomain.ParticipantStatusJoined,
		StakeCoin:       match.StakeCoin,
		RPSGesture:      strings.TrimSpace(rpsGesture),
		JoinedAtMS:      nowMS,
		UpdatedAtMS:     nowMS,
	})
	match.CurrentPlayers = int32(len(match.Participants))
	match.Status = dicedomain.MatchStatusJoining
	if match.CurrentPlayers >= match.MinPlayers {
		match.Status = dicedomain.MatchStatusReady
		match.ReadyAtMS = nowMS
	}
	match.UpdatedAtMS = nowMS
	r.matches[match.MatchID] = cloneMatch(match)
	return cloneMatch(match), nil
}

func (r *memoryRepo) GetDiceMatch(_ context.Context, _ string, matchID string) (dicedomain.Match, error) {
	match := r.matches[strings.TrimSpace(matchID)]
	if match.MatchID == "" {
		return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
	}
	return cloneMatch(match), nil
}

func (r *memoryRepo) ClaimDiceMatchForRoll(_ context.Context, _ string, matchID string, userID int64, nowMS int64) (dicedomain.Match, error) {
	match := r.matches[strings.TrimSpace(matchID)]
	if match.MatchID == "" {
		return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
	}
	if !hasParticipant(match.Participants, userID) {
		return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
	}
	if match.Result == dicedomain.ParticipantResultDraw {
		match.Result = ""
		for index := range match.Participants {
			match.Participants[index].DicePoints = nil
			match.Participants[index].Result = ""
			match.Participants[index].PayoutCoin = 0
			match.Participants[index].UpdatedAtMS = nowMS
		}
	}
	match.Status = dicedomain.MatchStatusSettling
	match.UpdatedAtMS = nowMS
	r.matches[match.MatchID] = cloneMatch(match)
	return cloneMatch(match), nil
}

func (r *memoryRepo) SaveDiceRolls(_ context.Context, match dicedomain.Match, nowMS int64) (dicedomain.Match, error) {
	current := r.matches[match.MatchID]
	for _, source := range match.Participants {
		for index := range current.Participants {
			if current.Participants[index].UserID == source.UserID {
				current.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...)
				current.Participants[index].RPSGesture = source.RPSGesture
				current.Participants[index].Result = source.Result
				current.Participants[index].PayoutCoin = source.PayoutCoin
				current.Participants[index].UpdatedAtMS = nowMS
			}
		}
	}
	current.Status = dicedomain.MatchStatusPayoutApplying
	current.Result = match.Result
	current.UpdatedAtMS = nowMS
	r.matches[current.MatchID] = cloneMatch(current)
	return cloneMatch(current), nil
}

func (r *memoryRepo) SaveDiceDrawForReroll(_ context.Context, match dicedomain.Match, nowMS int64) (dicedomain.Match, error) {
	current := r.matches[match.MatchID]
	for _, source := range match.Participants {
		for index := range current.Participants {
			if current.Participants[index].UserID == source.UserID {
				current.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...)
				current.Participants[index].Result = dicedomain.ParticipantResultDraw
				current.Participants[index].UpdatedAtMS = nowMS
			}
		}
	}
	current.Status = dicedomain.MatchStatusReady
	current.Result = dicedomain.ParticipantResultDraw
	current.RoundNo++
	current.UpdatedAtMS = nowMS
	r.matches[current.MatchID] = cloneMatch(current)
	return cloneMatch(current), nil
}

func (r *memoryRepo) MarkDiceParticipantDebitSucceeded(_ context.Context, _ string, matchID string, userID int64, orderID string, balanceAfter int64, nowMS int64) error {
	return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) {
		p.Status = dicedomain.ParticipantStatusDebitSucceeded
		p.DebitOrderID = orderID
		p.BalanceAfter = balanceAfter
	})
}

func (r *memoryRepo) MarkDiceParticipantDebitFailed(_ context.Context, _ string, matchID string, userID int64, orderID string, nowMS int64) error {
	return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) {
		p.Status = dicedomain.ParticipantStatusDebitFailed
		p.DebitOrderID = orderID
	})
}

func (r *memoryRepo) MarkDiceParticipantPayoutSucceeded(_ context.Context, _ string, matchID string, userID int64, orderID string, balanceAfter int64, nowMS int64) error {
	return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) {
		p.Status = dicedomain.ParticipantStatusPayoutSucceeded
		p.PayoutOrderID = orderID
		p.BalanceAfter = balanceAfter
	})
}

func (r *memoryRepo) MarkDiceParticipantRefundSucceeded(_ context.Context, _ string, matchID string, userID int64, orderID string, balanceAfter int64, nowMS int64) error {
	return r.updateParticipant(matchID, userID, nowMS, func(p *dicedomain.Participant) {
		p.Status = dicedomain.ParticipantStatusRefundSucceeded
		p.RefundOrderID = orderID
		p.BalanceAfter = balanceAfter
	})
}

func (r *memoryRepo) MarkDiceMatchSettled(_ context.Context, _ string, matchID string, result string, nowMS int64) error {
	match := r.matches[strings.TrimSpace(matchID)]
	match.Status = dicedomain.MatchStatusSettled
	match.Result = result
	match.SettledAtMS = nowMS
	match.UpdatedAtMS = nowMS
	for index := range match.Participants {
		match.Participants[index].Status = dicedomain.ParticipantStatusSettled
		match.Participants[index].UpdatedAtMS = nowMS
	}
	r.matches[match.MatchID] = cloneMatch(match)
	r.applyStats(match)
	return nil
}

func (r *memoryRepo) MarkDiceMatchFailed(_ context.Context, _ string, matchID string, result string, nowMS int64) error {
	match := r.matches[strings.TrimSpace(matchID)]
	match.Status = dicedomain.MatchStatusFailed
	match.Result = result
	match.UpdatedAtMS = nowMS
	r.matches[match.MatchID] = cloneMatch(match)
	return nil
}

func (r *memoryRepo) GetDiceConfig(_ context.Context, app string, gameID string) (dicedomain.Config, error) {
	gameID = strings.TrimSpace(gameID)
	config, ok := r.configs[gameID]
	if !ok {
		return dicedomain.NormalizeConfig(dicedomain.Config{AppCode: appcode.Normalize(app), GameID: gameID})
	}
	config.PoolBalanceCoin = r.poolTotal(gameID)
	return config, nil
}

func (r *memoryRepo) ListSelfGameConfigs(_ context.Context, _ string) ([]dicedomain.Config, error) {
	items := make([]dicedomain.Config, 0, len(r.configs))
	for _, config := range r.configs {
		config.PoolBalanceCoin = r.poolTotal(config.GameID)
		items = append(items, config)
	}
	sort.Slice(items, func(i, j int) bool { return items[i].GameID < items[j].GameID })
	return items, nil
}

func (r *memoryRepo) UpsertDiceConfig(_ context.Context, config dicedomain.Config, nowMS int64) (dicedomain.Config, error) {
	normalized, err := dicedomain.NormalizeConfig(config)
	if err != nil {
		return dicedomain.Config{}, err
	}
	normalized.AppCode = appcode.Normalize(normalized.AppCode)
	normalized.CreatedAtMS = nowMS
	normalized.UpdatedAtMS = nowMS
	r.configs[normalized.GameID] = normalized
	for _, option := range normalized.StakeOptions {
		key := poolKey(normalized.GameID, option.StakeCoin)
		if _, ok := r.pools[key]; !ok {
			r.pools[key] = selfgame.DefaultStakePool(normalized.AppCode, normalized.GameID, option.StakeCoin, nowMS)
		}
	}
	return normalized, nil
}

func (r *memoryRepo) FindAndJoinWaitingDiceMatch(context.Context, string, string, int64, int64, string, int64) (dicedomain.Match, error) {
	return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found")
}

func (r *memoryRepo) JoinDiceRobot(_ context.Context, _ string, matchID string, robotUserID int64, forcedResult string, rpsGesture string, nowMS int64) (dicedomain.Match, error) {
	match := r.matches[strings.TrimSpace(matchID)]
	if match.MatchID == "" {
		return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
	}
	match.Participants = append(match.Participants, dicedomain.Participant{
		AppCode:         match.AppCode,
		MatchID:         match.MatchID,
		UserID:          robotUserID,
		ParticipantType: dicedomain.ParticipantTypeRobot,
		SeatNo:          int32(len(match.Participants) + 1),
		Status:          dicedomain.ParticipantStatusJoined,
		StakeCoin:       match.StakeCoin,
		RPSGesture:      strings.TrimSpace(rpsGesture),
		JoinedAtMS:      nowMS,
		UpdatedAtMS:     nowMS,
	})
	match.CurrentPlayers = int32(len(match.Participants))
	match.MatchMode = dicedomain.MatchModeRobot
	match.ForcedResult = strings.TrimSpace(forcedResult)
	match.Status = dicedomain.MatchStatusReady
	match.ReadyAtMS = nowMS
	match.UpdatedAtMS = nowMS
	r.matches[match.MatchID] = cloneMatch(match)
	return cloneMatch(match), nil
}

func (r *memoryRepo) CancelDiceMatch(_ context.Context, _ string, matchID string, _ int64, nowMS int64) (dicedomain.Match, error) {
	match := r.matches[strings.TrimSpace(matchID)]
	match.Status = dicedomain.MatchStatusCanceled
	match.CanceledAtMS = nowMS
	match.UpdatedAtMS = nowMS
	r.matches[match.MatchID] = cloneMatch(match)
	return cloneMatch(match), nil
}

func (r *memoryRepo) PickActiveDiceRobot(context.Context, string, string, string, int64) (dicedomain.Robot, error) {
	return dicedomain.Robot{}, xerr.New(xerr.NotFound, "repository robot picker is not used in api sim")
}

func (r *memoryRepo) AdjustSelfGameStakePool(_ context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) {
	key := poolKey(adjustment.GameID, adjustment.StakeCoin)
	pool, ok := r.pools[key]
	if !ok {
		pool = selfgame.DefaultStakePool(adjustment.AppCode, adjustment.GameID, adjustment.StakeCoin, adjustment.CreatedAtMS)
	}
	switch adjustment.Direction {
	case dicedomain.PoolDirectionIn:
		pool.BalanceCoin += adjustment.AmountCoin
		r.poolIn[key] += adjustment.AmountCoin
		if strings.Contains(adjustment.Reason, "initial") {
			r.poolInitial[key] += adjustment.AmountCoin
		}
	case dicedomain.PoolDirectionOut:
		pool.BalanceCoin -= adjustment.AmountCoin
		if pool.BalanceCoin < 0 {
			return dicedomain.PoolAdjustment{}, xerr.New(xerr.Conflict, "self game stake pool balance is insufficient")
		}
		r.poolOut[key] += adjustment.AmountCoin
	default:
		return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "pool direction is invalid")
	}
	pool.UpdatedAtMS = adjustment.CreatedAtMS
	r.pools[key] = selfgame.NormalizeStakePool(pool)
	adjustment.BalanceAfter = pool.BalanceCoin
	return adjustment, nil
}

func (r *memoryRepo) GetSelfGameStakePool(_ context.Context, app string, gameID string, stakeCoin int64, nowMS int64) (selfgame.StakePool, error) {
	key := poolKey(gameID, stakeCoin)
	if pool, ok := r.pools[key]; ok {
		return selfgame.NormalizeStakePool(pool), nil
	}
	return selfgame.DefaultStakePool(appcode.Normalize(app), gameID, stakeCoin, nowMS), nil
}

func (r *memoryRepo) ListSelfGameStakePools(_ context.Context, _ string, gameID string, _ int64) ([]selfgame.StakePool, error) {
	var items []selfgame.StakePool
	prefix := strings.TrimSpace(gameID) + ":"
	for key, pool := range r.pools {
		if strings.HasPrefix(key, prefix) {
			items = append(items, selfgame.NormalizeStakePool(pool))
		}
	}
	sort.Slice(items, func(i, j int) bool { return items[i].StakeCoin < items[j].StakeCoin })
	return items, nil
}

func (r *memoryRepo) UpsertSelfGameStakePoolConfig(_ context.Context, pool selfgame.StakePool, nowMS int64) (selfgame.StakePool, error) {
	key := poolKey(pool.GameID, pool.StakeCoin)
	current, ok := r.pools[key]
	if !ok {
		current = selfgame.DefaultStakePool(pool.AppCode, pool.GameID, pool.StakeCoin, nowMS)
	}
	current.TargetBalanceCoin = pool.TargetBalanceCoin
	current.SafeBalanceCoin = pool.SafeBalanceCoin
	current.WarningBalanceCoin = pool.WarningBalanceCoin
	current.DangerBalanceCoin = pool.DangerBalanceCoin
	current.Status = pool.Status
	current.UpdatedAtMS = nowMS
	r.pools[key] = selfgame.NormalizeStakePool(current)
	return r.pools[key], nil
}

func (r *memoryRepo) GetSelfGameNewUserPolicy(_ context.Context, app string, gameID string, nowMS int64) (selfgame.NewUserPolicy, error) {
	if policy, ok := r.policies[strings.TrimSpace(gameID)]; ok {
		return selfgame.NormalizeNewUserPolicy(policy), nil
	}
	return selfgame.DefaultNewUserPolicy(appcode.Normalize(app), gameID, nowMS), nil
}

func (r *memoryRepo) UpsertSelfGameNewUserPolicy(_ context.Context, policy selfgame.NewUserPolicy, nowMS int64) (selfgame.NewUserPolicy, error) {
	policy.UpdatedAtMS = nowMS
	if policy.CreatedAtMS == 0 {
		policy.CreatedAtMS = nowMS
	}
	r.policies[policy.GameID] = selfgame.NormalizeNewUserPolicy(policy)
	return r.policies[policy.GameID], nil
}

func (r *memoryRepo) GetSelfGameProtectionState(_ context.Context, app string, gameID string, userID int64, _ int64) (selfgame.ProtectionState, error) {
	key := stateKey(gameID, userID)
	state, ok := r.protection[key]
	if !ok {
		state = selfgame.ProtectionState{AppCode: appcode.Normalize(app), GameID: gameID, UserID: userID, Status: "active"}
	}
	return selfgame.NormalizeProtectionState(state), nil
}

func (r *memoryRepo) GetSelfGameRiskResult(_ context.Context, _ string, _ string, userID int64, _ int64) (selfgame.RiskResult, error) {
	return selfgame.NormalizeRiskResult(r.risk[userID]), nil
}

func (r *memoryRepo) GetSelfGameUserStats(_ context.Context, _ string, gameID string, userID int64, _ int64) (selfgame.UserStats, error) {
	return r.stats[stateKey(gameID, userID)], nil
}

func (r *memoryRepo) RecordSelfGameStrategyLog(_ context.Context, log selfgame.StrategyLog) error {
	r.logs[fmt.Sprintf("%s:%d", log.MatchID, log.UserID)] = log
	return nil
}

func (r *memoryRepo) ConsumeSelfGameProtection(_ context.Context, event selfgame.ProtectionEvent) (selfgame.ProtectionState, bool, error) {
	key := stateKey(event.GameID, event.UserID)
	state := r.protection[key]
	state.AppCode = event.AppCode
	state.GameID = event.GameID
	state.UserID = event.UserID
	state.Status = "active"
	state.UsedRounds += event.UsedRoundDelta
	state.LifetimeSubsidyCoin += event.LifetimeSubsidyCoinDelta
	state.Day1SubsidyCoin += event.Day1SubsidyCoinDelta
	state.UpdatedAtMS = event.CreatedAtMS
	r.protection[key] = selfgame.NormalizeProtectionState(state)
	r.protectionEventByMatch[fmt.Sprintf("%s:%d", event.MatchID, event.UserID)] = event
	return r.protection[key], true, nil
}

func (r *memoryRepo) ListDiceRobots(context.Context, string, string, string, int32, string) ([]dicedomain.Robot, string, error) {
	return nil, "", nil
}
func (r *memoryRepo) RegisterDiceRobots(context.Context, string, string, []int64, int64, int64) ([]dicedomain.Robot, error) {
	return nil, nil
}
func (r *memoryRepo) SetDiceRobotStatus(context.Context, string, string, int64, string, int64) (dicedomain.Robot, error) {
	return dicedomain.Robot{}, nil
}
func (r *memoryRepo) DeleteDiceRobot(context.Context, string, string, int64) error        { return nil }
func (r *memoryRepo) RecordExploreWinner(context.Context, dicedomain.ExploreWinner) error { return nil }
func (r *memoryRepo) ListExploreWinners(context.Context, string, int32) ([]dicedomain.ExploreWinner, error) {
	return nil, nil
}

func (r *memoryRepo) setRisk(userID int64, risk selfgame.RiskResult) {
	r.risk[userID] = selfgame.NormalizeRiskResult(risk)
}

func (r *memoryRepo) updateParticipant(matchID string, userID int64, nowMS int64, apply func(*dicedomain.Participant)) error {
	match := r.matches[strings.TrimSpace(matchID)]
	for index := range match.Participants {
		if match.Participants[index].UserID == userID {
			apply(&match.Participants[index])
			match.Participants[index].UpdatedAtMS = nowMS
			r.matches[match.MatchID] = cloneMatch(match)
			return nil
		}
	}
	return xerr.New(xerr.NotFound, "dice participant not found")
}

func (r *memoryRepo) applyStats(match dicedomain.Match) {
	for _, participant := range match.Participants {
		if participant.ParticipantType != dicedomain.ParticipantTypeUser {
			continue
		}
		if participant.Result == dicedomain.ParticipantResultDraw {
			continue
		}
		key := stateKey(match.GameID, participant.UserID)
		stats := r.stats[key]
		net := participant.PayoutCoin - participant.StakeCoin
		stats.TodayNetWinCoin += net
		stats.SevenDayNetWinCoin += net
		if match.MatchMode == dicedomain.MatchModeRobot {
			stats.EffectiveBotGameCount++
			stats.TodayRobotGameCount++
		}
		if participant.Result == dicedomain.ParticipantResultWin {
			stats.WinStreak++
			stats.LoseStreak = 0
		}
		if participant.Result == dicedomain.ParticipantResultLose {
			stats.LoseStreak++
			stats.WinStreak = 0
		}
		r.stats[key] = stats
	}
}

func (r *memoryRepo) strategyLog(matchID string, userID int64) selfgame.StrategyLog {
	return r.logs[fmt.Sprintf("%s:%d", matchID, userID)]
}

func (r *memoryRepo) protectionEvent(matchID string, userID int64) selfgame.ProtectionEvent {
	return r.protectionEventByMatch[fmt.Sprintf("%s:%d", matchID, userID)]
}

func (r *memoryRepo) poolSnapshot(gameID string, stakeCoin int64) (int64, string) {
	pool := r.pools[poolKey(gameID, stakeCoin)]
	required := requiredPoolCoin(stakeCoin)
	return pool.BalanceCoin, selfgame.PoolLevelFor(pool, required)
}

func (r *memoryRepo) poolTotal(gameID string) int64 {
	var total int64
	prefix := strings.TrimSpace(gameID) + ":"
	for key, pool := range r.pools {
		if strings.HasPrefix(key, prefix) {
			total += pool.BalanceCoin
		}
	}
	return total
}

func (r *memoryRepo) poolSummaries() []poolSummary {
	items := make([]poolSummary, 0, len(r.pools))
	for key, pool := range r.pools {
		// initial funding is also an in transaction, so the accounting formula is initial + non-initial in - out.
		accounted := r.poolInitial[key] + (r.poolIn[key] - r.poolInitial[key]) - r.poolOut[key]
		items = append(items, poolSummary{
			GameID:        pool.GameID,
			StakeCoin:     pool.StakeCoin,
			InitialCoin:   r.poolInitial[key],
			BalanceCoin:   pool.BalanceCoin,
			InCoin:        r.poolIn[key],
			OutCoin:       r.poolOut[key],
			PoolLevel:     selfgame.PoolLevelFor(pool, requiredPoolCoin(pool.StakeCoin)),
			AccountingOK:  accounted == pool.BalanceCoin,
			AccountedCoin: accounted,
		})
	}
	sort.Slice(items, func(i, j int) bool {
		if items[i].GameID == items[j].GameID {
			return items[i].StakeCoin < items[j].StakeCoin
		}
		return items[i].GameID < items[j].GameID
	})
	return items
}

type memoryWallet struct{ balances map[int64]int64 }

func (w *memoryWallet) setBalance(userID int64, coin int64) { w.balances[userID] = coin }
func (w *memoryWallet) balance(userID int64) int64          { return w.balances[userID] }

func (w *memoryWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
	if req.GetCoinAmount() <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
	}
	switch req.GetOpType() {
	case "debit":
		if w.balances[req.GetUserId()] < req.GetCoinAmount() {
			return nil, xerr.New(xerr.InsufficientBalance, "coin balance is insufficient")
		}
		w.balances[req.GetUserId()] -= req.GetCoinAmount()
	case "credit", "refund":
		w.balances[req.GetUserId()] += req.GetCoinAmount()
	default:
		return nil, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
	}
	return &walletv1.ApplyGameCoinChangeResponse{
		WalletTransactionId: "api_sim_wtx_" + stableHash(req.GetProviderOrderId())[:20],
		BalanceAfter:        w.balances[req.GetUserId()],
	}, nil
}

func (w *memoryWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{
		AssetType:       "COIN",
		AvailableAmount: w.balances[req.GetUserId()],
	}}}, nil
}

type memoryRobotRegistry struct {
	robots []int64
}

func newMemoryRobotRegistry(count int) *memoryRobotRegistry {
	robots := make([]int64, 0, count)
	for i := 0; i < count; i++ {
		robots = append(robots, int64(800000000+i))
	}
	return &memoryRobotRegistry{robots: robots}
}

func (r *memoryRobotRegistry) PickActiveDiceRobot(_ context.Context, app string, gameID string, _ string, exclude []int64, nowMS int64) (dicedomain.Robot, error) {
	blocked := map[int64]struct{}{}
	for _, id := range exclude {
		blocked[id] = struct{}{}
	}
	candidates := make([]int64, 0, len(r.robots))
	for _, id := range r.robots {
		if _, ok := blocked[id]; !ok {
			candidates = append(candidates, id)
		}
	}
	if len(candidates) == 0 {
		return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
	}
	id := candidates[randomInt(len(candidates))]
	return dicedomain.Robot{AppCode: appcode.Normalize(app), GameID: gameID, UserID: id, Status: dicedomain.RobotStatusActive, LastUsedAtMS: nowMS}, nil
}

func (r *memoryRobotRegistry) ListDiceRobots(context.Context, string, string, string, int32, string) ([]dicedomain.Robot, string, error) {
	return nil, "", nil
}
func (r *memoryRobotRegistry) RegisterDiceRobots(context.Context, string, string, []int64, int64, int64) ([]dicedomain.Robot, error) {
	return nil, nil
}
func (r *memoryRobotRegistry) SetDiceRobotStatus(context.Context, string, string, int64, string, int64) (dicedomain.Robot, error) {
	return dicedomain.Robot{}, nil
}
func (r *memoryRobotRegistry) DeleteDiceRobot(context.Context, string, string, int64) error {
	return nil
}

func meta(requestID string, actor int64) *gamev1.RequestMeta {
	return &gamev1.RequestMeta{RequestId: requestID, Caller: "selfgame-api-sim", ActorUserId: actor, SentAtMs: time.Now().UnixMilli(), AppCode: appCode}
}

func randomScenarioUser(index int, total int) *userState {
	cohort := cohortMixed
	if index < total/2 {
		cohort = cohortRobotOnly
	}
	risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: randomRiskScore()})
	return &userState{
		UserID:          int64(100000 + index),
		Cohort:          cohort,
		InitialCoin:     int64(20_000 + randomInt(280_001)),
		RiskScore:       risk.RiskScore,
		RiskTier:        risk.RiskTier,
		RegisteredHours: int32(randomInt(24)),
	}
}

func accumulateSummary(s *summary, trace roundTrace) {
	s.Attempts++
	if trace.Settled {
		s.Settled++
	}
	if trace.Result == resultSkipped {
		s.Skipped++
	}
	if trace.ActualMatchMode == matchModeHuman {
		s.HumanMatches++
	}
	if trace.ActualMatchMode == matchModeRobot {
		s.RobotMatches++
	}
	if trace.Result == resultWin {
		s.Wins++
		s.NetFromWins += trace.UserNetCoin
	}
	if trace.Result == resultLose {
		s.Losses++
		s.NetFromLosses += trace.UserNetCoin
	}
	if trace.Result == resultDraw {
		s.Draws++
	}
	if trace.Decision == selfgame.DecisionForceHumanWin {
		s.ForceHumanWin++
	}
	if trace.Decision == selfgame.DecisionForceHumanLose {
		s.ForceHumanLose++
	}
	if trace.ReasonCode == "NEW_USER_POSITIVE_FEEDBACK" {
		s.NewUserPositiveFeedback++
	}
	if trace.ReasonCode == "NEW_USER_LOSE_STREAK_PROTECTION" {
		s.NewUserLoseStreakProtection++
	}
	if trace.ReasonCode == "POOL_BLACK" {
		s.PoolBlackSkips++
	}
	if trace.ReasonCode == "RISK_BLOCKED" {
		s.RiskBlocked++
	}
	if trace.StakeCoin >= 40000 && trace.Decision == selfgame.DecisionForceHumanWin {
		s.HighStakeProtectionHits++
	}
	if trace.GameID == dicedomain.SelfGameIDRock && trace.Result == resultDraw && trace.ProtectionConsumed {
		s.RockDrawProtectionConsumptions++
	}
	if trace.ProtectionConsumed {
		s.ProtectionConsumedRounds++
		s.ProtectionSubsidyCoin += trace.ProtectionSubsidyCoin
	}
	if trace.EffectiveRobotGameCounted {
		s.EffectiveRobotGameCount++
	}
	if trace.DrawExcludedFromEffective {
		s.EffectiveRobotDrawExcludedCount++
	}
}

func accumulateUserResult(s *summary, record userRecord) {
	s.TotalUserNetCoin += record.NetCoin
	if record.NetCoin > 0 {
		s.UsersPositive++
	}
	if record.NetCoin < 0 {
		s.UsersNegative++
	}
}

func summarizeCohorts(path string) []cohortSummary {
	records := readUserRecords(path)
	by := map[string]*cohortSummary{
		cohortRobotOnly: {Cohort: cohortRobotOnly},
		cohortMixed:     {Cohort: cohortMixed},
	}
	for _, record := range records {
		item := by[record.Cohort]
		item.Users++
		item.TotalUserNetCoin += record.NetCoin
		if record.NetCoin > 0 {
			item.UsersPositive++
		}
		if record.NetCoin < 0 {
			item.UsersNegative++
		}
		for _, trace := range record.Rounds {
			item.Rounds++
			var tmp summary
			accumulateSummary(&tmp, trace)
			item.Settled += tmp.Settled
			item.Skipped += tmp.Skipped
			item.HumanMatches += tmp.HumanMatches
			item.RobotMatches += tmp.RobotMatches
			item.Wins += tmp.Wins
			item.Losses += tmp.Losses
			item.Draws += tmp.Draws
			item.ForceHumanWin += tmp.ForceHumanWin
			item.ForceHumanLose += tmp.ForceHumanLose
			item.NewUserPositiveFeedback += tmp.NewUserPositiveFeedback
			item.NewUserLoseStreakProtection += tmp.NewUserLoseStreakProtection
			item.PoolBlackSkips += tmp.PoolBlackSkips
			item.RiskBlocked += tmp.RiskBlocked
			item.HighStakeProtectionHits += tmp.HighStakeProtectionHits
			item.RockDrawProtectionConsumptions += tmp.RockDrawProtectionConsumptions
			item.ProtectionConsumedRounds += tmp.ProtectionConsumedRounds
			item.ProtectionSubsidyCoin += tmp.ProtectionSubsidyCoin
			item.NetFromWins += tmp.NetFromWins
			item.NetFromLosses += tmp.NetFromLosses
			item.EffectiveRobotGameCount += tmp.EffectiveRobotGameCount
			item.EffectiveRobotDrawExcludedCount += tmp.EffectiveRobotDrawExcludedCount
		}
	}
	out := []cohortSummary{*by[cohortRobotOnly], *by[cohortMixed]}
	for i := range out {
		out[i].AverageUserNetCoin = ratioInt64(out[i].TotalUserNetCoin, int64(out[i].Users))
		out[i].OpenRate = ratio(out[i].Settled, out[i].Rounds)
		out[i].RobotOpenRate = ratio(out[i].RobotMatches, out[i].RobotMatches+out[i].PoolBlackSkips)
	}
	return out
}

func userResult(match *gamev1.DiceMatch, userID int64) string {
	for _, participant := range match.GetParticipants() {
		if participant.GetUserId() == userID {
			switch participant.GetResult() {
			case dicedomain.ParticipantResultWin:
				return resultWin
			case dicedomain.ParticipantResultLose:
				return resultLose
			case dicedomain.ParticipantResultDraw:
				return resultDraw
			}
		}
	}
	return resultSkipped
}

func randomHumanGesture(gameID string) string {
	if !dicedomain.IsRPSGameID(gameID) {
		return ""
	}
	return []string{"rock", "paper", "scissors"}[randomInt(3)]
}

func initialPoolCoin(gameID string, stake int64) int64 {
	switch stake {
	case 1000:
		return 2_000_000
	case 5000:
		return 3_000_000
	case 40000:
		return 80_000
	case 100000:
		return 40_000
	default:
		return stake * 10
	}
}

func requiredPoolCoin(stake int64) int64 {
	return stake * 2 * (10_000 - feeBPS - poolBPS) / 10_000
}

func randomRiskScore() int32 {
	roll := randomInt(100)
	switch {
	case roll < 78:
		return int32(randomInt(40))
	case roll < 90:
		return int32(40 + randomInt(20))
	case roll < 96:
		return int32(60 + randomInt(20))
	default:
		return int32(80 + randomInt(20))
	}
}

func weightedString(items []weightedStringValue) string {
	total := 0
	for _, item := range items {
		total += item.Weight
	}
	roll := randomInt(total)
	for _, item := range items {
		if roll < item.Weight {
			return item.Value
		}
		roll -= item.Weight
	}
	return items[len(items)-1].Value
}

type weightedStringValue struct {
	Value  string
	Weight int
}

func weightedInt64(items []weightedInt64Value) int64 {
	total := 0
	for _, item := range items {
		total += item.Weight
	}
	roll := randomInt(total)
	for _, item := range items {
		if roll < item.Weight {
			return item.Value
		}
		roll -= item.Weight
	}
	return items[len(items)-1].Value
}

type weightedInt64Value struct {
	Value  int64
	Weight int
}

func shuffledRange(total int) []int {
	out := make([]int, total)
	for i := range out {
		out[i] = i
	}
	for i := len(out) - 1; i > 0; i-- {
		j := randomInt(i + 1)
		out[i], out[j] = out[j], out[i]
	}
	return out
}

func randomInt(max int) int {
	if max <= 0 {
		return 0
	}
	n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
	if err != nil {
		panic(err)
	}
	return int(n.Int64())
}

func randomHex(size int) string {
	buf := make([]byte, size)
	if _, err := rand.Read(buf); err != nil {
		panic(err)
	}
	return hex.EncodeToString(buf)
}

func stableHash(input string) string {
	sum := sha256.Sum256([]byte(strings.TrimSpace(input)))
	return hex.EncodeToString(sum[:])
}

func cloneMatch(match dicedomain.Match) dicedomain.Match {
	match.Participants = append([]dicedomain.Participant(nil), match.Participants...)
	for index := range match.Participants {
		match.Participants[index].DicePoints = append([]int32(nil), match.Participants[index].DicePoints...)
	}
	return match
}

func hasParticipant(participants []dicedomain.Participant, userID int64) bool {
	for _, participant := range participants {
		if participant.UserID == userID {
			return true
		}
	}
	return false
}

func joinable(status string) bool {
	return status == dicedomain.MatchStatusCreated || status == dicedomain.MatchStatusJoining
}

func poolKey(gameID string, stake int64) string {
	return strings.TrimSpace(gameID) + ":" + fmt.Sprint(stake)
}

func stateKey(gameID string, userID int64) string {
	return strings.TrimSpace(gameID) + ":" + fmt.Sprint(userID)
}

func abs64(v int64) int64 {
	if v < 0 {
		return -v
	}
	return v
}

func ratio(numerator int, denominator int) float64 {
	if denominator == 0 {
		return 0
	}
	return float64(numerator) / float64(denominator)
}

func ratioInt64(numerator int64, denominator int64) float64 {
	if denominator == 0 {
		return 0
	}
	return float64(numerator) / float64(denominator)
}

func mustCreate(path string) *os.File {
	file, err := os.Create(path)
	if err != nil {
		fatalf("create %s: %v", path, err)
	}
	return file
}

func writeJSONLine(file *os.File, value any) error {
	data, err := json.Marshal(value)
	if err != nil {
		return err
	}
	if _, err := file.Write(append(data, '\n')); err != nil {
		return err
	}
	return nil
}

func writeJSONFile(path string, value any) {
	data, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		fatalf("marshal %s: %v", path, err)
	}
	if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
		fatalf("write %s: %v", path, err)
	}
}

func readUserRecords(path string) []userRecord {
	data, err := os.ReadFile(path)
	if err != nil {
		fatalf("read %s: %v", path, err)
	}
	lines := strings.Split(strings.TrimSpace(string(data)), "\n")
	records := make([]userRecord, 0, len(lines))
	for _, line := range lines {
		if strings.TrimSpace(line) == "" {
			continue
		}
		var record userRecord
		if err := json.Unmarshal([]byte(line), &record); err != nil {
			fatalf("parse user record: %v", err)
		}
		records = append(records, record)
	}
	return records
}

func countJSONLines(path string) int {
	data, err := os.ReadFile(path)
	if err != nil {
		return 0
	}
	count := 0
	for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
		if strings.TrimSpace(line) != "" {
			count++
		}
	}
	return count
}

func writeSummary(path string, rep report) {
	var b strings.Builder
	fmt.Fprintf(&b, "# 自研游戏接口模拟结果\n\n")
	fmt.Fprintf(&b, "- Run ID: `%s`\n", rep.RunID)
	fmt.Fprintf(&b, "- 用户数: `%d`\n", rep.UserCount)
	fmt.Fprintf(&b, "- 每人局数: `%d`\n", rep.RoundsPerUser)
	fmt.Fprintf(&b, "- 随机源: `%s`\n", rep.RandomSource)
	fmt.Fprintf(&b, "- 接口链路: `%s`\n", rep.InterfaceMode)
	fmt.Fprintf(&b, "- 用户完整轨迹: `%s`\n\n", rep.UserTrajectory)
	fmt.Fprintf(&b, "## Cohort 汇总\n\n")
	fmt.Fprintf(&b, "| 组 | 用户 | 局数 | settled | skipped | robot | human | wins | losses | draws | 人均净变化 | open rate | robot open rate |\n")
	fmt.Fprintf(&b, "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n")
	for _, item := range rep.Cohorts {
		fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %d | %d | %d | %d | %.2f | %.4f | %.4f |\n",
			item.Cohort, item.Users, item.Rounds, item.Settled, item.Skipped, item.RobotMatches, item.HumanMatches,
			item.Wins, item.Losses, item.Draws, item.AverageUserNetCoin, item.OpenRate, item.RobotOpenRate)
	}
	fmt.Fprintf(&b, "\n## 档位奖池\n\n")
	fmt.Fprintf(&b, "| 游戏 | 档位 | 初始 | 入池 | 出池 | 期末 | 水位 | 对账 |\n")
	fmt.Fprintf(&b, "| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |\n")
	for _, pool := range rep.PoolSummaries {
		fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %s | %v |\n", pool.GameID, pool.StakeCoin, pool.InitialCoin, pool.InCoin, pool.OutCoin, pool.BalanceCoin, pool.PoolLevel, pool.AccountingOK)
	}
	fmt.Fprintf(&b, "\n## 验收\n\n")
	fmt.Fprintf(&b, "- 结论: `%v`\n", rep.Validation.Passed)
	fmt.Fprintf(&b, "- Admin 配置接口: `%v`\n", rep.Validation.AdminConfigAPICalled)
	fmt.Fprintf(&b, "- 档位奖池接口: `%v`\n", rep.Validation.StakePoolAPICalled)
	fmt.Fprintf(&b, "- MatchDice: `%v`\n", rep.Validation.AppMatchAPICalled)
	fmt.Fprintf(&b, "- Create/Join: `%v`\n", rep.Validation.AppCreateJoinAPICalled)
	fmt.Fprintf(&b, "- RollDiceMatch: `%v`\n", rep.Validation.AppRollAPICalled)
	if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
		fatalf("write summary: %v", err)
	}
}

func fatalf(format string, args ...any) {
	fmt.Fprintf(os.Stderr, format+"\n", args...)
	os.Exit(1)
}
