服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
313 lines
9.0 KiB
Go
313 lines
9.0 KiB
Go
package leaderboard
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidSettlement = fmt.Errorf("%w: settlement is invalid", ErrInvalidSubmission)
|
|
ErrNilRewardOutbox = errors.New("leaderboard reward outbox is required")
|
|
)
|
|
|
|
const RewardOutboxPending = "pending"
|
|
|
|
type RewardRule struct {
|
|
MinRank int `json:"min_rank"`
|
|
MaxRank int `json:"max_rank"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
}
|
|
|
|
type RewardOptions struct {
|
|
SettlementID string `json:"settlement_id"`
|
|
Rules []RewardRule `json:"rules"`
|
|
}
|
|
|
|
type RewardGrant struct {
|
|
SettlementID string `json:"settlement_id"`
|
|
LeaderboardID string `json:"leaderboard_id"`
|
|
OwnerID string `json:"owner_id"`
|
|
Rank int `json:"rank"`
|
|
Score int64 `json:"score"`
|
|
Subscore int64 `json:"subscore,omitempty"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
}
|
|
|
|
type RewardOutboxEvent struct {
|
|
ID string `json:"id"`
|
|
Grant RewardGrant `json:"grant"`
|
|
Status string `json:"status"`
|
|
Attempts int `json:"attempts,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type RewardOutbox interface {
|
|
EnqueueRewardGrants(context.Context, []RewardGrant) ([]RewardOutboxEvent, error)
|
|
PendingRewardGrants(context.Context, int) ([]RewardOutboxEvent, error)
|
|
}
|
|
|
|
type MemoryRewardOutbox struct {
|
|
mu sync.Mutex
|
|
now func() time.Time
|
|
order []string
|
|
events map[string]RewardOutboxEvent
|
|
}
|
|
|
|
type SeasonSettlementOptions struct {
|
|
SettlementID string `json:"settlement_id"`
|
|
RewardRules []RewardRule `json:"reward_rules,omitempty"`
|
|
RewardOutbox RewardOutbox `json:"-"`
|
|
CaptureLimit int `json:"capture_limit,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
OperatorID string `json:"operator_id,omitempty"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
}
|
|
|
|
type SeasonSettlement struct {
|
|
SettlementID string `json:"settlement_id"`
|
|
LeaderboardID string `json:"leaderboard_id"`
|
|
Capture Capture `json:"capture"`
|
|
Grants []RewardGrant `json:"grants"`
|
|
OutboxEvents []RewardOutboxEvent `json:"outbox_events,omitempty"`
|
|
Reset Definition `json:"reset"`
|
|
}
|
|
|
|
func NewMemoryRewardOutbox(now func() time.Time) *MemoryRewardOutbox {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &MemoryRewardOutbox{
|
|
now: now,
|
|
events: make(map[string]RewardOutboxEvent),
|
|
}
|
|
}
|
|
|
|
func (o *MemoryRewardOutbox) EnqueueRewardGrants(ctx context.Context, grants []RewardGrant) ([]RewardOutboxEvent, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if o == nil {
|
|
return nil, ErrNilRewardOutbox
|
|
}
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
// 内存 outbox 以 grant.IdempotencyKey 去重;同 key 不重复追加奖励事件,方便结算任务安全重跑。
|
|
events := make([]RewardOutboxEvent, 0, len(grants))
|
|
for _, grant := range grants {
|
|
normalized := cloneRewardGrant(grant)
|
|
normalized.IdempotencyKey = strings.TrimSpace(normalized.IdempotencyKey)
|
|
if normalized.IdempotencyKey == "" {
|
|
return nil, ErrInvalidSettlement
|
|
}
|
|
if existing, ok := o.events[normalized.IdempotencyKey]; ok {
|
|
events = append(events, cloneRewardOutbox(existing))
|
|
continue
|
|
}
|
|
now := o.now().UTC()
|
|
event := RewardOutboxEvent{
|
|
ID: normalized.IdempotencyKey,
|
|
Grant: normalized,
|
|
Status: RewardOutboxPending,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
o.order = append(o.order, event.ID)
|
|
o.events[event.ID] = event
|
|
events = append(events, cloneRewardOutbox(event))
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func (o *MemoryRewardOutbox) PendingRewardGrants(ctx context.Context, limit int) ([]RewardOutboxEvent, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if o == nil {
|
|
return nil, ErrNilRewardOutbox
|
|
}
|
|
if limit <= 0 || limit > maxListLimit {
|
|
limit = maxListLimit
|
|
}
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
events := make([]RewardOutboxEvent, 0, limit)
|
|
for _, id := range o.order {
|
|
event := o.events[id]
|
|
if event.Status != RewardOutboxPending {
|
|
continue
|
|
}
|
|
events = append(events, cloneRewardOutbox(event))
|
|
if len(events) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func CloseSeason(ctx context.Context, runtime Runtime, leaderboardID string, options SeasonSettlementOptions) (SeasonSettlement, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return SeasonSettlement{}, err
|
|
}
|
|
if runtime == nil {
|
|
return SeasonSettlement{}, ErrLeaderboardNotFound
|
|
}
|
|
settlementID := strings.TrimSpace(options.SettlementID)
|
|
if settlementID == "" {
|
|
return SeasonSettlement{}, ErrInvalidSettlement
|
|
}
|
|
// 结算顺序固定为:截榜快照 -> 生成奖励幂等键 -> 写奖励 outbox -> 可选清榜,避免清榜后丢奖励证据。
|
|
capture, err := runtime.Capture(ctx, leaderboardID, CaptureOptions{
|
|
Limit: options.CaptureLimit,
|
|
Reason: options.Reason,
|
|
OperatorID: options.OperatorID,
|
|
RequestID: options.RequestID,
|
|
Metadata: map[string]string{"settlement_id": settlementID},
|
|
})
|
|
if err != nil {
|
|
return SeasonSettlement{}, err
|
|
}
|
|
grants, err := BuildRewardGrants(capture, RewardOptions{SettlementID: settlementID, Rules: options.RewardRules})
|
|
if err != nil {
|
|
return SeasonSettlement{}, err
|
|
}
|
|
var events []RewardOutboxEvent
|
|
if len(grants) > 0 {
|
|
if options.RewardOutbox == nil {
|
|
return SeasonSettlement{}, ErrNilRewardOutbox
|
|
}
|
|
events, err = options.RewardOutbox.EnqueueRewardGrants(ctx, grants)
|
|
if err != nil {
|
|
return SeasonSettlement{}, err
|
|
}
|
|
}
|
|
reset, err := runtime.Reset(ctx, leaderboardID)
|
|
if err != nil {
|
|
return SeasonSettlement{}, err
|
|
}
|
|
return SeasonSettlement{
|
|
SettlementID: settlementID,
|
|
LeaderboardID: strings.TrimSpace(leaderboardID),
|
|
Capture: cloneCapture(capture),
|
|
Grants: cloneRewardGrants(grants),
|
|
OutboxEvents: cloneRewardEvents(events),
|
|
Reset: cloneDefinition(reset),
|
|
}, nil
|
|
}
|
|
|
|
func BuildRewardGrants(capture Capture, options RewardOptions) ([]RewardGrant, error) {
|
|
settlementID := strings.TrimSpace(options.SettlementID)
|
|
if settlementID == "" || strings.TrimSpace(capture.LeaderboardID) == "" {
|
|
return nil, ErrInvalidSettlement
|
|
}
|
|
rules, err := normalizeRewardRules(options.Rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rules) == 0 || len(capture.Records) == 0 {
|
|
return []RewardGrant{}, nil
|
|
}
|
|
// 奖励幂等键由 settlement/leaderboard/player/rank 组成,同一次结算重放不会重复发奖。
|
|
grants := make([]RewardGrant, 0, len(capture.Records))
|
|
for _, record := range capture.Records {
|
|
if strings.TrimSpace(record.OwnerID) == "" || record.Rank <= 0 {
|
|
continue
|
|
}
|
|
rule, ok := rewardRuleForRank(rules, record.Rank)
|
|
if !ok {
|
|
continue
|
|
}
|
|
grants = append(grants, RewardGrant{
|
|
SettlementID: settlementID,
|
|
LeaderboardID: strings.TrimSpace(capture.LeaderboardID),
|
|
OwnerID: strings.TrimSpace(record.OwnerID),
|
|
Rank: record.Rank,
|
|
Score: record.Score,
|
|
Subscore: record.Subscore,
|
|
IdempotencyKey: rewardGrantKey(settlementID, capture.LeaderboardID, record.OwnerID, record.Rank),
|
|
Payload: cloneRewardPayload(rule.Payload),
|
|
})
|
|
}
|
|
return grants, nil
|
|
}
|
|
|
|
func normalizeRewardRules(rules []RewardRule) ([]RewardRule, error) {
|
|
out := make([]RewardRule, 0, len(rules))
|
|
for _, rule := range rules {
|
|
if rule.MinRank <= 0 {
|
|
return nil, ErrInvalidSettlement
|
|
}
|
|
if rule.MaxRank <= 0 {
|
|
rule.MaxRank = rule.MinRank
|
|
}
|
|
if rule.MaxRank < rule.MinRank {
|
|
return nil, ErrInvalidSettlement
|
|
}
|
|
if len(rule.Payload) > 0 && !json.Valid(rule.Payload) {
|
|
return nil, ErrInvalidSettlement
|
|
}
|
|
rule.Payload = cloneRewardPayload(rule.Payload)
|
|
out = append(out, rule)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func rewardRuleForRank(rules []RewardRule, rank int) (RewardRule, bool) {
|
|
for _, rule := range rules {
|
|
if rank >= rule.MinRank && rank <= rule.MaxRank {
|
|
return rule, true
|
|
}
|
|
}
|
|
return RewardRule{}, false
|
|
}
|
|
|
|
func rewardGrantKey(settlementID, leaderboardID, ownerID string, rank int) string {
|
|
return fmt.Sprintf("leaderboard:settlement:v1:%s:%s:%s:%d",
|
|
encodeKeyPart(settlementID),
|
|
encodeKeyPart(leaderboardID),
|
|
encodeKeyPart(ownerID),
|
|
rank,
|
|
)
|
|
}
|
|
|
|
func cloneRewardPayload(payload json.RawMessage) json.RawMessage {
|
|
if payload == nil {
|
|
return nil
|
|
}
|
|
return append(json.RawMessage(nil), payload...)
|
|
}
|
|
|
|
func cloneRewardGrant(grant RewardGrant) RewardGrant {
|
|
grant.Payload = cloneRewardPayload(grant.Payload)
|
|
return grant
|
|
}
|
|
|
|
func cloneRewardGrants(grants []RewardGrant) []RewardGrant {
|
|
out := make([]RewardGrant, len(grants))
|
|
for i, grant := range grants {
|
|
out[i] = cloneRewardGrant(grant)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneRewardOutbox(event RewardOutboxEvent) RewardOutboxEvent {
|
|
event.Grant = cloneRewardGrant(event.Grant)
|
|
return event
|
|
}
|
|
|
|
func cloneRewardEvents(events []RewardOutboxEvent) []RewardOutboxEvent {
|
|
out := make([]RewardOutboxEvent, len(events))
|
|
for i, event := range events {
|
|
out[i] = cloneRewardOutbox(event)
|
|
}
|
|
return out
|
|
}
|