2026-06-11 18:14:04 +08:00

546 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}