Files
90CNServer/server/internal/modules/dnf/quest/finish_owner.go
T
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

1806 lines
71 KiB
Go
Raw 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 quest
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/carrygold"
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
dnfhonor "longheng.io/server/internal/modules/dnf/honor"
"longheng.io/server/internal/modules/dnf/jobmap"
dnfonlineevent "longheng.io/server/internal/modules/dnf/onlineevent"
"longheng.io/server/internal/modules/dnf/profession"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
var (
ErrFinishSettlementUnavailable = errors.New("dnf quest finish settlement is unavailable")
ErrFinishQuestNotPending = errors.New("dnf quest is not pending a finish reward")
ErrFinishQuestCompletionConflict = errors.New("dnf quest finish completion key conflicts with persisted state")
ErrFinishQuestSettlementStale = errors.New("dnf quest finish replay receipt is stale")
ErrFinishQuestSettlementCorrupt = errors.New("dnf quest finish settlement receipt is corrupt")
ErrFinishQuestMultiplierUnsupported = errors.New("dnf quest finish multiplier is unsupported")
ErrFinishQuestCommitTimeRequired = errors.New("dnf quest finish commit time is required")
ErrFinishItemAllocatorRequired = errors.New("dnf quest finish item allocator is required")
ErrFinishProgressionUnproven = errors.New("dnf quest finish progression is outside proven PVF thresholds")
ErrFinishExperienceBonusInvalid = errors.New("dnf quest finish experience bonus is invalid")
ErrFinishProfessionUnavailable = errors.New("dnf quest finish profession resources are unavailable")
ErrFinishRequiredItemsMissing = errors.New("dnf quest finish required items are missing")
)
const (
finishRewardGranted = "granted"
finishSettlementReceiptKey = "settlement_receipt_v1"
finishSettlementReceiptLevel = 1
)
// FinishItemGrantRequest is a PVF-backed item delta which must be placed into
// the transaction-scoped inventory clone. The bridge supplies the allocator
// because current item category ranges and raw entries are current-EXE/PVF
// concerns, while the quest owner retains the atomic transaction boundary.
type FinishItemGrantRequest struct {
QuestID int64
CompletionKey string
Source string
ItemID int64
Count int64
// AccountInventory 是**本次结算事务内**的账号共享仓记录(事务不可用时为 nil)。
//
// 为什么发放器必须看得到它:无色小晶块 / 灵魂 这一类"账号共享道具"在同一个账号里
// 只有一个家 —— list 0 的 354..365 固定格(权威表见
// dnfrepo/account_shared_materials.go)。任务奖励过去只落角色背包,于是同一个
// list-0 报文里会出现两行同 itemID(背包一行 + 账号格一行),客户端把背包那行
// 显示成仓库的数量 —— 2026-09-21 的事故就是这个。
//
// 约定:
// · 非 nil 时,发放器**必须**先把这个道具并进它的固定格,装不下的余量才落背包;
// 它可以直接改这条记录(调用方负责在同一事务里 Save)。
// · nil 时发放器必须退回"只落角色背包"(改造前的行为),
// **绝不能**因为账号仓不可用让整笔任务结算失败。
//
// 目前只有任务通关奖励路径([reward int data])会给出这条记录:实测真实
// Script.pvf 里晶块/灵魂只出现在 [reward int data] / [reward selection int data]
// 与 [int data](后者是交付要求,走消耗),而 [depend give item](接任务时的
// 携带道具)里一个都没有 —— 所以接任务那条路径仍然按老样子只发背包。
AccountInventory *dnfrepo.AccountInventoryRecord
}
// FinishCommittedItem is the exact post-allocation item receipt. RawEntry is a
// deep copy of the current 0x77 inventory row after the grant was applied.
type FinishCommittedItem struct {
SlotKey string `json:"slot_key"`
SlotIndex uint16 `json:"slot_index"`
ItemID int64 `json:"item_id"`
Delta int64 `json:"delta"`
PostCount int64 `json:"post_count"`
CountOrSeed uint32 `json:"count_or_seed"`
RawEntry []byte `json:"raw_entry"`
}
type FinishCommittedCurrency struct {
Name string `json:"name"`
Delta int64 `json:"delta"`
PostValue int64 `json:"post_value"`
}
// FinishConsumedItem records one quest-submit material stack consumed by the
// finish transaction. SlotKey is the current list:slot owner key; the current
// EXE finish ACK subtracts Delta from SlotIndex, while post-finish refreshes
// use RawEntry when the stack remains or a delete row when PostCount is zero.
type FinishConsumedItem struct {
SlotKey string `json:"slot_key"`
SlotIndex uint16 `json:"slot_index"`
ItemID int64 `json:"item_id"`
Delta int64 `json:"delta"`
PostCount int64 `json:"post_count"`
RawEntry []byte `json:"raw_entry,omitempty"`
}
type FinishItemAllocator func(*dnfrepo.InventoryRecord, FinishItemGrantRequest) (FinishCommittedItem, error)
type HellPartyUnlock struct {
AreaID int64 `json:"area_id"`
QuestID int64 `json:"quest_id"`
}
type FinishCommitInput struct {
AccountID string
CharacterID string
QuestID int64
RewardSelectIndex uint16
HasRewardSelect bool
Multiplier uint16
ExpectedCompletionKey string
CommittedAt time.Time
Progression *progression.Tables
ExperienceBonusPercent int64
AllocateItem FinishItemAllocator
ProfessionProfiles *profession.Profiles
SkillCatalog *dnfskill.Table
HellPartyUnlocks []HellPartyUnlock
// AdventureRuntime 是当前运行时 PVF 的冒险团配置(商店点数换算 / 成长胶囊槽)。
// 满级角色做任务时,越过等级上限的那部分经验要同事务记进账号级冒险团状态
// (骁勇结晶、成长胶囊槽、荣誉经验);没有它就只能整笔丢掉。
AdventureRuntime *adventuregroup.RuntimeConfig
}
// FinishCommitResult is a detached post-commit snapshot. Every map, slice and
// raw row is cloned before it crosses the domain boundary. It contains no
// opcode or packet fields and is shared by town op34 and dungeon settlement.
type FinishCommitResult struct {
CharacterID string
CompletionKey string
Source string
QuestID int64
AtomicCommitted bool
Replayed bool
BaseExperienceDelta uint32
ExperienceBonusDelta uint32
ContinuityExperienceDelta uint32
ExperienceDelta uint32
PreviousLevel int
NewLevel int
PreviousExperience uint32
NewExperience uint32
SPDelta int
TPDelta int
Items []FinishCommittedItem
ConsumedItems []FinishConsumedItem
Currency []FinishCommittedCurrency
// VoucherCredits 是本次任务奖励里被直接入账号钱包的代币券 / 欢乐代币券数量。
//
// 这两件券在道具 PVF 里是普通 `[type] [material]`、**没有 `[action type]`**,
// 客户端根本没有任何途径把它们换成钱包余额;当普通道具发下去,玩家只会拿到一件
// 永远用不掉的"材料"(现场 2026-09-19 的抱怨,见 cerashop/voucher.go)。
VoucherCredits dnfcerashop.VoucherCounts
// VoucherWallet 是入账后的账号钱包(nil = 本次奖励里没有券),
// 供桥接层原样推给客户端。
VoucherWallet *dnfcerashop.Wallet
PostCommitCharacter dnfrepo.CharacterRecord
PostCommitSkill dnfrepo.SkillRecord
PostCommitInventory dnfrepo.InventoryRecord
PostCommitAccountInventory dnfrepo.AccountInventoryRecord
PostCommitQuest dnfrepo.QuestRecord
Profession profession.Transition
ProfessionGrants []profession.Grant
HasProfession bool
ExpertJobType byte
HasExpertJob bool
HasSlotExpansion bool
SlotExpansionIndex uint32
SlotExpansionBit byte
}
type finishSettlementReceipt struct {
Version int `json:"version"`
CharacterID string `json:"character_id"`
CompletionKey string `json:"completion_key"`
Source string `json:"source"`
QuestID int64 `json:"quest_id"`
BaseExperienceDelta uint32 `json:"base_experience_delta,omitempty"`
ExperienceBonusDelta uint32 `json:"experience_bonus_delta,omitempty"`
ContinuityExperienceDelta uint32 `json:"continuity_experience_delta,omitempty"`
ExperienceDelta uint32 `json:"experience_delta"`
PreviousLevel int `json:"previous_level"`
NewLevel int `json:"new_level"`
PreviousExperience uint32 `json:"previous_experience"`
NewExperience uint32 `json:"new_experience"`
SPDelta int `json:"sp_delta"`
TPDelta int `json:"tp_delta"`
PostSkillPoints dnfrepo.SkillPointState `json:"post_skill_points"`
Items []FinishCommittedItem `json:"items,omitempty"`
ConsumedItems []FinishConsumedItem `json:"consumed_items,omitempty"`
Currency []FinishCommittedCurrency `json:"currency,omitempty"`
// VoucherCredits 必须进收据:重放时只从收据重建结果、不再发放,
// 所以入账数量必须与"已发放"的事实一起持久化。
VoucherCredits dnfcerashop.VoucherCounts `json:"voucher_credits,omitempty"`
Profession profession.Transition `json:"profession,omitempty"`
ProfessionGrants []profession.Grant `json:"profession_grants,omitempty"`
HasProfession bool `json:"has_profession,omitempty"`
ExpertJobType byte `json:"expert_job_type,omitempty"`
HasExpertJob bool `json:"has_expert_job,omitempty"`
HasSlotExpansion bool `json:"has_slot_expansion,omitempty"`
SlotExpansionIndex uint32 `json:"slot_expansion_index,omitempty"`
SlotExpansionBit byte `json:"slot_expansion_bit,omitempty"`
HellPartyUnlocks []HellPartyUnlock `json:"hell_party_unlocks,omitempty"`
}
// ApplyFinishSettlement validates the Phase-A pending marker and atomically
// commits quest state, PVF experience/SP, and PVF item rewards. A same-key
// replay returns the persisted receipt without granting twice; a different or
// no-longer-current receipt fails closed.
func (o *Owner) ApplyFinishSettlement(
ctx context.Context,
catalog *Catalog,
input FinishCommitInput,
) (FinishCommitResult, error) {
if o == nil || o.repositories.CharacterSettlement == nil || catalog == nil || input.Progression == nil {
return FinishCommitResult{}, ErrFinishSettlementUnavailable
}
input.AccountID = strings.TrimSpace(input.AccountID)
input.CharacterID = strings.TrimSpace(input.CharacterID)
input.ExpectedCompletionKey = strings.TrimSpace(input.ExpectedCompletionKey)
if input.AccountID == "" || input.CharacterID == "" {
return FinishCommitResult{}, ErrCharacterRequired
}
if input.QuestID <= 0 || input.QuestID > math.MaxUint16 {
return FinishCommitResult{}, ErrQuestIDRequired
}
if input.Multiplier != 1 {
return FinishCommitResult{}, fmt.Errorf("%w: multiplier=%d", ErrFinishQuestMultiplierUnsupported, input.Multiplier)
}
if input.CommittedAt.IsZero() {
return FinishCommitResult{}, ErrFinishQuestCommitTimeRequired
}
if input.ExperienceBonusPercent < 0 {
return FinishCommitResult{}, fmt.Errorf("%w: percent=%d", ErrFinishExperienceBonusInvalid, input.ExperienceBonusPercent)
}
if ctx == nil {
ctx = context.Background()
}
var candidate FinishCommitResult
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, input.CharacterID, func(tx dnfrepo.Group) error {
if tx.AccountInventory == nil || tx.Character == nil || tx.Quest == nil || tx.Skill == nil || tx.Inventory == nil || tx.Equipment == nil {
return ErrFinishSettlementUnavailable
}
character, found, err := tx.Character.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
character = dnfrepo.CloneCharacter(character)
if strings.TrimSpace(character.CharacterID) != input.CharacterID || strings.TrimSpace(character.AccountID) != input.AccountID {
return fmt.Errorf("%w: character=%q account=%q", ErrCharacterNotFound, character.CharacterID, character.AccountID)
}
accountInventory, accountInventoryFound, err := tx.AccountInventory.Load(ctx, input.AccountID)
if err != nil {
return err
}
if accountInventoryFound && strings.TrimSpace(accountInventory.AccountID) != input.AccountID {
return fmt.Errorf("%w: account inventory owner=%q", ErrFinishSettlementUnavailable, accountInventory.AccountID)
}
accountInventory = dnfrepo.CloneAccountInventory(accountInventory)
accountInventory.AccountID = input.AccountID
quests, found, err := tx.Quest.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(quests.CharacterID) != input.CharacterID {
return ErrFinishQuestNotPending
}
quests = dnfrepo.CloneQuest(quests)
state, field, known := mutableQuestState(&quests, input.QuestID)
if !known {
return ErrFinishQuestNotPending
}
skill, found, err := tx.Skill.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(skill.CharacterID) != input.CharacterID {
return fmt.Errorf("%w: skill record", ErrFinishSettlementUnavailable)
}
skill = dnfrepo.CloneSkill(skill)
previousSkill := dnfrepo.CloneSkill(skill)
previousSkillPoints := skill.Points
inventory, found, err := tx.Inventory.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != input.CharacterID {
return fmt.Errorf("%w: inventory record", ErrFinishSettlementUnavailable)
}
inventory = dnfrepo.CloneInventory(inventory)
if equipment, equipmentFound, err := tx.Equipment.Load(ctx, input.CharacterID); err != nil {
return err
} else if equipmentFound && strings.TrimSpace(equipment.CharacterID) != input.CharacterID {
return fmt.Errorf("%w: equipment owner=%q", ErrFinishSettlementUnavailable, equipment.CharacterID)
}
now := input.CommittedAt.UTC()
questFields := []dnfrepo.QuestField{field}
// A subtype-2 parent can own a final reward-bearing child. Keep that
// child active until the atomic settlement below consumes its materials,
// grants its reward, and completes both rows. The legacy quest-clear
// helper is reserved for no-reward linked objectives.
rewardChildPending := false
if jobValue, parseErr := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8); parseErr == nil &&
jobmap.Valid(int(jobValue)) && character.Level > 0 {
growTypeValue := character.Stats["grow_type"]
if growTypeValue >= 0 && growTypeValue <= math.MaxUint8 {
_, _, rewardChildPending = catalog.FinishRewardDefinition(
quests,
CharacterEligibility{Level: character.Level, Job: int(jobValue), GrowType: int(growTypeValue)},
input.QuestID,
)
}
}
if isActiveQuestStatus(state.Status) && !rewardChildPending {
if questClearTrigger, linkedFields, questClear := finishQuestClearTrigger(catalog, &quests, input.QuestID, now); questClear {
if questClearTrigger != 0 {
return ErrFinishQuestNotPending
}
for _, linkedField := range linkedFields {
questFields = mergeQuestChangedField(questFields, linkedField)
}
if state.ProgressValue != 0 {
state.ProgressValue = 0
state.UpdatedAt = now
switch field {
case dnfrepo.QuestFieldStates:
quests.States[input.QuestID] = state
case dnfrepo.QuestFieldProgress:
quests.Progress[input.QuestID] = state
default:
return ErrFinishQuestNotPending
}
}
}
}
completionKey := strings.TrimSpace(state.Extra["completion_key"])
if completionKey == "" && isActiveQuestStatus(state.Status) && state.ProgressValue == 0 && strings.TrimSpace(state.Extra["reward_state"]) == "" {
// NPC/dialogue profession quests do not pass through dungeon clear-map
// Phase A. Their durable completion proof is the accepted quest row
// itself with a zero trigger. Derive the key from persisted acceptance
// state so a rolled-back/retried op34 produces the same identity.
proofTime := state.UpdatedAt.UTC().UnixNano()
if proofTime == 0 {
proofTime = quests.UpdatedAt.UTC().UnixNano()
}
completionKey = fmt.Sprintf("quest-finish/%s/%d/%d", input.CharacterID, input.QuestID, proofTime)
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["completion_key"] = completionKey
state.Extra["completion_kind"] = "active_trigger_zero_op34"
state.Extra["reward_state"] = clearMapRewardPending
}
if completionKey == "" {
return ErrFinishQuestNotPending
}
if input.ExpectedCompletionKey != "" && input.ExpectedCompletionKey != completionKey {
return fmt.Errorf("%w: expected=%q persisted=%q", ErrFinishQuestCompletionConflict, input.ExpectedCompletionKey, completionKey)
}
if strings.EqualFold(strings.TrimSpace(state.Extra["reward_state"]), finishRewardGranted) {
replayed, err := finishResultFromReceipt(ctx, input, tx.HellPartyPermission, state, character, skill, inventory, accountInventory, quests)
if err != nil {
return err
}
candidate = replayed
return nil
}
if !isActiveQuestStatus(state.Status) || state.ProgressValue != 0 || strings.TrimSpace(state.Extra["reward_state"]) != clearMapRewardPending {
return ErrFinishQuestNotPending
}
jobValue, err := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
if err != nil || !jobmap.Valid(int(jobValue)) || character.Level <= 0 {
return fmt.Errorf("%w: level=%d job=%q", ErrQuestNotAcceptable, character.Level, character.Job)
}
growTypeValue := character.Stats["grow_type"]
if growTypeValue < 0 || growTypeValue > math.MaxUint8 {
return fmt.Errorf("%w: grow_type=%d", ErrQuestNotAcceptable, growTypeValue)
}
growType := int(growTypeValue)
eligibility := CharacterEligibility{Level: character.Level, Job: int(jobValue), GrowType: growType, Wedding: WeddingEligible(character)}
_, definitionKnown := catalog.Find(input.QuestID)
if !definitionKnown {
return ErrQuestDefinitionMissing
}
rewardDefinition, additionalCompleted, rewardChild := catalog.FinishRewardDefinition(quests, eligibility, input.QuestID)
rewardQuestID := input.QuestID
if rewardChild {
rewardQuestID = rewardDefinition.ID
}
reward, err := catalog.PlanFinishReward(eligibility, rewardQuestID, input.RewardSelectIndex, input.HasRewardSelect)
if err != nil {
return err
}
var hellPartyUnlocks []HellPartyUnlock
if reward.HasHellPartyUnlock {
hellPartyUnlocks, err = validFinishHellPartyUnlocks(input.HellPartyUnlocks, input.QuestID)
if err != nil || tx.HellPartyPermission == nil {
return ErrFinishSettlementUnavailable
}
for _, unlock := range hellPartyUnlocks {
if _, _, err := tx.HellPartyPermission.Upsert(ctx, input.CharacterID, unlock.AreaID, unlock.QuestID); err != nil {
return err
}
}
}
if reward.HasProfession && (input.ProfessionProfiles == nil || input.SkillCatalog == nil) {
return ErrFinishProfessionUnavailable
}
if reward.HasProfession && reward.HasExpertJob {
return ErrQuestRewardMalformed
}
if reward.HasProfession {
reward.Profession, err = input.ProfessionProfiles.PlanTransition(byte(jobValue), byte(growType), reward.ProfessionRequest)
if err != nil {
return err
}
}
baseExperienceDelta, err := input.Progression.QuestExperience(character.Level, reward.QuestLevel, reward.Difficulty, reward.IgnoreLevel4Exp)
if err != nil {
return err
}
experienceDelta, experienceBonusDelta := finishExperienceWithBonus(baseExperienceDelta, input.ExperienceBonusPercent)
progressPlan, err := planFinishProgression(input.Progression, character, skill, experienceDelta)
if err != nil {
return err
}
continuityExperienceDelta := uint32(0)
continuityEligibility := eligibility
if reward.HasProfession {
continuityEligibility.GrowType = int(reward.Profession.NewGrowType)
}
completedQuestIDs := append([]int64{input.QuestID}, additionalCompleted...)
if targetLevel, applies := catalog.MainContinuityLevelFloor(continuityEligibility, quests, completedQuestIDs...); applies {
if targetLevel > progression.MaximumCharacterLevel {
targetLevel = progression.MaximumCharacterLevel
}
if progressPlan.Experience.NewLevel < targetLevel {
threshold, thresholdErr := input.Progression.ThresholdToNext(targetLevel - 1)
if thresholdErr != nil {
return fmt.Errorf("%w: main continuity target_level=%d: %v", ErrFinishProgressionUnproven, targetLevel, thresholdErr)
}
if threshold > progressPlan.Experience.NewExperience {
continuityExperienceDelta = threshold - progressPlan.Experience.NewExperience
experienceDelta += continuityExperienceDelta
progressPlan, err = planFinishProgression(input.Progression, character, skill, experienceDelta)
if err != nil {
return err
}
}
}
}
if character.Stats == nil {
character.Stats = make(map[string]int64, 2)
}
// 任务经验现在**任意等级**都要镜像进账号账本:全额进骁勇结晶,
// 其中越过等级上限的部分才进成长胶囊槽与名誉经验。口径与怪物击杀、
// 通关结算完全一致(同一个 adventuregroup.CreditExperience),
// 且与角色经验同事务提交。
creditAccountID := strings.TrimSpace(input.AccountID)
if input.AdventureRuntime != nil && tx.Account != nil && creditAccountID != "" && experienceDelta > 0 {
previousExperience, present := character.Stats["exp"]
if !present || previousExperience < 0 || uint64(previousExperience) > math.MaxUint32 {
return fmt.Errorf("%w: exp=%d present=%t", ErrFinishProgressionUnproven, previousExperience, present)
}
postCapExperience, shareErr := dnfhonor.PostCapExperienceShare(
input.Progression,
character.Level,
uint32(previousExperience),
experienceDelta,
progression.MaximumCharacterLevel,
)
if shareErr != nil {
return shareErr
}
if _, creditErr := adventuregroup.CreditExperience(
ctx,
tx.Account,
*input.AdventureRuntime,
creditAccountID,
adventuregroup.ExperienceCredit{
AwardedExperience: uint64(experienceDelta),
MaximumLevelExperience: uint64(postCapExperience),
},
now,
); creditErr != nil {
// 账号行缺失时的取舍与怪物击杀/通关结算一致:有满级部分就必须落账
// (整笔失败),只有未满级经验镜像进结晶时跳过记账、照常发任务奖励。
if !errors.Is(creditErr, adventuregroup.ErrCreditAccountNotFound) || postCapExperience > 0 {
return creditErr
}
}
}
requiredItems := finishRequiredItemRules(catalog, rewardDefinition, eligibility)
consumedItems, err := consumeFinishRequiredItems(&inventory, &accountInventory, requiredItems)
if err != nil {
return err
}
currency, err := chargeFinishGoldCost(&character, reward)
if err != nil {
return err
}
rewardCurrency, err := applyFinishRewardCurrency(&character, input.Progression, reward)
if err != nil {
return err
}
currency = append(currency, rewardCurrency...)
source := "quest_pvf:" + reward.PVFPath
// 代币券 / 欢乐代币券不落背包:它们在道具 PVF 里是普通 `[material]`,
// 没有 `[action type]`,客户端根本无法把它们换成钱包余额,当道具发下去
// 只会得到一件永远用不掉的"材料"。
//
// 做法是先把券从奖励规则里**摘出来**,剩下的规则照原样走既有落格逻辑
// (非券奖励的行为一个字节都不变),券改成入账号钱包 —— 且与任务结算
// **同一事务**:要么任务完成且券到账,要么两者都没发生。
voucherRules, itemRules := splitFinishVoucherRules(reward.Items)
// ⚠ 只有"有券 **且** 账号仓储可用 **且** 账号号非空"时才把券入钱包。
//
// 账号号为空时必须整体退回道具发放:`tx.Account.Load(ctx, "")` 会因为
// TransactionKeyError 直接报错,那会把整笔任务结算打成硬失败 —— 改动前这种
// 情况只是把券当普通道具发下去,任务照常完成。这是"静默改变失败模式",
// 属于绝不能引入的那一类。
//
// `allocateFinishRewardItems` 会直接改 `inventory`(落格),所以**只能调用一次**:
// 早先的写法是先用 itemRules 落一次、发现不可用再用全部规则落第二次 ——
// 那会把非券奖励**发两份**。这种"边界条件下静默多发"比直接报错危险得多。
accountID := strings.TrimSpace(input.AccountID)
creditVouchers := len(voucherRules) > 0 && tx.Account != nil && accountID != ""
if !creditVouchers {
itemRules = reward.Items
}
items, err := allocateFinishRewardItems(&inventory, itemRules, input.AllocateItem, FinishItemGrantRequest{
QuestID: input.QuestID, CompletionKey: completionKey, Source: source,
// 账号共享仓随请求一起交给发放器:无色/灵魂类奖励的唯一落点是它的固定格。
// 这条记录已经在本事务里 Load + Clone 过,发放器改的就是它,
// 下面的 Save 条件再决定要不要落库。
AccountInventory: &accountInventory,
})
if err != nil {
return err
}
var voucherCredits dnfcerashop.VoucherCounts
var voucherWallet *dnfcerashop.Wallet
if creditVouchers {
voucherCredits, err = finishVoucherCounts(voucherRules)
if err != nil {
return err
}
account, accountFound, accountErr := tx.Account.Load(ctx, accountID)
if accountErr != nil {
return accountErr
}
if !accountFound {
return ErrFinishSettlementUnavailable
}
account = dnfrepo.CloneAccount(account)
// 溢出 = 整笔结算失败:客户端把这三个字段按 i32 读,截断会显示成负数;
// 而"任务标记完成却没发奖励"同样不可接受,所以宁可让玩家重试。
updated, creditErr := dnfcerashop.CreditVoucherCounts(&account, voucherCredits)
if creditErr != nil {
return fmt.Errorf("%w: voucher credit: %v", ErrQuestRewardMalformed, creditErr)
}
account.UpdatedAt = now
if err := tx.Account.Save(ctx, account); err != nil {
return err
}
wallet := updated
voucherWallet = &wallet
}
character.Level = progressPlan.Experience.NewLevel
character.Stats["exp"] = int64(progressPlan.Experience.NewExperience)
character.UpdatedAt = now
skill.Points = progressPlan.SkillPoints.New
if reward.HasProfession {
character.Stats["grow_type"] = int64(reward.Profession.NewGrowType)
skill, err = input.ProfessionProfiles.ApplySkillTransition(
input.SkillCatalog,
byte(jobValue),
progressPlan.Experience.NewLevel,
reward.Profession,
skill,
progressPlan.SkillPoints.New,
)
if err != nil {
return err
}
}
professionGrants, err := finishProfessionGrants(previousSkill, skill, reward.HasProfession)
if err != nil {
return err
}
if reward.HasExpertJob {
// characters.expert_job_type is the repository-backed source used by
// the current USERINFO subtype0 builder. The op34 chain-20 ACK mirrors
// this same committed value for the online client.
character.Stats["expert_job_type"] = int64(reward.ExpertJobType)
}
if reward.HasSlotExpansion {
character.Stats["ex_equip_slot_stat"] = character.Stats["ex_equip_slot_stat"] | int64(reward.SlotExpansionBit)
}
skill.UpdatedAt = now
inventory.UpdatedAt = now
// 账号记录既可能被"交材料"改到,也可能被"发奖励"改到(无色/灵魂落 354..365
// 固定格),两者任意一种发生都要在同一事务里落库。
accountSharedTouched := finishConsumedAccountSharedItems(consumedItems) || finishGrantedAccountSharedItems(items)
if accountSharedTouched {
accountInventory.UpdatedAt = now
}
receipt := finishSettlementReceipt{
Version: finishSettlementReceiptLevel, CharacterID: input.CharacterID,
CompletionKey: completionKey, Source: source, QuestID: input.QuestID,
BaseExperienceDelta: baseExperienceDelta, ExperienceBonusDelta: experienceBonusDelta,
ContinuityExperienceDelta: continuityExperienceDelta,
ExperienceDelta: experienceDelta,
PreviousLevel: progressPlan.Experience.PreviousLevel, NewLevel: progressPlan.Experience.NewLevel,
PreviousExperience: progressPlan.Experience.PreviousExperience, NewExperience: progressPlan.Experience.NewExperience,
SPDelta: skill.Points.RemainingSP - previousSkillPoints.RemainingSP,
TPDelta: skill.Points.RemainingTP - previousSkillPoints.RemainingTP,
PostSkillPoints: skill.Points,
Items: cloneFinishItems(items),
ConsumedItems: cloneFinishConsumedItems(consumedItems),
Currency: append([]FinishCommittedCurrency(nil), currency...),
VoucherCredits: voucherCredits,
Profession: reward.Profession,
ProfessionGrants: cloneProfessionGrants(professionGrants),
HasProfession: reward.HasProfession,
ExpertJobType: reward.ExpertJobType,
HasExpertJob: reward.HasExpertJob,
HasSlotExpansion: reward.HasSlotExpansion,
SlotExpansionIndex: reward.SlotExpansionIndex,
SlotExpansionBit: reward.SlotExpansionBit,
HellPartyUnlocks: cloneFinishHellPartyUnlocks(hellPartyUnlocks),
}
receiptJSON, err := json.Marshal(receipt)
if err != nil {
return err
}
state.Status = "completed"
state.ProgressValue = 0
state.RewardSelectIndex = int64(input.RewardSelectIndex)
state.Multiplier = 1
state.UpdatedAt = now
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["reward_state"] = finishRewardGranted
state.Extra["settlement_source"] = source
state.Extra[finishSettlementReceiptKey] = string(receiptJSON)
switch field {
case dnfrepo.QuestFieldStates:
quests.States[input.QuestID] = state
case dnfrepo.QuestFieldProgress:
quests.Progress[input.QuestID] = state
default:
return ErrFinishQuestNotPending
}
quests.UpdatedAt = now
for _, childID := range additionalCompleted {
childState, childField, childKnown := mutableQuestState(&quests, childID)
if !childKnown || !isActiveQuestStatus(childState.Status) || childState.ProgressValue != 0 {
return ErrFinishQuestNotPending
}
childState.Status = "completed"
childState.UpdatedAt = now
if childState.Extra == nil {
childState.Extra = make(map[string]string, 6)
}
childState.Extra["reward_state"] = finishRewardGranted
childState.Extra["completed_by_parent"] = strconv.FormatInt(input.QuestID, 10)
switch childField {
case dnfrepo.QuestFieldStates:
quests.States[childID] = childState
case dnfrepo.QuestFieldProgress:
quests.Progress[childID] = childState
default:
return ErrFinishQuestNotPending
}
questFields = mergeQuestChangedField(questFields, childField)
}
for _, parentField := range syncActiveQuestClearParentProgress(catalog, &quests, now) {
questFields = mergeQuestChangedField(questFields, parentField)
}
// 回归冒险家(onlineevent returnee)的"完成任务"计数:它是回归任务链的
// 推进量,只在这里累加(与任务交付同事务,重放同一交付不会重复计入)。
// is_return_user 非 0 才算回归账号。
if character.Stats["is_return_user"] != 0 {
character.Stats[dnfonlineevent.ReturneeQuestCountKey] =
min(max(character.Stats[dnfonlineevent.ReturneeQuestCountKey], 0), math.MaxInt64-1) + 1
}
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
return err
}
skillFields := []dnfrepo.SkillField{dnfrepo.SkillFieldPoints}
if reward.HasProfession {
skillFields = append(
skillFields,
dnfrepo.SkillFieldSkills,
dnfrepo.SkillFieldTrees,
dnfrepo.SkillFieldLayouts,
dnfrepo.SkillFieldCooldowns,
)
}
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill, skillFields...); err != nil {
return err
}
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
// 账号记录既可能被"扣材料"改到,也可能被"发奖励"改到(无色/灵魂落固定格),
// 两者任意一种发生都要在同一事务里落库。
if accountSharedTouched {
if err := tx.AccountInventory.Save(ctx, accountInventory); err != nil {
return err
}
}
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, quests, questFields...); err != nil {
return err
}
candidate = finishResultFromCommitted(receipt, false, character, skill, inventory, accountInventory, quests)
candidate.VoucherWallet = voucherWallet
return nil
})
if err != nil {
return FinishCommitResult{}, err
}
candidate.AtomicCommitted = true
return cloneFinishCommitResult(candidate), nil
}
func finishQuestClearTrigger(catalog *Catalog, record *dnfrepo.QuestRecord, questID int64, completedAt time.Time) (int64, []dnfrepo.QuestField, bool) {
if catalog == nil || record == nil {
return 0, nil, false
}
definition, known := catalog.Find(questID)
if !known {
return 0, nil, false
}
tag := normalizeQuestTag(definition.Type)
if tag != "quest clear" && tag != "clear quest" {
return 0, nil, false
}
completed, _ := questStateSets(*record)
trigger := questClearParentTrigger(definition.IntData, completed)
if trigger == 0 {
return 0, nil, true
}
// The current client finishes the final linked subquest differently from
// intermediate linked steps: after its trigger channels reach zero it sends
// op33 for the parent and then op34 for the parent, without a child op34.
// Accept that terminal state only when every unresolved PVF child belongs to
// this parent and is durably active at exactly zero progress.
type terminalChild struct {
questID int64
field dnfrepo.QuestField
state dnfrepo.QuestState
}
children := make([]terminalChild, 0, len(definition.IntData))
for _, childID := range definition.IntData {
if childID <= 0 {
continue
}
if _, done := completed[childID]; done {
continue
}
childDefinition, childKnown := catalog.Find(childID)
childState, childField, stateKnown := mutableQuestState(record, childID)
if !childKnown || childDefinition.MainQuestID != questID || !linkedObjectiveQuest(childDefinition) || !stateKnown ||
!isActiveQuestStatus(childState.Status) || childState.ProgressValue != 0 {
return trigger, nil, true
}
children = append(children, terminalChild{questID: childID, field: childField, state: childState})
}
if len(children) == 0 {
return trigger, nil, true
}
changedFields := make([]dnfrepo.QuestField, 0, 2)
for _, child := range children {
child.state.Status = "completed"
child.state.ProgressValue = 0
child.state.UpdatedAt = completedAt
if child.state.Extra == nil {
child.state.Extra = make(map[string]string, 6)
}
child.state.Extra["reward_state"] = finishRewardGranted
child.state.Extra["auto_completed"] = "true"
child.state.Extra["auto_complete_reason"] = "quest_clear_parent_terminal_zero_trigger"
child.state.Extra["auto_completed_by_parent"] = strconv.FormatInt(questID, 10)
switch child.field {
case dnfrepo.QuestFieldStates:
record.States[child.questID] = child.state
case dnfrepo.QuestFieldProgress:
record.Progress[child.questID] = child.state
default:
return trigger, nil, true
}
changedFields = mergeQuestChangedField(changedFields, child.field)
}
return 0, changedFields, true
}
// syncActiveQuestClearParentProgress ports only the C# completion-transaction
// rule: after a quest becomes cleared, every currently active quest-clear
// parent recomputes its remaining prerequisite count in that same transaction.
// It intentionally changes no protocol field; the existing post-commit quest
// snapshots serialize the persisted trigger values for the current EXE.
func syncActiveQuestClearParentProgress(
catalog *Catalog,
record *dnfrepo.QuestRecord,
now time.Time,
) []dnfrepo.QuestField {
if catalog == nil || record == nil {
return nil
}
completed, _ := questStateSets(*record)
changed := make(map[dnfrepo.QuestField]bool, 2)
syncField := func(field dnfrepo.QuestField, states map[int64]dnfrepo.QuestState) {
if len(states) == 0 {
return
}
ids := make([]int64, 0, len(states))
for questID := range states {
ids = append(ids, questID)
}
sort.Slice(ids, func(left, right int) bool { return ids[left] < ids[right] })
for _, questID := range ids {
state := states[questID]
if !isActiveQuestStatus(state.Status) {
continue
}
definition, known := catalog.Find(questID)
if !known {
continue
}
tag := normalizeQuestTag(definition.Type)
if tag != "quest clear" && tag != "clear quest" {
continue
}
next := questClearParentTrigger(definition.IntData, completed)
if next < 0 {
next = 0
}
if state.ProgressValue == next {
continue
}
state.ProgressValue = next
state.UpdatedAt = now
states[questID] = state
changed[field] = true
}
}
syncField(dnfrepo.QuestFieldStates, record.States)
syncField(dnfrepo.QuestFieldProgress, record.Progress)
fields := make([]dnfrepo.QuestField, 0, len(changed))
for _, field := range []dnfrepo.QuestField{dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress} {
if changed[field] {
fields = append(fields, field)
}
}
return fields
}
// questClearParentTrigger deliberately differs from the accept-time helper:
// an active quest-clear parent with real prerequisites is finishable when all
// of them are completed, so its trigger becomes zero. A malformed/empty
// prerequisite list keeps the existing C# fallback of one.
func questClearParentTrigger(values []int64, completed map[int64]struct{}) int64 {
hasRequired := false
missing := int64(0)
for _, questID := range values {
if questID <= 0 {
continue
}
hasRequired = true
if _, exists := completed[questID]; !exists {
missing++
}
}
if !hasRequired {
return 1
}
return int64(boundedTriggerChannel(missing))
}
func planFinishProgression(
tables *progression.Tables,
character dnfrepo.CharacterRecord,
skill dnfrepo.SkillRecord,
gain uint32,
) (progression.ExperienceSkillPointPlan, error) {
if tables == nil || character.Level <= 0 || character.Level > progression.MaximumCharacterLevel ||
skill.Points.SyncedLevel != character.Level {
return progression.ExperienceSkillPointPlan{}, fmt.Errorf("%w: level=%d synced=%d", ErrFinishProgressionUnproven, character.Level, skill.Points.SyncedLevel)
}
experienceValue, exists := character.Stats["exp"]
if !exists || experienceValue < 0 || experienceValue > math.MaxUint32 {
return progression.ExperienceSkillPointPlan{}, fmt.Errorf("%w: exp=%d present=%t", ErrFinishProgressionUnproven, experienceValue, exists)
}
newTotal := uint64(experienceValue) + uint64(gain)
if newTotal > math.MaxUint32 {
return progression.ExperienceSkillPointPlan{}, progression.ErrExperienceOutOfRange
}
result := progression.ExperienceResult{
PreviousLevel: character.Level, PreviousExperience: uint32(experienceValue), Gain: gain,
NewLevel: character.Level, NewExperience: uint32(newTotal),
}
for result.NewLevel < progression.MaximumCharacterLevel {
threshold, err := tables.ThresholdToNext(result.NewLevel)
if err != nil {
return progression.ExperienceSkillPointPlan{}, fmt.Errorf("%w: level=%d: %v", ErrFinishProgressionUnproven, result.NewLevel, err)
}
if result.NewExperience < threshold {
break
}
if _, ok := tables.SkillPointsAtLevel(result.NewLevel + 1); !ok {
return progression.ExperienceSkillPointPlan{}, fmt.Errorf("%w: missing SP row level=%d", ErrFinishProgressionUnproven, result.NewLevel+1)
}
result.NewLevel++
}
// Ordinary character EXP is pinned to the cumulative threshold that enters
// level 90. ExpTable/SP rows above 90 are compatibility data, not permission
// for a quest finish to persist level 91.
if result.NewLevel == progression.MaximumCharacterLevel {
entryThreshold, err := tables.ThresholdToNext(progression.MaximumCharacterLevel - 1)
if err != nil {
return progression.ExperienceSkillPointPlan{}, fmt.Errorf(
"%w: level=%d: %v",
ErrFinishProgressionUnproven,
progression.MaximumCharacterLevel,
err,
)
}
if result.NewExperience > entryThreshold {
result.NewExperience = entryThreshold
result.Saturated = true
}
}
result.LevelsGained = result.NewLevel - result.PreviousLevel
points, err := tables.AdvanceSkillPoints(skill.Points, result.NewLevel)
if err != nil {
return progression.ExperienceSkillPointPlan{}, err
}
return progression.ExperienceSkillPointPlan{Experience: result, SkillPoints: points}, nil
}
func finishExperienceWithBonus(base uint32, percent int64) (uint32, uint32) {
if base == 0 || percent <= 0 {
return base, 0
}
limit := uint64(math.MaxUint32 - base)
if uint64(percent) > math.MaxUint64/uint64(base) {
return math.MaxUint32, uint32(limit)
}
bonus := uint64(base) * uint64(percent) / 100
if bonus > limit {
bonus = limit
}
return base + uint32(bonus), uint32(bonus)
}
// splitFinishVoucherRules 把任务奖励规则拆成"券"和"普通道具"两组,保持原有顺序。
//
// 券(代币券 2681762 / 欢乐代币券 2681917)不落背包,直接入账号钱包;其余规则原样
// 交给既有的落格逻辑,所以非券奖励的行为完全不变。
func splitFinishVoucherRules(rules []RewardItemRule) (vouchers, items []RewardItemRule) {
for _, rule := range rules {
if dnfcerashop.IsVoucher(rule.ItemID) {
vouchers = append(vouchers, rule)
continue
}
items = append(items, rule)
}
return vouchers, items
}
// finishVoucherCounts 把券规则折成入账数量。
//
// 这里对每条规则做与 allocateFinishRewardItems 相同的字段校验:券既然不走落格路径,
// 就没人替我们挡住 `Count <= 0` / 超出 uint32 这类坏值了,必须自己挡。
func finishVoucherCounts(rules []RewardItemRule) (dnfcerashop.VoucherCounts, error) {
var counts dnfcerashop.VoucherCounts
for _, rule := range rules {
if rule.ItemID <= 0 || rule.Count <= 0 || rule.Count > math.MaxUint32 {
return dnfcerashop.VoucherCounts{}, ErrQuestRewardMalformed
}
switch dnfcerashop.VoucherKindOf(rule.ItemID) {
case dnfcerashop.VoucherTokenCera:
if counts.TokenCera > math.MaxInt32-rule.Count {
return dnfcerashop.VoucherCounts{}, fmt.Errorf("%w: token cera voucher overflow", ErrQuestRewardMalformed)
}
counts.TokenCera += rule.Count
case dnfcerashop.VoucherHappyTokenCera:
if counts.HappyTokenCera > math.MaxInt32-rule.Count {
return dnfcerashop.VoucherCounts{}, fmt.Errorf("%w: happy token cera voucher overflow", ErrQuestRewardMalformed)
}
counts.HappyTokenCera += rule.Count
default:
return dnfcerashop.VoucherCounts{}, fmt.Errorf("%w: item %d is not a voucher", ErrQuestRewardMalformed, rule.ItemID)
}
}
return counts, nil
}
func allocateFinishRewardItems(
record *dnfrepo.InventoryRecord,
rules []RewardItemRule,
allocator FinishItemAllocator,
base FinishItemGrantRequest,
) ([]FinishCommittedItem, error) {
for _, rule := range rules {
if rule.ItemID <= 0 || rule.Count <= 0 || rule.Count > math.MaxUint32 {
return nil, ErrQuestRewardMalformed
}
}
if len(rules) != 0 && allocator == nil {
return nil, ErrFinishItemAllocatorRequired
}
items := make([]FinishCommittedItem, 0, len(rules))
positions := make(map[string]int, len(rules))
for _, rule := range rules {
request := base
request.ItemID = rule.ItemID
request.Count = rule.Count
item, err := allocator(record, request)
if err != nil {
return nil, err
}
if item.ItemID != rule.ItemID || item.Delta != rule.Count || item.SlotKey == "" || item.PostCount < item.Delta || item.CountOrSeed == 0 || len(item.RawEntry) == 0 {
return nil, fmt.Errorf("%w: invalid allocator receipt item=%d", ErrQuestRewardMalformed, rule.ItemID)
}
if index, exists := positions[item.SlotKey]; exists {
previous := items[index]
if previous.ItemID != item.ItemID || previous.SlotIndex != item.SlotIndex ||
previous.Delta > math.MaxUint32-item.Delta ||
previous.CountOrSeed > math.MaxUint32-item.CountOrSeed ||
previous.PostCount > math.MaxInt64-item.Delta ||
item.PostCount != previous.PostCount+item.Delta {
return nil, fmt.Errorf("%w: inconsistent allocator receipt slot=%s item=%d", ErrQuestRewardMalformed, item.SlotKey, rule.ItemID)
}
item.Delta += previous.Delta
item.CountOrSeed += previous.CountOrSeed
items[index] = item
continue
}
positions[item.SlotKey] = len(items)
items = append(items, item)
}
return items, nil
}
// finishRequiredItemRules 汇总这次交付要消耗的**道具**。
//
// 口径(服主 2026-10-03):[int data] 是 (ID, 数量) 成对,**ID 0 = 金币**。
// 金币**不在这里**处理 —— 它走 FinishRewardPlan.GoldCost + chargeFinishGoldCost,
// 原因有二:
// 1. 这里的规则表会被当作"背包里要有多少件"去盘点(consumeFinishRequiredItems),
// 把 0 塞进去只会得到一条 item=0 的"道具不足",玩家看不懂;
// 2. 这个表还会经 itemPairRulesFromInts 喂给客户端的 seeking_item_ids,
// 金币混进去会让任务目标列表多出一个 id=0 的假道具需求。
//
// 但 `itemID <= 0` 这条旧过滤**确实**是"金币任务不扣钱"的根因之一:它把 (0, 金额)
// 整条丢掉了,连"有金币要求"这个事实都没留下来。现在这件事由 finishDefinitionGoldCost
// 单独承担。
func finishRequiredItemRules(catalog *Catalog, definition Definition, character CharacterEligibility) []RewardItemRule {
_ = character
coalesced := make(map[int64]int64, 4)
add := func(itemID, count int64) {
if itemID <= 0 || count <= 0 {
return
}
current := coalesced[itemID]
if count > math.MaxUint32 || current > math.MaxUint32-count {
coalesced[itemID] = math.MaxUint32 + 1
return
}
coalesced[itemID] = current + count
}
// 交付物的**唯一权威来源**:finishDeliveryRules。金币的统计
// (finishDefinitionGoldCost)走同一个函数,两边的遍历顺序不可能再各自漂移。
carryForward := finishCarryForwardEventItemIDs(catalog, definition)
for _, rule := range finishDeliveryRules(definition) {
if carryForward[rule.ItemID] {
continue
}
add(rule.ItemID, rule.Count)
}
if len(coalesced) == 0 {
return nil
}
itemIDs := make([]int64, 0, len(coalesced))
for itemID := range coalesced {
itemIDs = append(itemIDs, itemID)
}
sort.Slice(itemIDs, func(left, right int) bool { return itemIDs[left] < itemIDs[right] })
out := make([]RewardItemRule, 0, len(itemIDs))
for _, itemID := range itemIDs {
out = append(out, RewardItemRule{ItemID: itemID, Count: coalesced[itemID]})
}
return out
}
// finishDeliveryRules 汇总"交付要求"里的全部条目,**保留 ID 0(金币)**。
//
// 两个调用方共用它,这是刻意的:`finishRequiredItemRules`(扣道具)与
// `finishDefinitionGoldCost`(扣金币)必须看同一份交付定义 —— 早先它们各写一遍遍历,
// 服主 2026-10-03 追问"金币不在首位还能不能认"时就暴露出这个隐患:
// `[seeking]` 的交付物来自 **[depend give item]**,而金币统计当时只看了
// finishDefinitionSeekingRules,于是"金币只写在 depend give item 里"的任务会被漏掉。
//
// 不含金币过滤:**语义由类型决定**(只有 seeking 家族的 [int data] 是交付物,
// [use skill] / [clear quest by grade] 等类型的 [int data] 是条件参数,不在这里),
// 由调用方各自决定怎么处理 ID 0。
func finishDeliveryRules(definition Definition) []RewardItemRule {
rules := append([]RewardItemRule(nil), finishDefinitionSeekingRules(definition)...)
// [depend give item] 是接任务时发放、交任务时回收的那一节(既有语义,见
// finishRequiredItemRules 与 give_up_reclaim.go),同样是"交付要求"。
rules = append(rules, itemPairRulesAllowingGold(definition.DependGiveItemData)...)
return rules
}
// finishGoldRequiredItemID 是 [int data] 里金币的"ID"(服主 2026-10-03 口径)。
const finishGoldRequiredItemID int64 = 0
// finishDefinitionGoldCost 汇总这个任务交付要的金币总量。
//
// 只看**交付要求**(finishDeliveryRules:seeking 家族的 [int data] + [depend give item]),
// 绝不碰 [reward int data]:那里的 `0 0` 是"无奖励"占位,而真实的金币奖励由
// [gold multiple] 标记 + 等级表算出来(applyFinishRewardCurrency),是另一条路径。
// 把两者混起来会凭空给一堆任务加上金币消耗。
//
// 金币的位置无意义:只要 (0, 金额) 成对出现,在首位、中间还是末位都算
// (用例 TestOwnerApplyFinishSettlementChargesGoldAnywhereInIntData)。
func finishDefinitionGoldCost(definition Definition) int64 {
total := int64(0)
for _, rule := range finishDeliveryRules(definition) {
if rule.ItemID != finishGoldRequiredItemID || rule.Count <= 0 {
continue
}
if total > math.MaxInt64-rule.Count {
return math.MaxInt64
}
total += rule.Count
}
return total
}
func finishDefinitionSeekingRules(definition Definition) []RewardItemRule {
switch normalizeQuestTag(definition.Type) {
case "seek n meet npc":
if len(definition.IntData) < 2 {
return nil
}
return []RewardItemRule{{ItemID: definition.IntData[0], Count: definition.IntData[1]}}
case "seeking":
// 保留 ID 0:金币在 [int data] 里就是 0。规则表在这里只用于"数金币",
// 真正扣款走 chargeFinishGoldCost(见 finishRequiredItemRules 的说明)。
return itemPairRulesAllowingGold(definition.IntData)
default:
return nil
}
}
// itemPairRulesAllowingGold 与 itemPairRulesFromInts 同构,但**保留** ID 0(金币)。
// 只用于交付要求这条路径的量算;喂给客户端的列表仍然用 itemPairRulesFromInts。
func itemPairRulesAllowingGold(values []int64) []RewardItemRule {
if len(values) < 2 {
return nil
}
rules := make([]RewardItemRule, 0, len(values)/2)
for index := 0; index+1 < len(values); index += 2 {
itemID, count := values[index], values[index+1]
if itemID < 0 || count <= 0 {
continue
}
rules = append(rules, RewardItemRule{ItemID: itemID, Count: count})
}
return rules
}
func itemPairRulesFromInts(values []int64) []RewardItemRule {
if len(values) < 2 {
return nil
}
rules := make([]RewardItemRule, 0, len(values)/2)
for index := 0; index+1 < len(values); index += 2 {
itemID, count := values[index], values[index+1]
if itemID <= 0 || count <= 0 {
continue
}
rules = append(rules, RewardItemRule{ItemID: itemID, Count: count})
}
return rules
}
func finishCarryForwardEventItemIDs(catalog *Catalog, definition Definition) map[int64]bool {
carryForward := make(map[int64]bool)
if catalog == nil || !definition.HasDependGiveItem {
return carryForward
}
eventRules := itemPairRulesFromInts(definition.DependGiveItemData)
if len(eventRules) == 0 {
return carryForward
}
for _, successor := range catalog.Successors(definition.ID) {
nextNeeds := make(map[int64]bool)
for _, rule := range finishDefinitionSeekingRules(successor) {
nextNeeds[rule.ItemID] = true
}
if len(nextNeeds) == 0 {
continue
}
nextGives := make(map[int64]bool)
for _, rule := range itemPairRulesFromInts(successor.DependGiveItemData) {
nextGives[rule.ItemID] = true
}
for _, rule := range eventRules {
if nextNeeds[rule.ItemID] && !nextGives[rule.ItemID] {
carryForward[rule.ItemID] = true
}
}
}
return carryForward
}
func consumeFinishRequiredItems(
record *dnfrepo.InventoryRecord,
account *dnfrepo.AccountInventoryRecord,
rules []RewardItemRule,
) ([]FinishConsumedItem, error) {
if len(rules) == 0 {
return nil, nil
}
if record == nil || account == nil {
return nil, ErrFinishSettlementUnavailable
}
required := make(map[int64]int64, len(rules))
for _, rule := range rules {
if rule.ItemID <= 0 || rule.Count <= 0 || rule.Count > math.MaxUint32 {
return nil, ErrQuestRewardMalformed
}
if required[rule.ItemID] > math.MaxUint32-rule.Count {
return nil, ErrQuestRewardMalformed
}
required[rule.ItemID] += rule.Count
}
if len(required) == 0 {
return nil, nil
}
type candidate struct {
key string
slot uint16
accountShared bool
stack dnfrepo.ItemStack
}
candidatesByItem := make(map[int64][]candidate, len(required))
for key, stack := range record.Slots {
slot, ok := parseFinishMainInventorySlotKey(key)
if !ok || finishSlotIsAccountShared(slot) || stack.ItemID <= 0 {
continue
}
if _, needed := required[stack.ItemID]; !needed {
continue
}
candidatesByItem[stack.ItemID] = append(candidatesByItem[stack.ItemID], candidate{
key: key, slot: slot, stack: stack,
})
}
for key, stack := range account.Slots {
slot, ok := parseFinishMainInventorySlotKey(key)
if !ok || !finishSlotIsAccountShared(slot) || stack.ItemID <= 0 {
continue
}
if _, needed := required[stack.ItemID]; !needed {
continue
}
candidatesByItem[stack.ItemID] = append(candidatesByItem[stack.ItemID], candidate{
key: key, slot: slot, accountShared: true, stack: stack,
})
}
for itemID, candidates := range candidatesByItem {
sort.Slice(candidates, func(left, right int) bool {
return candidates[left].slot < candidates[right].slot
})
candidatesByItem[itemID] = candidates
}
for itemID, count := range required {
held := int64(0)
for _, candidate := range candidatesByItem[itemID] {
held += finishRequiredItemAvailableCount(candidate.stack, candidate.accountShared)
if held >= count {
break
}
}
if held < count {
return nil, fmt.Errorf("%w: item=%d required=%d held=%d", ErrFinishRequiredItemsMissing, itemID, count, held)
}
}
itemIDs := make([]int64, 0, len(required))
for itemID := range required {
itemIDs = append(itemIDs, itemID)
}
sort.Slice(itemIDs, func(left, right int) bool { return itemIDs[left] < itemIDs[right] })
consumed := make([]FinishConsumedItem, 0, len(required))
for _, itemID := range itemIDs {
remaining := required[itemID]
for _, candidate := range candidatesByItem[itemID] {
if remaining <= 0 {
break
}
slots := record.Slots
if candidate.accountShared {
slots = account.Slots
}
stack, exists := slots[candidate.key]
if !exists || stack.ItemID != itemID {
return nil, ErrFinishRequiredItemsMissing
}
available := finishRequiredItemAvailableCount(stack, candidate.accountShared)
if available <= 0 {
continue
}
take := available
if take > remaining {
take = remaining
}
post := available - take
entry := FinishConsumedItem{
SlotKey: candidate.key,
SlotIndex: candidate.slot,
ItemID: itemID,
Delta: take,
PostCount: post,
}
if post <= 0 {
delete(slots, candidate.key)
} else {
stack.Count = post
stack = finishStackWithUpdatedCount(stack, candidate.slot, itemID, post)
slots[candidate.key] = stack
entry.RawEntry = append([]byte(nil), stack.RawEntry...)
}
consumed = append(consumed, entry)
remaining -= take
}
if remaining != 0 {
return nil, ErrFinishRequiredItemsMissing
}
}
return consumed, nil
}
func finishSlotIsAccountShared(slot uint16) bool {
return slot <= math.MaxInt16 && dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, int16(slot))
}
func finishConsumedAccountSharedItems(items []FinishConsumedItem) bool {
for _, item := range items {
if finishSlotIsAccountShared(item.SlotIndex) {
return true
}
}
return false
}
// finishGrantedAccountSharedItems reports whether any granted reward landed in an
// account-shared warehouse cell (list 0 slot 354..365) instead of the backpack.
// Such a grant mutates the account record, so the caller must save it inside the
// same settlement transaction -- mirroring finishConsumedAccountSharedItems.
func finishGrantedAccountSharedItems(items []FinishCommittedItem) bool {
for _, item := range items {
if finishSlotIsAccountShared(item.SlotIndex) {
return true
}
}
return false
}
func parseFinishMainInventorySlotKey(key string) (uint16, bool) {
listRaw, slotRaw, ok := strings.Cut(key, ":")
if !ok || strings.TrimSpace(listRaw) != "0" {
return 0, false
}
slot, err := strconv.ParseUint(strings.TrimSpace(slotRaw), 10, 16)
if err != nil || slot > math.MaxInt16 {
return 0, false
}
return uint16(slot), true
}
func finishStackAvailableCount(stack dnfrepo.ItemStack) int64 {
if stack.Count > 0 {
return stack.Count
}
return 1
}
func finishRequiredItemAvailableCount(stack dnfrepo.ItemStack, accountShared bool) int64 {
if accountShared && stack.Count <= 0 {
return 0
}
return finishStackAvailableCount(stack)
}
func finishStackWithUpdatedCount(stack dnfrepo.ItemStack, slot uint16, itemID, count int64) dnfrepo.ItemStack {
if stack.Extra != nil {
for _, key := range []string{"amount_or_count", "amount", "count", "stack", "quantity"} {
if _, exists := stack.Extra[key]; exists {
stack.Extra[key] = strconv.FormatInt(count, 10)
}
}
}
if len(stack.RawEntry) == currentQuestFinishRawEntrySize && itemID > 0 && itemID <= math.MaxUint32 && count > 0 && count <= math.MaxUint32 {
raw := append([]byte(nil), stack.RawEntry...)
binary.LittleEndian.PutUint16(raw[0:2], slot)
binary.LittleEndian.PutUint32(raw[2:6], uint32(itemID))
binary.LittleEndian.PutUint32(raw[6:10], uint32(count))
stack.RawEntry = raw
}
return stack
}
const currentQuestFinishRawEntrySize = 0x77
func finishResultFromReceipt(
ctx context.Context,
input FinishCommitInput,
hellPartyPermissions dnfrepo.HellPartyPermissionRepository,
state dnfrepo.QuestState,
character dnfrepo.CharacterRecord,
skill dnfrepo.SkillRecord,
inventory dnfrepo.InventoryRecord,
accountInventory dnfrepo.AccountInventoryRecord,
quests dnfrepo.QuestRecord,
) (FinishCommitResult, error) {
if ctx == nil {
ctx = context.Background()
}
raw := strings.TrimSpace(state.Extra[finishSettlementReceiptKey])
if raw == "" {
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
var receipt finishSettlementReceipt
if err := json.Unmarshal([]byte(raw), &receipt); err != nil {
return FinishCommitResult{}, fmt.Errorf("%w: %v", ErrFinishQuestSettlementCorrupt, err)
}
if receipt.Version != finishSettlementReceiptLevel || receipt.CharacterID != input.CharacterID || receipt.QuestID != input.QuestID ||
receipt.CompletionKey == "" || receipt.CompletionKey != strings.TrimSpace(state.Extra["completion_key"]) || receipt.Source == "" {
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
if len(receipt.HellPartyUnlocks) != 0 {
unlock, err := validFinishHellPartyUnlocks(receipt.HellPartyUnlocks, input.QuestID)
if err != nil || hellPartyPermissions == nil {
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
permissions, found, err := hellPartyPermissions.Load(ctx, input.CharacterID)
if err != nil {
return FinishCommitResult{}, err
}
if !found || !finishHellPartyPermissionsContain(permissions, unlock) {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
}
if input.ExpectedCompletionKey != "" && input.ExpectedCompletionKey != receipt.CompletionKey {
return FinishCommitResult{}, ErrFinishQuestCompletionConflict
}
exp, ok := character.Stats["exp"]
if !ok || character.Level != receipt.NewLevel || exp != int64(receipt.NewExperience) || skill.Points != receipt.PostSkillPoints {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
if receipt.HasProfession && character.Stats["grow_type"] != int64(receipt.Profession.NewGrowType) {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
if !validFinishProfessionGrants(receipt.ProfessionGrants, receipt.HasProfession) {
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
if receipt.HasExpertJob && character.Stats["expert_job_type"] != int64(receipt.ExpertJobType) {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
if receipt.HasSlotExpansion {
expectedBit, validIndex := ExEquipSlotBitForPVFIndex(receipt.SlotExpansionIndex)
if !validIndex ||
receipt.SlotExpansionBit != expectedBit ||
character.Stats["ex_equip_slot_stat"]&int64(receipt.SlotExpansionBit) == 0 {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
}
for _, item := range receipt.Items {
// 奖励行的家可能在账号共享仓(无色/灵魂落 354..365 固定格),
// 与下面 ConsumedItems 的判定同一口径:先看槽号属于哪一侧,再去那一侧找。
// 少了这个分支,重放会把已经落库的奖励判成 stale,
// 于是重连后的重放 ACK 永远发不出去。
slots := inventory.Slots
if finishSlotIsAccountShared(item.SlotIndex) {
slots = accountInventory.Slots
}
stack, exists := slots[item.SlotKey]
if !exists || stack.ItemID != item.ItemID || stack.Count != item.PostCount || !bytes.Equal(stack.RawEntry, item.RawEntry) {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
}
for _, item := range receipt.ConsumedItems {
if item.SlotKey == "" || item.SlotIndex > math.MaxInt16 || item.ItemID <= 0 || item.Delta <= 0 || item.Delta > math.MaxUint32 || item.PostCount < 0 {
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
slots := inventory.Slots
if finishSlotIsAccountShared(item.SlotIndex) {
slots = accountInventory.Slots
}
stack, exists := slots[item.SlotKey]
if item.PostCount == 0 {
if exists && stack.ItemID == item.ItemID && stack.Count > 0 {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
continue
}
if !exists || stack.ItemID != item.ItemID || stack.Count != item.PostCount || !bytes.Equal(stack.RawEntry, item.RawEntry) {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
}
for _, currency := range receipt.Currency {
switch strings.ToLower(strings.TrimSpace(currency.Name)) {
case "gold":
// 金币条目的 Delta **可以是负数**:交付金币(PVF [int data] 的 ID 0,
// 服主口径 2026-10-03)走的就是这一条,例如耳环槽位任务扣 100 万。
// 因此判据不能是"Delta < 0 即损坏",而要看它自洽且没把钱包扣成负数:
// PostValue >= 0 —— 钱包不能为负;
// |Delta| <= PostValue —— 扣款额不可能超过扣完的余额;
// PostValue == 落库后的金币 —— 防陈旧(重放/并发改钱)。
if currency.PostValue < 0 {
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
delta := currency.Delta
if delta < 0 {
delta = -delta
}
if delta > currency.PostValue {
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
if character.Stats["gold"] != currency.PostValue {
return FinishCommitResult{}, ErrFinishQuestSettlementStale
}
default:
return FinishCommitResult{}, ErrFinishQuestSettlementCorrupt
}
}
return finishResultFromCommitted(receipt, true, character, skill, inventory, accountInventory, quests), nil
}
func validFinishHellPartyUnlocks(unlocks []HellPartyUnlock, questID int64) ([]HellPartyUnlock, error) {
if questID <= 0 || len(unlocks) == 0 {
return nil, ErrFinishSettlementUnavailable
}
seen := make(map[int64]struct{}, len(unlocks))
result := make([]HellPartyUnlock, 0, len(unlocks))
for _, unlock := range unlocks {
if unlock.AreaID <= 0 || unlock.QuestID != questID {
return nil, ErrFinishSettlementUnavailable
}
if _, exists := seen[unlock.AreaID]; exists {
return nil, ErrFinishSettlementUnavailable
}
seen[unlock.AreaID] = struct{}{}
result = append(result, unlock)
}
sort.Slice(result, func(i, j int) bool { return result[i].AreaID < result[j].AreaID })
return result, nil
}
func cloneFinishHellPartyUnlocks(unlocks []HellPartyUnlock) []HellPartyUnlock {
return append([]HellPartyUnlock(nil), unlocks...)
}
func finishHellPartyPermissionsContain(record dnfrepo.HellPartyPermissionRecord, unlocks []HellPartyUnlock) bool {
if len(unlocks) == 0 {
return true
}
byArea := make(map[int64]int64, len(record.Entries))
for _, entry := range record.Entries {
if entry.AreaID > 0 && entry.QuestID > 0 {
byArea[entry.AreaID] = entry.QuestID
}
}
for _, unlock := range unlocks {
if _, ok := byArea[unlock.AreaID]; !ok {
return false
}
}
return true
}
// chargeFinishGoldCost 扣掉这次交付要求的金币(非纯金币任务 = 0,直接跳过)。
//
// 口径(服主 2026-10-03):PVF [int data] 里 **ID 0 就是金币**,例如开耳环槽位任务的
// `0 1000000 3034 1000 ...` 要求 100 万金币。此前 (0, 金额) 被当成非法 itemID 整条丢掉,
// 全库 68 个任务的交付金币从来没扣过。
//
// 位置在金币**奖励**之前:先交钱再领奖。金币不够就整笔拒绝(ErrFinishRequiredItemsMissing),
// 复用既有的 op34 失败语义 —— 任务不会完成、材料不会被吃掉。
func chargeFinishGoldCost(
character *dnfrepo.CharacterRecord,
reward FinishRewardPlan,
) ([]FinishCommittedCurrency, error) {
if character == nil || reward.GoldCost <= 0 {
return nil, nil
}
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
before := character.Stats["gold"]
if before < reward.GoldCost {
return nil, fmt.Errorf("%w: gold required=%d held=%d quest=%d",
ErrFinishRequiredItemsMissing, reward.GoldCost, before, reward.QuestID)
}
after := before - reward.GoldCost
character.Stats["gold"] = after
// Delta 为负:收据里这是"扣",而 applyFinishRewardCurrency 那条是"发"。
// finishSettlementReceipt.HasCommitted 的一致性检查读的是**最后一条** gold 的
// PostValue,所以扣款条目必须排在奖励条目之前 —— 见调用点。
return []FinishCommittedCurrency{{
Name: "gold",
Delta: -reward.GoldCost,
PostValue: after,
}}, nil
}
func applyFinishRewardCurrency(
character *dnfrepo.CharacterRecord,
tables *progression.Tables,
reward FinishRewardPlan,
) ([]FinishCommittedCurrency, error) {
if character == nil || !reward.HasGoldReward {
return nil, nil
}
goldDelta, err := tables.QuestGold(character.Level, reward.QuestLevel, reward.GoldMultiple, reward.IgnoreLevel4Exp)
if err != nil {
return nil, err
}
if goldDelta == 0 {
return nil, nil
}
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
before := character.Stats["gold"]
delta := int64(goldDelta)
if before < 0 || before > (1<<63-1)-delta {
return nil, ErrQuestRewardMalformed
}
// The carry cap the character has bought bites on the quest faucet too. A
// quest reward cannot be handed back once the quest is finished, so the
// excess is discarded and the committed delta reports what was actually
// granted.
after, discarded := carrygold.Credit(*character, delta)
character.Stats["gold"] = after
return []FinishCommittedCurrency{{
Name: "gold",
Delta: delta - discarded,
PostValue: after,
}}, nil
}
func finishResultFromCommitted(
receipt finishSettlementReceipt,
replayed bool,
character dnfrepo.CharacterRecord,
skill dnfrepo.SkillRecord,
inventory dnfrepo.InventoryRecord,
accountInventory dnfrepo.AccountInventoryRecord,
quests dnfrepo.QuestRecord,
) FinishCommitResult {
return FinishCommitResult{
CharacterID: receipt.CharacterID, CompletionKey: receipt.CompletionKey, Source: receipt.Source,
QuestID: receipt.QuestID, Replayed: replayed,
BaseExperienceDelta: receipt.BaseExperienceDelta, ExperienceBonusDelta: receipt.ExperienceBonusDelta,
ContinuityExperienceDelta: receipt.ContinuityExperienceDelta,
ExperienceDelta: receipt.ExperienceDelta,
PreviousLevel: receipt.PreviousLevel, NewLevel: receipt.NewLevel,
PreviousExperience: receipt.PreviousExperience, NewExperience: receipt.NewExperience,
SPDelta: receipt.SPDelta, TPDelta: receipt.TPDelta,
Items: cloneFinishItems(receipt.Items), ConsumedItems: cloneFinishConsumedItems(receipt.ConsumedItems),
Currency: append([]FinishCommittedCurrency(nil), receipt.Currency...),
// 券的入账数量从收据原样带出:重放路径必须报告与首次结算完全相同的事实。
VoucherCredits: receipt.VoucherCredits,
Profession: receipt.Profession, ProfessionGrants: cloneProfessionGrants(receipt.ProfessionGrants),
HasProfession: receipt.HasProfession,
ExpertJobType: receipt.ExpertJobType, HasExpertJob: receipt.HasExpertJob,
HasSlotExpansion: receipt.HasSlotExpansion, SlotExpansionIndex: receipt.SlotExpansionIndex,
SlotExpansionBit: receipt.SlotExpansionBit,
PostCommitCharacter: dnfrepo.CloneCharacter(character), PostCommitSkill: dnfrepo.CloneSkill(skill),
PostCommitInventory: dnfrepo.CloneInventory(inventory),
PostCommitAccountInventory: dnfrepo.CloneAccountInventory(accountInventory),
PostCommitQuest: dnfrepo.CloneQuest(quests),
}
}
func finishProfessionGrants(before dnfrepo.SkillRecord, after dnfrepo.SkillRecord, enabled bool) ([]profession.Grant, error) {
if !enabled {
return nil, nil
}
grants := make([]profession.Grant, 0)
for rawSkillID, state := range after.Skills {
previous := before.Skills[rawSkillID]
if !state.Enabled || (previous.Enabled && previous.Level >= state.Level) {
continue
}
if rawSkillID <= 0 || rawSkillID > math.MaxUint16 || state.Level <= 0 || state.Level > math.MaxUint8 {
return nil, fmt.Errorf("%w: profession skill=%d level=%d", ErrQuestRewardMalformed, rawSkillID, state.Level)
}
grants = append(grants, profession.Grant{SkillID: uint16(rawSkillID), Level: state.Level})
}
sort.Slice(grants, func(i, j int) bool {
return grants[i].SkillID < grants[j].SkillID
})
return grants, nil
}
func validFinishProfessionGrants(grants []profession.Grant, hasProfession bool) bool {
if !hasProfession {
return len(grants) == 0
}
var previous uint16
for index, grant := range grants {
if grant.SkillID == 0 || grant.Level <= 0 || grant.Level > math.MaxUint8 ||
(index != 0 && grant.SkillID <= previous) {
return false
}
previous = grant.SkillID
}
return len(grants) <= math.MaxUint8
}
func cloneProfessionGrants(grants []profession.Grant) []profession.Grant {
if len(grants) == 0 {
return nil
}
return append([]profession.Grant(nil), grants...)
}
func cloneFinishItems(items []FinishCommittedItem) []FinishCommittedItem {
if len(items) == 0 {
return nil
}
out := append([]FinishCommittedItem(nil), items...)
for index := range out {
out[index].RawEntry = append([]byte(nil), out[index].RawEntry...)
}
return out
}
func cloneFinishConsumedItems(items []FinishConsumedItem) []FinishConsumedItem {
if len(items) == 0 {
return nil
}
out := append([]FinishConsumedItem(nil), items...)
for index := range out {
out[index].RawEntry = append([]byte(nil), out[index].RawEntry...)
}
return out
}
func cloneFinishCommitResult(result FinishCommitResult) FinishCommitResult {
result.Items = cloneFinishItems(result.Items)
result.ConsumedItems = cloneFinishConsumedItems(result.ConsumedItems)
result.Currency = append([]FinishCommittedCurrency(nil), result.Currency...)
if result.VoucherWallet != nil {
wallet := *result.VoucherWallet
result.VoucherWallet = &wallet
}
result.PostCommitCharacter = dnfrepo.CloneCharacter(result.PostCommitCharacter)
result.PostCommitSkill = dnfrepo.CloneSkill(result.PostCommitSkill)
result.PostCommitInventory = dnfrepo.CloneInventory(result.PostCommitInventory)
result.PostCommitAccountInventory = dnfrepo.CloneAccountInventory(result.PostCommitAccountInventory)
result.PostCommitQuest = dnfrepo.CloneQuest(result.PostCommitQuest)
return result
}