Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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
}