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" ForcedResultPlayerWin = "player_win" ForcedResultPlayerWinWithoutProtection = "player_win_no_protection" 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 LastUsedAtMS int64 UsedCount 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 StakeCoin int64 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 CountryID 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 } } // RPSLosingGestureFor 返回会输给入参的手势;新手局需要玩家必赢时只改机器人预选,不改真人已经提交的手势。 func RPSLosingGestureFor(gesture string) (string, bool) { switch strings.TrimSpace(gesture) { case RPSGestureRock: return RPSGestureScissors, true case RPSGesturePaper: return RPSGestureRock, true case RPSGestureScissors: return RPSGesturePaper, 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 }