DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,969 @@
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
}