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

970 lines
37 KiB
Go
Raw Permalink 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 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
}