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" // 首版禁止 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" 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" 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 } // 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 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 } // 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 }