package dice import ( "encoding/json" "sort" "strings" "hyapp/pkg/xerr" ) const ( // 默认平台值只用于自研骰子没有第三方 provider 配置时的订单快照,保证 game_orders 唯一键仍有稳定平台维度。 DefaultPlatformCode = "dice" DefaultProviderGameID = "dice" // DefaultFeeBPS 用万分比表达平台费,结算时先从总奖池扣除,再把剩余奖池均分给最高分赢家。 DefaultFeeBPS = int64(500) DefaultPoolBPS = int64(100) DefaultJoinTTLMillis = int64(60_000) DefaultRobotWaitMS = int64(3_000) DefaultGameID = "dice" SelfGameIDRock = "rock" // 首版禁止 1 人局,避免单人场景下“自己赢自己”的派奖语义不清;3 人局只是多一行 participant。 DefaultMinPlayers = int32(2) DefaultMaxPlayers = int32(2) HardMaxPlayers = int32(6) // 单局胜场制每个参与者只扔一颗骰子;数组仍保留是为了兼容旧接口字段和未来玩法扩展。 PointsPerParticipant = 1 ProviderOrderPrefix = "dice" MatchIDPrefix = "dice_match" ParticipantResultWin = "win" ParticipantResultLose = "lose" ParticipantResultDraw = "draw" RPSGestureRock = "rock" RPSGesturePaper = "paper" RPSGestureScissors = "scissors" MatchStatusCreated = "created" MatchStatusJoining = "joining" MatchStatusReady = "ready" MatchStatusSettling = "settling" MatchStatusPayoutApplying = "payout_applying" MatchStatusSettled = "settled" MatchStatusFailed = "failed" MatchStatusCanceled = "canceled" MatchPhaseWaiting = "waiting" MatchPhaseCountdown = "countdown" MatchPhaseRolling = "rolling" MatchPhaseComparing = "comparing" MatchPhaseSettled = "settled" MatchPhaseCanceled = "canceled" MatchPhaseFailed = "failed" CountdownMillis = int64(3_000) RollingMillis = int64(3_000) ComparingMillis = int64(3_000) DrawRerollMillis = int64(2_000) SettlementShowMillis = int64(3_000) ParticipantStatusJoined = "joined" ParticipantStatusDebitSucceeded = "debit_succeeded" ParticipantStatusDebitFailed = "debit_failed" ParticipantStatusPayoutSucceeded = "payout_succeeded" ParticipantStatusRefundSucceeded = "refund_succeeded" ParticipantStatusSettled = "settled" ParticipantTypeUser = "user" ParticipantTypeRobot = "robot" MatchModeHuman = "human" MatchModeRobot = "robot" ForcedResultPlayerLose = "player_lose" ConfigStatusActive = "active" ConfigStatusDisabled = "disabled" RobotStatusActive = "active" RobotStatusDisabled = "disabled" ExploreGameCodeDice = "game_dice" ExploreGameCodeRock = "game_rock" DefaultExploreWinnerPageSize = int32(20) PoolDirectionIn = "in" PoolDirectionOut = "out" ) // StakeOption 是后台配置给 H5 展示的下注档位;排序字段只影响展示,不参与结算公式。 type StakeOption struct { StakeCoin int64 Enabled bool SortOrder int32 } // Config 是自研骰子游戏的运营配置快照;每局创建时会把费率复制到 match,保证历史局按当时配置结算。 type Config struct { AppCode string GameID string Status string StakeOptions []StakeOption FeeBPS int32 PoolBPS int32 MinPlayers int32 MaxPlayers int32 RobotEnabled bool RobotMatchWaitMS int64 PoolBalanceCoin int64 CreatedAtMS int64 UpdatedAtMS int64 } // Robot 是 game-service 对“哪些真实 App 用户可作为机器人”的最小登记,不复制用户头像昵称。 type Robot struct { AppCode string GameID string UserID int64 Status string CreatedByAdminID int64 CreatedAtMS int64 UpdatedAtMS int64 } // ExploreWinner 是 Explore Square 展示用的轻量获胜事实;只由真实游戏结算写入,读接口不能主动制造默认播报。 type ExploreWinner struct { AppCode string WinID string GameCode string GameID string UserID int64 DisplayName string AvatarURL string CoinAmount int64 WonAtMS int64 CreatedAtMS int64 UpdatedAtMS int64 } // PoolAdjustment 是奖池余额变化事实;所有机器人对局和后台手工调整都必须留下流水。 type PoolAdjustment struct { AdjustmentID string AppCode string GameID string MatchID string UserID int64 Direction string AmountCoin int64 Reason string BalanceAfter int64 CreatedBy int64 CreatedAtMS int64 } // Match 是骰子局的持久化事实;平台和 provider_game_id 是下单快照,不直接暴露给 App。 type Match struct { AppCode string MatchID string GameID string PlatformCode string ProviderGameID string RoomID string RegionID int64 MinPlayers int32 MaxPlayers int32 CurrentPlayers int32 StakeCoin int64 RoundNo int32 Status string Result string Participants []Participant JoinDeadlineMS int64 ReadyAtMS int64 CreatedAtMS int64 UpdatedAtMS int64 SettledAtMS int64 CanceledAtMS int64 FeeBPS int32 PoolBPS int32 MatchMode string ForcedResult string PoolDeltaCoin int64 } // Participant 是一局内单个用户的座位、扣款、投骰和派奖快照。 type Participant struct { AppCode string MatchID string UserID int64 ParticipantType string SeatNo int32 Status string StakeCoin int64 DicePoints []int32 RPSGesture string Result string PayoutCoin int64 DebitOrderID string PayoutOrderID string RefundOrderID string BalanceAfter int64 JoinedAtMS int64 UpdatedAtMS int64 } // NormalizeConfig 把后台配置收口成可执行值;空配置仍返回安全默认,保证新环境可以直接打开骰子 H5。 func NormalizeConfig(config Config) (Config, error) { config.GameID = strings.TrimSpace(config.GameID) if config.GameID == "" { config.GameID = DefaultGameID } config.Status = strings.TrimSpace(config.Status) if config.Status == "" { config.Status = ConfigStatusActive } if config.Status != ConfigStatusActive && config.Status != ConfigStatusDisabled { return Config{}, xerr.New(xerr.InvalidArgument, "dice config status is invalid") } if config.FeeBPS < 0 || config.FeeBPS >= 10_000 { return Config{}, xerr.New(xerr.InvalidArgument, "dice fee_bps is invalid") } if config.FeeBPS == 0 { config.FeeBPS = int32(DefaultFeeBPS) } if config.PoolBPS < 0 || int64(config.FeeBPS)+int64(config.PoolBPS) >= 10_000 { return Config{}, xerr.New(xerr.InvalidArgument, "dice pool_bps is invalid") } if config.PoolBPS == 0 { config.PoolBPS = int32(DefaultPoolBPS) } minPlayers, maxPlayers, err := NormalizePlayerBounds(config.MinPlayers, config.MaxPlayers) if err != nil { return Config{}, err } config.MinPlayers = minPlayers config.MaxPlayers = maxPlayers if config.RobotMatchWaitMS <= 0 { config.RobotMatchWaitMS = DefaultRobotWaitMS } config.StakeOptions = NormalizeStakeOptions(config.StakeOptions) return config, nil } // NormalizeStakeOptions 去重、过滤非法档位并保持后台排序;空配置给出本地可用的保底档位。 func NormalizeStakeOptions(options []StakeOption) []StakeOption { if len(options) == 0 { return []StakeOption{ {StakeCoin: 100, Enabled: true, SortOrder: 10}, {StakeCoin: 500, Enabled: true, SortOrder: 20}, {StakeCoin: 1000, Enabled: true, SortOrder: 30}, {StakeCoin: 8000, Enabled: true, SortOrder: 40}, } } seen := make(map[int64]struct{}, len(options)) out := make([]StakeOption, 0, len(options)) for _, option := range options { if option.StakeCoin <= 0 { continue } if _, ok := seen[option.StakeCoin]; ok { continue } seen[option.StakeCoin] = struct{}{} out = append(out, option) } sort.SliceStable(out, func(left int, right int) bool { if out[left].SortOrder == out[right].SortOrder { return out[left].StakeCoin < out[right].StakeCoin } return out[left].SortOrder < out[right].SortOrder }) if len(out) == 0 { return NormalizeStakeOptions(nil) } return out } // StakeEnabled 明确校验下注金额是否来自后台启用档位,避免 H5 篡改 stake_coin 打出任意金额。 func StakeEnabled(config Config, stakeCoin int64) bool { for _, option := range config.StakeOptions { if option.StakeCoin == stakeCoin && option.Enabled { return true } } return false } // StakeOptionsJSON 把后台档位保存成稳定 JSON;仓储不做字符串拼接,避免后续字段扩展破坏解析。 func StakeOptionsJSON(options []StakeOption) string { raw, err := json.Marshal(NormalizeStakeOptions(options)) if err != nil { return "[]" } return string(raw) } // ParseStakeOptions 解析数据库 JSON;坏配置回退为空,后续 NormalizeConfig 会补安全默认。 func ParseStakeOptions(raw string) []StakeOption { var options []StakeOption if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &options); err != nil { return nil } return NormalizeStakeOptions(options) } // NormalizePlayerBounds 把客户端可调人数压回当前可承载范围;多人扩展只需要调上限,不需要换表。 func NormalizePlayerBounds(minPlayers int32, maxPlayers int32) (int32, int32, error) { // 客户端不传人数时走产品默认值;传了非法值则直接拒绝,不在后端静默纠正成另一种玩法。 if minPlayers <= 0 { minPlayers = DefaultMinPlayers } if maxPlayers <= 0 { maxPlayers = DefaultMaxPlayers } if minPlayers < 2 { return 0, 0, xerr.New(xerr.InvalidArgument, "dice min_players must be at least 2") } if maxPlayers < minPlayers { return 0, 0, xerr.New(xerr.InvalidArgument, "dice max_players must be greater than or equal to min_players") } if maxPlayers > HardMaxPlayers { return 0, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large") } return minPlayers, maxPlayers, nil } // Score 只读取单颗骰子点数;校验长度可以防止旧三骰数据或未投骰数据被当作新规则结算。 func Score(points []int32) (int32, error) { // 点数必须来自服务端随机或已落库事实;长度不对说明本局还没完成投骰,不能推导结果。 if len(points) != PointsPerParticipant { return 0, xerr.New(xerr.InvalidArgument, "dice points are incomplete") } point := points[0] if point < 1 || point > 6 { return 0, xerr.New(xerr.InvalidArgument, "dice point is invalid") } return point, nil } // IsRPSGameID 标识复用 self-game match 表的真实石头剪刀布玩法;room-rps 有独立领域模型,不走这里。 func IsRPSGameID(gameID string) bool { return strings.TrimSpace(gameID) == SelfGameIDRock } // NormalizeRPSGesture 把 H5 输入收口为三种稳定英文枚举;后续落库、结算和响应都只使用这三个值。 func NormalizeRPSGesture(value string) (string, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case RPSGestureRock: return RPSGestureRock, true case RPSGesturePaper: return RPSGesturePaper, true case RPSGestureScissors: return RPSGestureScissors, true default: return "", false } } // RPSGesturePoint 只给 rps 结算事实写入 dice_points_json 做幂等标记和轻量展示兜底,不参与胜负判断。 func RPSGesturePoint(gesture string) (int32, bool) { switch strings.TrimSpace(gesture) { case RPSGestureRock: return 1, true case RPSGesturePaper: return 2, true case RPSGestureScissors: return 3, true default: return 0, false } } // RPSWinningGestureFor 返回能击败入参的手势;机器人奖池不足强制玩家输时只改机器人预选,不改真人事实。 func RPSWinningGestureFor(gesture string) (string, bool) { switch strings.TrimSpace(gesture) { case RPSGestureRock: return RPSGesturePaper, true case RPSGesturePaper: return RPSGestureScissors, true case RPSGestureScissors: return RPSGestureRock, true default: return "", false } } // rpsCompare 返回 left 相对 right 的胜负:1 表示 left 赢,-1 表示 left 输,0 表示和局。 func rpsCompare(left string, right string) (int, error) { left = strings.TrimSpace(left) right = strings.TrimSpace(right) if _, ok := RPSGesturePoint(left); !ok { return 0, xerr.New(xerr.InvalidArgument, "rps gesture is invalid") } if _, ok := RPSGesturePoint(right); !ok { return 0, xerr.New(xerr.InvalidArgument, "rps gesture is invalid") } if left == right { return 0, nil } if left == RPSGestureRock && right == RPSGestureScissors || left == RPSGesturePaper && right == RPSGestureRock || left == RPSGestureScissors && right == RPSGesturePaper { return 1, nil } return -1, nil } // SettleRPSByGestureWithPool 使用双方已经落库的预选手势结算;rock 不随机、不重选,和局直接退还双方本金并结束本局。 func SettleRPSByGestureWithPool(participants []Participant, feeBPS int64, poolBPS int64) ([]Participant, string, error) { // 真实石头剪刀布当前产品只开放 2 人对战;多人局没有明确胜负和分账语义,必须在规则层拒绝。 if len(participants) != int(DefaultMinPlayers) { return nil, "", xerr.New(xerr.Conflict, "rps match requires two participants") } if feeBPS < 0 || feeBPS >= 10_000 { return nil, "", xerr.New(xerr.InvalidArgument, "dice fee_bps is invalid") } if poolBPS < 0 || feeBPS+poolBPS >= 10_000 { return nil, "", xerr.New(xerr.InvalidArgument, "dice pool_bps is invalid") } settled := append([]Participant(nil), participants...) sort.SliceStable(settled, func(left int, right int) bool { return settled[left].SeatNo < settled[right].SeatNo }) var totalStake int64 for index := range settled { // rps 的手势必须来自 match/create/join 阶段的预选事实;roll 阶段不能补造或重随机。 gesture, ok := NormalizeRPSGesture(settled[index].RPSGesture) if !ok { return nil, "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid") } if settled[index].StakeCoin <= 0 { return nil, "", xerr.New(xerr.InvalidArgument, "dice stake_coin is invalid") } point, _ := RPSGesturePoint(gesture) settled[index].RPSGesture = gesture settled[index].DicePoints = []int32{point} settled[index].Result = ParticipantResultLose settled[index].PayoutCoin = 0 totalStake += settled[index].StakeCoin } compare, err := rpsCompare(settled[0].RPSGesture, settled[1].RPSGesture) if err != nil { return nil, "", err } if compare == 0 { // 预选手势相同就是本局和局;双方已扣本金后各退回自己的 stake_coin,不进入平台费或奖池抽成。 for index := range settled { settled[index].Result = ParticipantResultDraw settled[index].PayoutCoin = settled[index].StakeCoin } return settled, ParticipantResultDraw, nil } winnerIndex := 0 if compare < 0 { winnerIndex = 1 } // 非和局沿用 self-game 经济模型:总奖池先扣平台费和入池比例,剩余整数金额一次性给赢家。 payoutPool := totalStake * (10_000 - feeBPS - poolBPS) / 10_000 settled[winnerIndex].Result = ParticipantResultWin settled[winnerIndex].PayoutCoin = payoutPool return settled, ParticipantResultWin, nil } // SettleByHighestScore 使用单骰最高点数结算;全部平局只标记 draw,由 service 保持同一局继续重投。 func SettleByHighestScore(participants []Participant, feeBPS int64) ([]Participant, string, error) { return SettleByHighestScoreWithPool(participants, feeBPS, 0) } // SettleByHighestScoreWithPool 使用单骰最高点数结算,并把平台抽水和入池比例都从总奖池里扣出。 func SettleByHighestScoreWithPool(participants []Participant, feeBPS int64, poolBPS int64) ([]Participant, string, error) { // 规则层再次兜底人数,避免绕过 service 或仓储测试桩时把未成局数据结算成有效账务。 if len(participants) < int(DefaultMinPlayers) { return nil, "", xerr.New(xerr.Conflict, "dice match does not have enough participants") } if feeBPS < 0 || feeBPS >= 10_000 { return nil, "", xerr.New(xerr.InvalidArgument, "dice fee_bps is invalid") } if poolBPS < 0 || feeBPS+poolBPS >= 10_000 { return nil, "", xerr.New(xerr.InvalidArgument, "dice pool_bps is invalid") } settled := append([]Participant(nil), participants...) // 按座位稳定排序后再输出结果,客户端刷新时参与者顺序不会因为 map/查询顺序变化而跳动。 sort.SliceStable(settled, func(left int, right int) bool { return settled[left].SeatNo < settled[right].SeatNo }) var highScore int32 winnerIndexes := make([]int, 0, len(settled)) for index := range settled { // 每个参与者先取单骰点数,同时借 Score 校验 dice_points_json 是否完整合法。 score, err := Score(settled[index].DicePoints) if err != nil { return nil, "", err } if index == 0 || score > highScore { highScore = score winnerIndexes = winnerIndexes[:0] winnerIndexes = append(winnerIndexes, index) continue } if score == highScore { winnerIndexes = append(winnerIndexes, index) } } if len(winnerIndexes) == len(settled) { // 全员最高分相同视为和局;首版产品要求 2 秒后继续扔,所以这里不产生 payout,也不进入平台费和奖池抽成。 for index := range settled { settled[index].Result = ParticipantResultDraw settled[index].PayoutCoin = 0 } return settled, ParticipantResultDraw, nil } var totalStake int64 for index := range settled { // 押注必须是正数;一旦有坏账务快照,整局停止结算,由上层标记 failed 等待人工或补偿。 if settled[index].StakeCoin <= 0 { return nil, "", xerr.New(xerr.InvalidArgument, "dice stake_coin is invalid") } totalStake += settled[index].StakeCoin settled[index].Result = ParticipantResultLose settled[index].PayoutCoin = 0 } // 非和局时先扣平台费和入池金额,再把整数奖池按最高分赢家均分;除不尽的余数留在平台费侧,避免多发币。 payoutPool := totalStake * (10_000 - feeBPS - poolBPS) / 10_000 share := payoutPool / int64(len(winnerIndexes)) for _, index := range winnerIndexes { settled[index].Result = ParticipantResultWin settled[index].PayoutCoin = share } return settled, ParticipantResultWin, nil } // HasRolls 判断本局是否已经保存过骰子点数;重试结算必须复用旧点数,不能重新随机。 func HasRolls(participants []Participant) bool { for _, participant := range participants { if len(participant.DicePoints) > 0 { return true } } return false }