1722 lines
63 KiB
Go
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)
|
|
}
|