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

1722 lines
63 KiB
Go

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