服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
970 lines
37 KiB
Go
970 lines
37 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"hash/fnv"
|
||
"maps"
|
||
"math"
|
||
"math/rand/v2"
|
||
"sort"
|
||
"strconv"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
const (
|
||
runtimeRaidRewardReceiptVersion = 1
|
||
runtimeRaidRewardReceiptValueKey = "runtime_raid_reward_delivery_v1"
|
||
// runtimeRaidRewardResultType is the 0x0258 result type that actually makes
|
||
// the client reveal the phase rewards.
|
||
//
|
||
// The 90 client maps the body's first byte through the jump table at
|
||
// 0x167DF7C (f(0)=0, f(1)=0, f(2)=1, f(3)=2, f(>=4)=4) and only resultType 3
|
||
// reaches 0x1683B90, the reward-window refresh (guarded at 0x1D7CC73
|
||
// `cmp byte ptr [ebp-0x31],3 / jne`, whose only call site in the image is
|
||
// there). That guard is also the only place the ordinal gate
|
||
// (0x1D7CCC3 / 0x1D7CD0E, "因未满足条件�?无法获得奖励�? = 60456) runs.
|
||
//
|
||
// Live evidence overrides the earlier reading, though: the 2026-09-16T12:28
|
||
// settlement -- the last one this server sent with resultType 1, captured in
|
||
// the user's own screenshot -- did reveal both squad cards, while every
|
||
// resultType 3 settlement since 13:11 left them face down. The row delivery
|
||
// 0x1D7CBDA `call [model.vtable[0]](resultType, memberKey, cardIndex, sentinel)`
|
||
// is what the window acts on, so the refresh at 0x1683B90 is not the reveal.
|
||
// Keep 1 (86JP's own value) until R2 lands the matching cardIndex semantics.
|
||
runtimeRaidRewardResultType = 1
|
||
)
|
||
|
||
// currentRaidRewardWireCardIndex is the wire ordinal of one member's clicked card
|
||
// slot. Keep it identical to the clicked slot until R2's seat semantics land.
|
||
//
|
||
// The 0/2 normalization below was added for the resultType==3 branch, whose seat
|
||
// gate (0x1D7CCC3 / 0x1D7CCD7 test ordinal 2, 0x1D7CD0E / 0x1D7CD1B test ordinal
|
||
// 0) pops 60456 "因未满足条件�?无法获得奖励�? for anything else -- live
|
||
// 2026-09-16T13:11Z, character 22 clicked slot 1 and got that popup. The client
|
||
// sends the raw clicked slot (0..3, sender 0x0165F390), and the 12:28 settlement
|
||
// that did reveal the cards carried exactly those raw ordinals (1 and 2), so the
|
||
// normalization only deserves to exist while resultType is 3.
|
||
func currentRaidRewardWireCardIndex(clicked byte) byte {
|
||
return clicked
|
||
}
|
||
|
||
var (
|
||
errRuntimeRaidRewardRepository = errors.New("runtime raid reward repository is unavailable")
|
||
errRuntimeRaidRewardReceipt = errors.New("runtime raid reward receipt is invalid")
|
||
errRuntimeRaidRewardGrant = errors.New("runtime raid reward grant is invalid")
|
||
)
|
||
|
||
type runtimeRaidRewardInventoryRefresh struct {
|
||
CharacterID uint16
|
||
Slots []int16
|
||
}
|
||
|
||
type runtimeRaidRewardDeliveryCache struct {
|
||
Phase byte
|
||
Token uint64
|
||
ReceiptScope string
|
||
Rewards raid.RaidSettlementRewards
|
||
RewardBody []byte
|
||
InventoryRefreshes []runtimeRaidRewardInventoryRefresh
|
||
}
|
||
|
||
type runtimeRaidRewardCompletionKey struct {
|
||
RaidKey uint32
|
||
Phase byte
|
||
Token uint64
|
||
}
|
||
|
||
type runtimeRaidRewardReceipt struct {
|
||
Version int `json:"version"`
|
||
RaidKey uint32 `json:"raid_key"`
|
||
StartAttempt uint64 `json:"start_attempt"`
|
||
StartedAtUnixNano int64 `json:"started_at_unix_nano"`
|
||
Phase byte `json:"phase"`
|
||
Token uint64 `json:"token"`
|
||
Rewards raid.RaidSettlementRewards `json:"rewards"`
|
||
RewardBody []byte `json:"reward_body"`
|
||
Members []runtimeRaidRewardReceiptMember `json:"members"`
|
||
CommittedAtUnixNano int64 `json:"committed_at_unix_nano"`
|
||
}
|
||
|
||
type runtimeRaidRewardReceiptMember struct {
|
||
CharacterID uint16 `json:"character_id"`
|
||
Slots []int16 `json:"slots"`
|
||
PostState []runtimeRaidRewardReceiptSlotState `json:"post_state"`
|
||
// Overflow 是这一位成员**背包那一栏放不下、改走邮件**的奖励行。
|
||
//
|
||
// 它同时是重投的依据:邮件在事务外发(邮件有自己的工作单元),所以"回执已落、
|
||
// 邮件还没发"这个窗口只能靠回执重放补上 —— deliverRuntimeRaidRewardOverflowMail
|
||
// 每次都按这份清单重投,AppendSystemMail 的 completion_key 保证只落一封。
|
||
Overflow []runtimeRaidRewardOverflowRow `json:"overflow,omitempty"`
|
||
}
|
||
|
||
// runtimeRaidRewardOverflowRow 是一行走邮件的奖励(已经是解析后的最终物品)。
|
||
type runtimeRaidRewardOverflowRow struct {
|
||
ItemID int64 `json:"item_id"`
|
||
Count int64 `json:"count"`
|
||
}
|
||
|
||
type runtimeRaidRewardReceiptSlotState struct {
|
||
Slot int16 `json:"slot"`
|
||
Stack dnfrepo.ItemStack `json:"stack"`
|
||
}
|
||
|
||
type runtimeRaidRewardGrantPlan struct {
|
||
Resolved antonRewardPVFResolved
|
||
Definition dungeonDropItemDefinition
|
||
}
|
||
|
||
type runtimeRaidRewardMemberPlan struct {
|
||
Member runtimeRaidMemberState
|
||
CardIndex byte
|
||
Grants []runtimeRaidRewardGrantPlan
|
||
}
|
||
|
||
type runtimeRaidRewardPlan struct {
|
||
Rewards raid.RaidSettlementRewards
|
||
Body []byte
|
||
Members []runtimeRaidRewardMemberPlan
|
||
}
|
||
|
||
type runtimeRaidRewardRandomSource struct {
|
||
next func() uint64
|
||
}
|
||
|
||
func (source runtimeRaidRewardRandomSource) Uint64() uint64 {
|
||
if source.next != nil {
|
||
return source.next()
|
||
}
|
||
return rand.Uint64()
|
||
}
|
||
|
||
func (s *Service) runtimeRaidAntonRewardCatalog() (*antonRewardPVFCatalog, error) {
|
||
if s == nil {
|
||
return nil, errAntonRewardPVFCatalogRequired
|
||
}
|
||
s.raidAntonRewardMu.Lock()
|
||
defer s.raidAntonRewardMu.Unlock()
|
||
if s.raidAntonReward != nil || s.raidAntonRewardErr != nil {
|
||
return s.raidAntonReward, s.raidAntonRewardErr
|
||
}
|
||
items, err := s.currentPVFItemCatalog()
|
||
if err == nil {
|
||
var config *raid.AntonConfig
|
||
config, err = s.runtimeRaidAntonConfig()
|
||
if err == nil {
|
||
s.raidAntonReward, err = newAntonRewardPVFCatalog(items, config)
|
||
}
|
||
}
|
||
if err != nil {
|
||
s.raidAntonRewardErr = fmt.Errorf("load current anton reward catalog: %w", err)
|
||
}
|
||
return s.raidAntonReward, s.raidAntonRewardErr
|
||
}
|
||
|
||
func (s *Service) runtimeRaidLukeRewardCatalog() (*antonRewardPVFCatalog, error) {
|
||
if s == nil {
|
||
return nil, errAntonRewardPVFCatalogRequired
|
||
}
|
||
s.raidLukeRewardMu.Lock()
|
||
defer s.raidLukeRewardMu.Unlock()
|
||
if s.raidLukeReward != nil || s.raidLukeRewardErr != nil {
|
||
return s.raidLukeReward, s.raidLukeRewardErr
|
||
}
|
||
items, err := s.currentPVFItemCatalog()
|
||
if err == nil {
|
||
var config *raid.LukeConfig
|
||
config, err = s.runtimeRaidLukeConfig()
|
||
if err == nil {
|
||
s.raidLukeReward, err = newRaidRewardPVFCatalog(items, config.Raid, nil, false, currentLukeRewardPhaseCount)
|
||
}
|
||
}
|
||
if err != nil {
|
||
s.raidLukeRewardErr = fmt.Errorf("load current luke reward catalog: %w", err)
|
||
}
|
||
return s.raidLukeReward, s.raidLukeRewardErr
|
||
}
|
||
|
||
func (s *Service) runtimeRaidRewardCatalogForState(state runtimeRaidState) (*antonRewardPVFCatalog, error) {
|
||
// 超时空的奖励表不在 etc/raid/fiendwar 之外的任何地方:它的 [CLEAR REWARD ITEM]
|
||
// 由 parseFiendWarBattle 挂到合成的 Battle.Raid 上,所以目录用 PVF 物品表 +
|
||
// runtimeRaidBaseConfig 现搭,而不是去读 luke/anton 的奖励文档。
|
||
if state.RaidMode == runtimeRaidOwnerFiendWar {
|
||
items, err := s.currentPVFItemCatalog()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return newRaidRewardPVFCatalog(items, runtimeRaidBaseConfig(state), nil, false, currentFiendWarRewardPhaseCount)
|
||
}
|
||
if state.RaidMode == runtimeRaidOwnerLuke {
|
||
return s.runtimeRaidLukeRewardCatalog()
|
||
}
|
||
return s.runtimeRaidAntonRewardCatalog()
|
||
}
|
||
|
||
func (s *Service) commitRuntimeRaidSettlementRewards(
|
||
raidKey uint32,
|
||
phase byte,
|
||
now time.Time,
|
||
) (runtimeRaidSettlementRewardDelivery, error) {
|
||
if s == nil {
|
||
return runtimeRaidSettlementRewardDelivery{}, errRuntimeRaidRewardRepository
|
||
}
|
||
now = runtimeRaidNormalizeTime(now)
|
||
s.raidRewardCommitMu.Lock()
|
||
defer s.raidRewardCommitMu.Unlock()
|
||
|
||
state, token, err := s.reserveRuntimeRaidRewardDelivery(raidKey, phase, now)
|
||
if err != nil {
|
||
return runtimeRaidSettlementRewardDelivery{}, err
|
||
}
|
||
if cache := state.Lifecycle.RewardDelivery; cache != nil && state.Lifecycle.RewardDeliveryCommitted {
|
||
return runtimeRaidRewardDeliveryFromCache(state, cache), nil
|
||
}
|
||
|
||
receiptScope := runtimeRaidRewardReceiptScope(state, phase, token)
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.RaidRewards == nil {
|
||
return runtimeRaidSettlementRewardDelivery{}, errRuntimeRaidRewardRepository
|
||
}
|
||
|
||
characterIDs := make([]string, 0, len(state.FrozenMembers))
|
||
for _, member := range state.FrozenMembers {
|
||
characterIDs = append(characterIDs, strconv.Itoa(int(member.CharID)))
|
||
}
|
||
var committed runtimeRaidRewardReceipt
|
||
var plan runtimeRaidRewardPlan
|
||
planResolved := false
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
err = repositories.RaidRewards.WithinRaidRewards(
|
||
ctx,
|
||
characterIDs,
|
||
receiptScope,
|
||
func(inventories dnfrepo.InventoryRepository, receipts dnfrepo.SettingsRepository) error {
|
||
receiptRecord, found, loadErr := receipts.Load(ctx, receiptScope)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if found {
|
||
existing, decodeErr := decodeRuntimeRaidRewardReceipt(receiptRecord)
|
||
if decodeErr != nil {
|
||
return decodeErr
|
||
}
|
||
if validateErr := s.validateRuntimeRaidRewardReceipt(&state, phase, token, existing); validateErr != nil {
|
||
return validateErr
|
||
}
|
||
if validateErr := validateRuntimeRaidRewardReceiptInventories(ctx, inventories, existing); validateErr != nil {
|
||
return validateErr
|
||
}
|
||
committed = existing
|
||
return nil
|
||
}
|
||
|
||
if !planResolved {
|
||
catalog, catalogErr := s.runtimeRaidRewardCatalogForState(state)
|
||
if catalogErr != nil {
|
||
return catalogErr
|
||
}
|
||
resolved, resolveErr := s.resolveRuntimeRaidRewardPlan(state, phase, now, catalog)
|
||
if resolveErr != nil {
|
||
return resolveErr
|
||
}
|
||
plan = resolved
|
||
planResolved = true
|
||
}
|
||
|
||
receipt := runtimeRaidRewardReceipt{
|
||
Version: runtimeRaidRewardReceiptVersion,
|
||
RaidKey: state.RaidKey,
|
||
StartAttempt: state.StartAttempt,
|
||
StartedAtUnixNano: state.Lifecycle.StartedAt.UnixNano(),
|
||
Phase: phase,
|
||
Token: token,
|
||
Rewards: cloneRuntimeRaidSettlementRewards(plan.Rewards),
|
||
RewardBody: append([]byte(nil), plan.Body...),
|
||
Members: make([]runtimeRaidRewardReceiptMember, 0, len(plan.Members)),
|
||
CommittedAtUnixNano: now.UnixNano(),
|
||
}
|
||
for _, memberPlan := range plan.Members {
|
||
characterID := strconv.Itoa(int(memberPlan.Member.CharID))
|
||
inventory, inventoryFound, inventoryErr := inventories.Load(ctx, characterID)
|
||
if inventoryErr != nil {
|
||
return inventoryErr
|
||
}
|
||
if !inventoryFound {
|
||
return fmt.Errorf("%w: character=%s inventory missing", errRuntimeRaidRewardGrant, characterID)
|
||
}
|
||
inventory = dnfrepo.CloneInventory(inventory)
|
||
touched := make(map[int16]struct{})
|
||
overflow := make([]runtimeRaidRewardOverflowRow, 0, 1)
|
||
for _, grant := range memberPlan.Grants {
|
||
slots, grantErr := grantCurrentCeraShopProduct(
|
||
&inventory,
|
||
grant.Definition,
|
||
grant.Resolved.Count,
|
||
s.currentInventoryPlacementMainSlotCount(ctx, &inventory, characterID),
|
||
)
|
||
if grantErr != nil {
|
||
// 背包那一栏放不下 —— **这一位**的行改走邮件,不是整队失败。
|
||
//
|
||
// 这条路上的"全队一个事务"曾经是致命的:只要队里任何一个人的
|
||
// 对应分页满了,第一个 grantErr 就让整个闭包返回错误、事务整体
|
||
// 回滚,四个人的奖励一个都发不出去,回执也不落,客户端的牌就
|
||
// 永远停在"翻不开"(真机 2026-10-02 16:42,raid 12058625,
|
||
// 分片 20261002-019:
|
||
// game-raid-reward-auto-flip-commit-failed flipped=4
|
||
// error=... item=490008672 ... range=65..120)。
|
||
//
|
||
// grantCurrentCeraShopProductWithCarryLimit 在失败时已经把背包
|
||
// 快照回滚了(它自己的注释就写着"由调用方按原有溢出(邮件)分支
|
||
// 处理"),所以这里 continue 到下一行是安全的:不会有"背包放进
|
||
// 一部分 + 邮件又发一整份"的半截状态。
|
||
// 两个哨兵一起认:dungeon_drop_pickup.go 的映射会把底层错误
|
||
// 用 %v 吃掉、只留 bridge 自己的哨兵,所以只认一个不够稳
|
||
// (与 epic_book.go:208 / adventure_runtime.go:48 同一写法)。
|
||
if errors.Is(grantErr, errDungeonPickupInventoryFull) ||
|
||
errors.Is(grantErr, dnfdungeon.ErrPickupInventoryFull) {
|
||
overflow = append(overflow, runtimeRaidRewardOverflowRow{
|
||
ItemID: int64(grant.Resolved.ItemID),
|
||
Count: int64(grant.Resolved.Count),
|
||
})
|
||
continue
|
||
}
|
||
return fmt.Errorf(
|
||
"%w: character=%s item=%d count=%d: %v",
|
||
errRuntimeRaidRewardGrant,
|
||
characterID,
|
||
grant.Resolved.ItemID,
|
||
grant.Resolved.Count,
|
||
grantErr,
|
||
)
|
||
}
|
||
for _, slot := range slots {
|
||
if slot > math.MaxInt16 {
|
||
return fmt.Errorf("%w: character=%s slot=%d", errRuntimeRaidRewardGrant, characterID, slot)
|
||
}
|
||
touched[int16(slot)] = struct{}{}
|
||
}
|
||
}
|
||
// 整份奖励都进了邮件时这一位没有任何格位被动过,不必写回背包
|
||
// (克隆出来的 inventory 直接丢掉)。
|
||
if len(touched) != 0 {
|
||
inventory.UpdatedAt = now
|
||
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
||
return saveErr
|
||
}
|
||
}
|
||
slots := sortedRuntimeRaidRewardSlots(touched)
|
||
postState, snapshotErr := runtimeRaidRewardReceiptPostState(inventory, slots)
|
||
if snapshotErr != nil {
|
||
return fmt.Errorf("%w: character=%s: %v", errRuntimeRaidRewardReceipt, characterID, snapshotErr)
|
||
}
|
||
receipt.Members = append(receipt.Members, runtimeRaidRewardReceiptMember{
|
||
CharacterID: memberPlan.Member.CharID,
|
||
Slots: slots,
|
||
PostState: postState,
|
||
Overflow: overflow,
|
||
})
|
||
}
|
||
rawReceipt, encodeErr := json.Marshal(receipt)
|
||
if encodeErr != nil {
|
||
return encodeErr
|
||
}
|
||
if saveErr := receipts.Save(ctx, dnfrepo.SettingsRecord{
|
||
Scope: receiptScope,
|
||
Values: map[string]string{
|
||
runtimeRaidRewardReceiptValueKey: string(rawReceipt),
|
||
},
|
||
UpdatedAt: now,
|
||
}); saveErr != nil {
|
||
return saveErr
|
||
}
|
||
committed = receipt
|
||
return nil
|
||
},
|
||
)
|
||
if err != nil {
|
||
return runtimeRaidSettlementRewardDelivery{}, err
|
||
}
|
||
// 溢出邮件在事务**外**发:邮件有自己的工作单元(WithinMailboxAssets),进不了
|
||
// WithinRaidRewards 那个事务。奖励已经 durable 提交,所以这里失败只记日志、绝不
|
||
// 回滚 —— 与 currentUpgradeRefundMailer 的口径一致(为了补一封邮件去撤销一次
|
||
// 已经落库的结算,是更坏的结局)。回执里记着 Overflow,所以重投随时能补上,
|
||
// AppendSystemMail 的 completion_key 保证同一份回执只落一封邮件。
|
||
s.deliverRuntimeRaidRewardOverflowMail(ctx, receiptScope, committed)
|
||
cache, err := s.runtimeRaidRewardDeliveryCacheFromReceipt(&state, receiptScope, committed)
|
||
if err != nil {
|
||
return runtimeRaidSettlementRewardDelivery{}, err
|
||
}
|
||
state, err = s.storeRuntimeRaidRewardDeliveryCommitted(raidKey, phase, token, cache, now)
|
||
if err != nil {
|
||
return runtimeRaidSettlementRewardDelivery{}, err
|
||
}
|
||
return runtimeRaidRewardDeliveryFromCache(state, cache), nil
|
||
}
|
||
|
||
func (s *Service) completeRuntimeRaidSettlementRewards(
|
||
raidKey uint32,
|
||
phase byte,
|
||
token uint64,
|
||
now time.Time,
|
||
) (runtimeRaidSettlementRewardCompletion, error) {
|
||
if s == nil || token == 0 {
|
||
return runtimeRaidSettlementRewardCompletion{}, errRuntimeRaidRewardToken
|
||
}
|
||
now = runtimeRaidNormalizeTime(now)
|
||
s.raidRewardCommitMu.Lock()
|
||
defer s.raidRewardCommitMu.Unlock()
|
||
key := runtimeRaidRewardCompletionKey{RaidKey: raidKey, Phase: phase, Token: token}
|
||
if completed, found := s.raidRewardCompleted[key]; found {
|
||
return cloneRuntimeRaidRewardCompletion(completed), nil
|
||
}
|
||
if err := s.validateRuntimeRaidRewardDeliveryCommitted(raidKey, phase, token); err != nil {
|
||
return runtimeRaidSettlementRewardCompletion{}, err
|
||
}
|
||
|
||
switch phase {
|
||
case runtimeRaidDungeonPhaseOne:
|
||
if s.runtimeRaidIsSingle(raidKey) {
|
||
finished, err := s.finalizeRuntimeRaid(raidKey, token, now)
|
||
if err != nil {
|
||
return runtimeRaidSettlementRewardCompletion{}, err
|
||
}
|
||
completion := runtimeRaidSettlementRewardCompletion{Raid: finished}
|
||
s.storeRaidRewardCompletionLocked(key, cloneRuntimeRaidRewardCompletion(completion))
|
||
return completion, nil
|
||
}
|
||
state, timer, err := s.enterRuntimeRaidStandby(raidKey, phase, now)
|
||
if err != nil {
|
||
return runtimeRaidSettlementRewardCompletion{}, err
|
||
}
|
||
return runtimeRaidSettlementRewardCompletion{Raid: state, StandbyTimer: &timer}, nil
|
||
case runtimeRaidDungeonPhaseTwo:
|
||
state, err := s.finalizeRuntimeRaid(raidKey, token, now)
|
||
if err != nil {
|
||
return runtimeRaidSettlementRewardCompletion{}, err
|
||
}
|
||
completion := runtimeRaidSettlementRewardCompletion{Raid: state}
|
||
s.storeRaidRewardCompletionLocked(key, cloneRuntimeRaidRewardCompletion(completion))
|
||
return completion, nil
|
||
default:
|
||
return runtimeRaidSettlementRewardCompletion{}, errRuntimeRaidSettlementPhase
|
||
}
|
||
}
|
||
|
||
func (s *Service) reserveRuntimeRaidRewardDelivery(
|
||
raidKey uint32,
|
||
phase byte,
|
||
now time.Time,
|
||
) (runtimeRaidState, uint64, error) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[raidKey]
|
||
if state == nil {
|
||
return runtimeRaidState{}, 0, errRuntimeRaidNotFound
|
||
}
|
||
state.ensureRuntimeRaidLifecycleMapsLocked()
|
||
if state.Lifecycle.Status == runtimeRaidLifecycleStandby && phase == runtimeRaidDungeonPhaseOne &&
|
||
state.Lifecycle.Phase == phase && state.Lifecycle.RewardDeliveryCommitted && state.Lifecycle.RewardDelivery != nil {
|
||
return cloneRuntimeRaidState(*state), state.Lifecycle.RewardToken, nil
|
||
}
|
||
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards || state.Lifecycle.Phase != phase ||
|
||
(phase != runtimeRaidDungeonPhaseOne && phase != runtimeRaidDungeonPhaseTwo) ||
|
||
!s.runtimeRaidRewardRosterConfirmedLocked(state) {
|
||
return runtimeRaidState{}, 0, errRuntimeRaidRewardNotReady
|
||
}
|
||
token := state.Lifecycle.RewardToken
|
||
if token == 0 {
|
||
// The eligible roster is materialised here, once: the token and every
|
||
// receipt planned from it must describe the same member set, so the record
|
||
// of members the settlement gave up on stops moving at this instant.
|
||
s.freezeRuntimeRaidRewardSkipsLocked(state)
|
||
token = s.runtimeRaidRewardTokenLocked(state, phase)
|
||
state.Lifecycle.RewardToken = token
|
||
if state.Lifecycle.RewardOpenedAt.IsZero() {
|
||
state.Lifecycle.RewardOpenedAt = now
|
||
}
|
||
state.Lifecycle.Revision++
|
||
}
|
||
if cache := state.Lifecycle.RewardDelivery; cache != nil && (cache.Token != token || cache.Phase != phase) {
|
||
return runtimeRaidState{}, 0, errRuntimeRaidRewardToken
|
||
}
|
||
return cloneRuntimeRaidState(*state), token, nil
|
||
}
|
||
|
||
func runtimeRaidRewardPoolKinds(catalog *antonRewardPVFCatalog, phaseIndex int) []string {
|
||
granted := [...]string{"party_card", "squad_item"}
|
||
kinds := make([]string, 0, len(granted))
|
||
for _, kind := range granted {
|
||
pool, ok := catalog.Pool(phaseIndex, kind)
|
||
if !ok || len(pool.Entries) == 0 || pool.TotalWeight == 0 {
|
||
continue
|
||
}
|
||
kinds = append(kinds, kind)
|
||
}
|
||
return kinds
|
||
}
|
||
|
||
func (s *Service) resolveRuntimeRaidRewardPlan(
|
||
state runtimeRaidState,
|
||
phase byte,
|
||
now time.Time,
|
||
catalog *antonRewardPVFCatalog,
|
||
) (runtimeRaidRewardPlan, error) {
|
||
if catalog == nil || runtimeRaidBaseConfig(state) == nil || phase == 0 {
|
||
return runtimeRaidRewardPlan{}, errRuntimeRaidSettlementPhase
|
||
}
|
||
phaseIndex, ok := runtimeRaidRewardPhaseIndex(state, phase)
|
||
if !ok {
|
||
return runtimeRaidRewardPlan{}, errRuntimeRaidSettlementPhase
|
||
}
|
||
// 本阶段实际声明了哪些奖池。卢克一阶段(etc/raid/luke/luke.etc [PHASE] 进入源生宫殿)
|
||
// 只声明 party_card 与 pcroom_card,没有 squad_item;以前这里写死
|
||
// {"party_card", "squad_item"},问它要一个不存在的 squad_item 池会让 ResolvePool
|
||
// 直接报 "anton reward PVF pool is unresolved: phase=0 kind=squad_item" ——
|
||
// 整个结算因此失败:选牌后不发奖、不进等待、二阶段永远起不来。
|
||
// (移植上游 bb52082 之后的 runtimeRaidRewardPoolKinds;本树此前漏了它)
|
||
poolKinds := runtimeRaidRewardPoolKinds(catalog, phaseIndex)
|
||
if len(poolKinds) == 0 {
|
||
return runtimeRaidRewardPlan{}, fmt.Errorf("%w: phase=%d no granted reward pool", errRuntimeRaidRewardGrant, phase)
|
||
}
|
||
members := s.runtimeRaidRewardEligibleMembersLocked(&state)
|
||
runtimeRaidSortMembers(members)
|
||
plan := runtimeRaidRewardPlan{
|
||
Rewards: raid.RaidSettlementRewards{
|
||
ResultType: runtimeRaidRewardResultType,
|
||
Members: make([]raid.RaidSettlementMemberRewards, 0, len(members)),
|
||
},
|
||
Members: make([]runtimeRaidRewardMemberPlan, 0, len(members)),
|
||
}
|
||
rng := runtimeRaidRewardRandomSource{next: s.raidRewardRandom}
|
||
for _, member := range members {
|
||
selection, found := state.Lifecycle.RewardSelections[member.CharID]
|
||
if !found || selection.Mode != 0 {
|
||
return runtimeRaidRewardPlan{}, errRuntimeRaidRewardRosterPending
|
||
}
|
||
memberPlan := runtimeRaidRewardMemberPlan{Member: member, CardIndex: selection.Index, Grants: make([]runtimeRaidRewardGrantPlan, 0, len(poolKinds))}
|
||
// selection.Index is already normalised to the client's accepted ordinals
|
||
// (0 or 2) by selectRuntimeRaidSettlementCard, so the stored receipt, the
|
||
// plan and the wire row agree.
|
||
wireMember := raid.RaidSettlementMemberRewards{MemberKey: member.CharID, CardIndex: selection.Index, Rewards: make([]raid.RaidSettlementReward, 0, len(poolKinds))}
|
||
// 超时空:清数没达到 [MINIMAL DUNGEON CLEAR COUNT] 的成员照样走完结算
|
||
// 流程(要确认影片、要选牌,否则整团的结算门永远等不满),但一个池子都不给。
|
||
// runtimeFiendWarRewardEligible 对安东/卢克恒真,所以这条对它们零影响。
|
||
memberKinds := poolKinds
|
||
if !runtimeFiendWarRewardEligible(state, member.CharID) {
|
||
memberKinds = nil
|
||
}
|
||
for _, kind := range memberKinds {
|
||
resolved, err := catalog.ResolvePool(phaseIndex, kind, rng)
|
||
if err != nil {
|
||
return runtimeRaidRewardPlan{}, err
|
||
}
|
||
if resolved.ItemID == 0 || resolved.Count == 0 || resolved.Count > math.MaxUint16 {
|
||
return runtimeRaidRewardPlan{}, fmt.Errorf(
|
||
"%w: character=%d phase=%d kind=%s item=%d count=%d",
|
||
errRuntimeRaidRewardGrant,
|
||
member.CharID,
|
||
phase,
|
||
kind,
|
||
resolved.ItemID,
|
||
resolved.Count,
|
||
)
|
||
}
|
||
definition, err := catalog.ResolveFinalItem(resolved.ItemID)
|
||
if err != nil {
|
||
return runtimeRaidRewardPlan{}, err
|
||
}
|
||
definition, err = currentPVFItemDefinitionForGrantAt(definition, now)
|
||
if err != nil {
|
||
return runtimeRaidRewardPlan{}, err
|
||
}
|
||
if definition.ItemID != resolved.ItemID || definition.Kind != resolved.ItemKind {
|
||
return runtimeRaidRewardPlan{}, fmt.Errorf(
|
||
"%w: character=%d resolved=%d/%s definition=%d/%s",
|
||
errRuntimeRaidRewardGrant,
|
||
member.CharID,
|
||
resolved.ItemID,
|
||
resolved.ItemKind,
|
||
definition.ItemID,
|
||
definition.Kind,
|
||
)
|
||
}
|
||
memberPlan.Grants = append(memberPlan.Grants, runtimeRaidRewardGrantPlan{Resolved: resolved, Definition: definition})
|
||
// The row's first byte is the [STATE REWARD] flag column (1 on the rare
|
||
// squad_item rows). The 0x0258 handler sub_1D7C940 hands it to
|
||
// sub_1681410, which stores it at card +0x44; sub_167FE00 then reads the
|
||
// team board's (resultType 3) card and draws it gold when +0x44 == 1
|
||
// (0x0167FE1E). Sending 0 left every rare pull on a plain card.
|
||
//
|
||
// 上游 257a5b8(2026-09-25)定案:这一列就是 PVF [STATE REWARD] 的最后一列
|
||
// (种类 权重 物品 标记),Flag 由 anton_pvf.go 解析、anton_reward_pvf.go
|
||
// ResolvePool 带出,我们这条线的通路本来就是完整的,只有这里写死了 0。
|
||
wireMember.Rewards = append(wireMember.Rewards, raid.RaidSettlementReward{
|
||
Kind: resolved.Flag,
|
||
ItemIDOrValue: resolved.ItemID,
|
||
CountOrAux: uint16(resolved.Count),
|
||
})
|
||
}
|
||
plan.Members = append(plan.Members, memberPlan)
|
||
plan.Rewards.Members = append(plan.Rewards.Members, wireMember)
|
||
}
|
||
body, err := raid.BuildRaidSettlementRewardsBody(plan.Rewards)
|
||
if err != nil {
|
||
return runtimeRaidRewardPlan{}, err
|
||
}
|
||
plan.Body = body
|
||
return plan, nil
|
||
}
|
||
|
||
func (s *Service) storeRuntimeRaidRewardDeliveryCommitted(
|
||
raidKey uint32,
|
||
phase byte,
|
||
token uint64,
|
||
cache *runtimeRaidRewardDeliveryCache,
|
||
now time.Time,
|
||
) (runtimeRaidState, error) {
|
||
if cache == nil || cache.Token != token || cache.Phase != phase || token == 0 {
|
||
return runtimeRaidState{}, errRuntimeRaidRewardReceipt
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[raidKey]
|
||
if state == nil {
|
||
return runtimeRaidState{}, errRuntimeRaidNotFound
|
||
}
|
||
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards || state.Lifecycle.Phase != phase ||
|
||
state.Lifecycle.RewardToken != token || !s.runtimeRaidRewardRosterConfirmedLocked(state) {
|
||
return runtimeRaidState{}, errRuntimeRaidRewardNotReady
|
||
}
|
||
if existing := state.Lifecycle.RewardDelivery; existing != nil {
|
||
if existing.Token != token || existing.Phase != phase || !bytes.Equal(existing.RewardBody, cache.RewardBody) {
|
||
return runtimeRaidState{}, errRuntimeRaidRewardReceipt
|
||
}
|
||
} else {
|
||
state.Lifecycle.RewardDelivery = cloneRuntimeRaidRewardDeliveryCache(cache)
|
||
}
|
||
if !state.Lifecycle.RewardDeliveryCommitted {
|
||
state.Lifecycle.RewardDeliveryCommitted = true
|
||
state.Lifecycle.RewardDeliveryCommittedAt = now
|
||
state.Lifecycle.Revision++
|
||
}
|
||
return cloneRuntimeRaidState(*state), nil
|
||
}
|
||
|
||
func (s *Service) validateRuntimeRaidRewardDeliveryCommitted(raidKey uint32, phase byte, token uint64) error {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[raidKey]
|
||
if state == nil {
|
||
return errRuntimeRaidNotFound
|
||
}
|
||
cache := state.Lifecycle.RewardDelivery
|
||
if state.Lifecycle.Phase != phase || state.Lifecycle.RewardToken != token || token == 0 ||
|
||
!state.Lifecycle.RewardDeliveryCommitted || cache == nil || cache.Phase != phase || cache.Token != token ||
|
||
!s.runtimeRaidRewardRosterConfirmedLocked(state) {
|
||
return errRuntimeRaidRewardNotReady
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendRuntimeRaidSettlementInventoryRefreshes(refreshes []runtimeRaidRewardInventoryRefresh) error {
|
||
ordered := cloneRuntimeRaidRewardInventoryRefreshes(refreshes)
|
||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].CharacterID < ordered[j].CharacterID })
|
||
for _, refresh := range ordered {
|
||
session, online := s.onlineGameSession(refresh.CharacterID)
|
||
if !online || len(refresh.Slots) == 0 {
|
||
continue
|
||
}
|
||
itemRefreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(refresh.Slots))
|
||
for _, slot := range refresh.Slots {
|
||
itemRefreshes = append(itemRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: dnfrepo.MainInventoryListType,
|
||
SlotIndex: slot,
|
||
})
|
||
}
|
||
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "runtime_anton_raid_reward", itemRefreshes); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// runtimeRaidRewardTokenLocked keys the settlement on the raid, its start
|
||
// attempt and every member the receipt will actually pay -- a member the
|
||
// settlement gave up on is not part of the key, because it is not part of the
|
||
// grant. Caller holds raidMu.
|
||
func (s *Service) runtimeRaidRewardTokenLocked(state *runtimeRaidState, phase byte) uint64 {
|
||
hash := fnv.New64a()
|
||
var buffer [8]byte
|
||
binary.LittleEndian.PutUint32(buffer[:4], state.RaidKey)
|
||
_, _ = hash.Write(buffer[:4])
|
||
binary.LittleEndian.PutUint64(buffer[:], state.StartAttempt)
|
||
_, _ = hash.Write(buffer[:])
|
||
binary.LittleEndian.PutUint64(buffer[:], uint64(state.Lifecycle.StartedAt.UnixNano()))
|
||
_, _ = hash.Write(buffer[:])
|
||
_, _ = hash.Write([]byte{phase})
|
||
members := s.runtimeRaidRewardEligibleMembersLocked(state)
|
||
runtimeRaidSortMembers(members)
|
||
for _, member := range members {
|
||
binary.LittleEndian.PutUint16(buffer[:2], member.CharID)
|
||
_, _ = hash.Write(buffer[:2])
|
||
selection := state.Lifecycle.RewardSelections[member.CharID]
|
||
_, _ = hash.Write([]byte{selection.Mode, selection.Index})
|
||
}
|
||
token := hash.Sum64()
|
||
if token == 0 {
|
||
return 1
|
||
}
|
||
return token
|
||
}
|
||
|
||
func runtimeRaidRewardReceiptScope(state runtimeRaidState, phase byte, token uint64) string {
|
||
return fmt.Sprintf(
|
||
"raid_reward:v1:%08x:%x:%x:%d:%016x",
|
||
state.RaidKey,
|
||
state.StartAttempt,
|
||
uint64(state.Lifecycle.StartedAt.UnixNano()),
|
||
phase,
|
||
token,
|
||
)
|
||
}
|
||
|
||
func decodeRuntimeRaidRewardReceipt(record dnfrepo.SettingsRecord) (runtimeRaidRewardReceipt, error) {
|
||
raw := ""
|
||
if record.Values != nil {
|
||
raw = record.Values[runtimeRaidRewardReceiptValueKey]
|
||
}
|
||
if raw == "" {
|
||
return runtimeRaidRewardReceipt{}, errRuntimeRaidRewardReceipt
|
||
}
|
||
var receipt runtimeRaidRewardReceipt
|
||
if err := json.Unmarshal([]byte(raw), &receipt); err != nil {
|
||
return runtimeRaidRewardReceipt{}, fmt.Errorf("%w: %v", errRuntimeRaidRewardReceipt, err)
|
||
}
|
||
return receipt, nil
|
||
}
|
||
|
||
// validateRuntimeRaidRewardReceipt proves a stored receipt still describes this
|
||
// raid's settlement. The member rows are checked against the same eligible
|
||
// roster the plan was built from (the frozen roster minus the members the
|
||
// settlement gave up on), never against the raw frozen roster: a member that
|
||
// dropped before it confirmed has no row and must not be demanded one.
|
||
//
|
||
// Caller holds raidMu.
|
||
func (s *Service) validateRuntimeRaidRewardReceipt(
|
||
state *runtimeRaidState,
|
||
phase byte,
|
||
token uint64,
|
||
receipt runtimeRaidRewardReceipt,
|
||
) error {
|
||
members := s.runtimeRaidRewardEligibleMembersLocked(state)
|
||
runtimeRaidSortMembers(members)
|
||
if receipt.Version != runtimeRaidRewardReceiptVersion || receipt.RaidKey != state.RaidKey ||
|
||
receipt.StartAttempt != state.StartAttempt || receipt.StartedAtUnixNano != state.Lifecycle.StartedAt.UnixNano() ||
|
||
receipt.Phase != phase || receipt.Token != token || token == 0 || len(receipt.Members) != len(members) {
|
||
return errRuntimeRaidRewardReceipt
|
||
}
|
||
body, err := raid.BuildRaidSettlementRewardsBody(receipt.Rewards)
|
||
if err != nil || !bytes.Equal(body, receipt.RewardBody) {
|
||
return errRuntimeRaidRewardReceipt
|
||
}
|
||
if len(receipt.Rewards.Members) != len(members) {
|
||
return errRuntimeRaidRewardReceipt
|
||
}
|
||
for index, member := range members {
|
||
selection, found := state.Lifecycle.RewardSelections[member.CharID]
|
||
wireMember := receipt.Rewards.Members[index]
|
||
receiptMember := receipt.Members[index]
|
||
if !found || selection.Mode != 0 || wireMember.MemberKey != member.CharID || wireMember.CardIndex != selection.Index ||
|
||
receiptMember.CharacterID != member.CharID ||
|
||
// 有资格的成员必须真的拿到东西:要么写进了背包(有格位),要么走了邮件
|
||
// (有溢出行)。超时空只有达到 [MINIMAL DUNGEON CLEAR COUNT] 的成员才有
|
||
// 格子;安东/卢克没有这个门槛,runtimeFiendWarRewardEligible 对它们恒真,
|
||
// 所以这条判定的旧语义一字未改。
|
||
//
|
||
// 「溢出也算拿到」是 2026-10-02 补的:改动前只认格位,于是"背包满 → 整份
|
||
// 奖励走邮件"的那位会被判成回执坏了 —— 这是同一条 bug 的另一半,光放开
|
||
// 发奖循环还不够。
|
||
(runtimeFiendWarRewardEligible(*state, member.CharID) &&
|
||
len(receiptMember.Slots) == 0 && len(receiptMember.Overflow) == 0) ||
|
||
len(receiptMember.PostState) != len(receiptMember.Slots) {
|
||
return errRuntimeRaidRewardReceipt
|
||
}
|
||
// 反方向同样要钉:没资格的成员拿到任何东西(格位**或邮件**)就是收据坏了。
|
||
if !runtimeFiendWarRewardEligible(*state, member.CharID) &&
|
||
(len(wireMember.Rewards) != 0 || len(receiptMember.Slots) != 0 || len(receiptMember.Overflow) != 0) {
|
||
return errRuntimeRaidRewardReceipt
|
||
}
|
||
for slotIndex, slot := range receiptMember.Slots {
|
||
postState := receiptMember.PostState[slotIndex]
|
||
if slot < 0 || (slotIndex > 0 && receiptMember.Slots[slotIndex-1] >= slot) ||
|
||
postState.Slot != slot || postState.Stack.ItemID <= 0 || postState.Stack.Count <= 0 {
|
||
return errRuntimeRaidRewardReceipt
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateRuntimeRaidRewardReceiptInventories(
|
||
ctx context.Context,
|
||
inventories dnfrepo.InventoryRepository,
|
||
receipt runtimeRaidRewardReceipt,
|
||
) error {
|
||
if inventories == nil {
|
||
return errRuntimeRaidRewardRepository
|
||
}
|
||
for _, member := range receipt.Members {
|
||
characterID := strconv.Itoa(int(member.CharacterID))
|
||
inventory, found, err := inventories.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("%w: character=%s inventory missing", errRuntimeRaidRewardReceipt, characterID)
|
||
}
|
||
for _, postState := range member.PostState {
|
||
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, postState.Slot)
|
||
stack, occupied := inventory.Slots[key]
|
||
if !occupied || !runtimeRaidRewardItemStacksEqual(stack, postState.Stack) {
|
||
return fmt.Errorf("%w: character=%s slot=%d post-state changed", errRuntimeRaidRewardReceipt, characterID, postState.Slot)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func runtimeRaidRewardReceiptPostState(
|
||
inventory dnfrepo.InventoryRecord,
|
||
slots []int16,
|
||
) ([]runtimeRaidRewardReceiptSlotState, error) {
|
||
postState := make([]runtimeRaidRewardReceiptSlotState, 0, len(slots))
|
||
for _, slot := range slots {
|
||
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
|
||
stack, found := inventory.Slots[key]
|
||
if !found || stack.ItemID <= 0 || stack.Count <= 0 {
|
||
return nil, fmt.Errorf("slot=%d committed stack missing", slot)
|
||
}
|
||
postState = append(postState, runtimeRaidRewardReceiptSlotState{
|
||
Slot: slot,
|
||
Stack: cloneRuntimeRaidRewardItemStack(stack),
|
||
})
|
||
}
|
||
return postState, nil
|
||
}
|
||
|
||
func cloneRuntimeRaidRewardItemStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
||
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||
stack.Extra = maps.Clone(stack.Extra)
|
||
return stack
|
||
}
|
||
|
||
func runtimeRaidRewardItemStacksEqual(left dnfrepo.ItemStack, right dnfrepo.ItemStack) bool {
|
||
return left.ItemID == right.ItemID &&
|
||
left.Count == right.Count &&
|
||
left.Bind == right.Bind &&
|
||
left.ExpireAt.Equal(right.ExpireAt) &&
|
||
bytes.Equal(left.RawEntry, right.RawEntry) &&
|
||
maps.Equal(left.Extra, right.Extra)
|
||
}
|
||
|
||
func (s *Service) runtimeRaidRewardDeliveryCacheFromReceipt(
|
||
state *runtimeRaidState,
|
||
receiptScope string,
|
||
receipt runtimeRaidRewardReceipt,
|
||
) (*runtimeRaidRewardDeliveryCache, error) {
|
||
if err := s.validateRuntimeRaidRewardReceipt(state, receipt.Phase, receipt.Token, receipt); err != nil {
|
||
return nil, err
|
||
}
|
||
refreshes := make([]runtimeRaidRewardInventoryRefresh, 0, len(receipt.Members))
|
||
for _, member := range receipt.Members {
|
||
refreshes = append(refreshes, runtimeRaidRewardInventoryRefresh{
|
||
CharacterID: member.CharacterID,
|
||
Slots: append([]int16(nil), member.Slots...),
|
||
})
|
||
}
|
||
return &runtimeRaidRewardDeliveryCache{
|
||
Phase: receipt.Phase,
|
||
Token: receipt.Token,
|
||
ReceiptScope: receiptScope,
|
||
Rewards: cloneRuntimeRaidSettlementRewards(receipt.Rewards),
|
||
RewardBody: append([]byte(nil), receipt.RewardBody...),
|
||
InventoryRefreshes: refreshes,
|
||
}, nil
|
||
}
|
||
|
||
func runtimeRaidRewardDeliveryFromCache(
|
||
state runtimeRaidState,
|
||
cache *runtimeRaidRewardDeliveryCache,
|
||
) runtimeRaidSettlementRewardDelivery {
|
||
cloned := cloneRuntimeRaidRewardDeliveryCache(cache)
|
||
if cloned == nil {
|
||
return runtimeRaidSettlementRewardDelivery{Raid: cloneRuntimeRaidState(state)}
|
||
}
|
||
return runtimeRaidSettlementRewardDelivery{
|
||
Raid: cloneRuntimeRaidState(state),
|
||
Token: cloned.Token,
|
||
Rewards: cloned.Rewards,
|
||
RewardBody: cloned.RewardBody,
|
||
InventoryRefreshes: cloned.InventoryRefreshes,
|
||
}
|
||
}
|
||
|
||
func cloneRuntimeRaidRewardDeliveryCache(source *runtimeRaidRewardDeliveryCache) *runtimeRaidRewardDeliveryCache {
|
||
if source == nil {
|
||
return nil
|
||
}
|
||
clone := *source
|
||
clone.Rewards = cloneRuntimeRaidSettlementRewards(source.Rewards)
|
||
clone.RewardBody = append([]byte(nil), source.RewardBody...)
|
||
clone.InventoryRefreshes = cloneRuntimeRaidRewardInventoryRefreshes(source.InventoryRefreshes)
|
||
return &clone
|
||
}
|
||
|
||
func cloneRuntimeRaidSettlementRewards(source raid.RaidSettlementRewards) raid.RaidSettlementRewards {
|
||
clone := raid.RaidSettlementRewards{ResultType: source.ResultType, Members: make([]raid.RaidSettlementMemberRewards, len(source.Members))}
|
||
for index, member := range source.Members {
|
||
clone.Members[index] = member
|
||
clone.Members[index].Rewards = append([]raid.RaidSettlementReward(nil), member.Rewards...)
|
||
}
|
||
return clone
|
||
}
|
||
|
||
func cloneRuntimeRaidRewardInventoryRefreshes(source []runtimeRaidRewardInventoryRefresh) []runtimeRaidRewardInventoryRefresh {
|
||
clone := make([]runtimeRaidRewardInventoryRefresh, len(source))
|
||
for index, refresh := range source {
|
||
clone[index] = refresh
|
||
clone[index].Slots = append([]int16(nil), refresh.Slots...)
|
||
}
|
||
return clone
|
||
}
|
||
|
||
func cloneRuntimeRaidRewardCompletion(source runtimeRaidSettlementRewardCompletion) runtimeRaidSettlementRewardCompletion {
|
||
clone := runtimeRaidSettlementRewardCompletion{Raid: cloneRuntimeRaidState(source.Raid)}
|
||
if source.StandbyTimer != nil {
|
||
timer := *source.StandbyTimer
|
||
clone.StandbyTimer = &timer
|
||
}
|
||
return clone
|
||
}
|
||
|
||
func sortedRuntimeRaidRewardSlots(source map[int16]struct{}) []int16 {
|
||
slots := make([]int16, 0, len(source))
|
||
for slot := range source {
|
||
slots = append(slots, slot)
|
||
}
|
||
sort.Slice(slots, func(i, j int) bool { return slots[i] < slots[j] })
|
||
return slots
|
||
}
|