546 lines
18 KiB
Go
546 lines
18 KiB
Go
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
|
||
}
|