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

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

2016 lines
69 KiB
Go

package quest
import (
"context"
"errors"
"fmt"
"math"
"path"
"slices"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/jobmap"
"longheng.io/server/internal/modules/dnf/profession"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const DefaultList = "n_quest/quest.lst"
var ErrCatalogEmpty = errors.New("dnf quest catalog is empty")
var (
ErrQuestDefinitionMissing = errors.New("dnf quest definition is missing")
ErrQuestNotAcceptable = errors.New("dnf quest is not acceptable for this character state")
ErrQuestInitialTriggerUnsupported = errors.New("dnf quest initial trigger rule is not implemented")
ErrQuestRewardUnsupported = errors.New("dnf quest reward type is unsupported")
ErrQuestRewardMalformed = errors.New("dnf quest reward data is malformed")
ErrQuestRewardSelectionInvalid = errors.New("dnf quest reward selection is invalid")
)
// RewardItemRule preserves one exact PVF [reward int data] item rule. Job and
// grow-type filters are evaluated only after the selected character is known.
type RewardItemRule struct {
ItemID int64
Count int64
HasJobFilter bool
Job int
GrowType int
}
// TargetCharacterRule preserves one PVF [target character] tuple:
// job tag, first-grow branch, and awakening stage.
type TargetCharacterRule struct {
JobTag string
FirstGrowType int
AwakeningStage int
}
// QuestAnswerRequirement is one PVF [pre required quest answer] pair:
// completed question quest ID followed by its zero-based selected answer.
type QuestAnswerRequirement struct {
QuestID int64
Answer int64
}
// MonsterRewardItemEntry preserves one PVF [monster reward item] 7-tuple:
// monster_code dungeon_id difficulty item_id count drop_rate max_stack.
// dungeon_id -1 and difficulty -1 are wildcards (86JP QuestDropProvider).
type MonsterRewardItemEntry struct {
MonsterCode int64
DungeonID int64
Difficulty int64
ItemID int64
Count int64
DropRate int64
MaxStack int64
}
// EnemyRewardItemEntry preserves one PVF [enemy reward item] 8-tuple:
// enemy_code enemy_type dungeon_id difficulty item_id count drop_rate max_stack.
// enemy_type 2 = AI character, 3 = passive object (86JP QuestDropProvider).
type EnemyRewardItemEntry struct {
EnemyCode int64
EnemyType int64
DungeonID int64
Difficulty int64
ItemID int64
Count int64
DropRate int64
MaxStack int64
}
// DungeonScope preserves one PVF [dungeon info] pair. A value of -1 is the
// archive's wildcard for the corresponding field.
type DungeonScope struct {
DungeonID int64
Difficulty int64
}
// SeekingItemTarget is one (item id, required count) pair from a plain
// [seeking] quest's [int data].
type SeekingItemTarget struct {
ItemID int64
Count int64
}
type FinishRewardPlan struct {
QuestID int64
PVFPath string
QuestLevel int
Difficulty rune
IgnoreLevel4Exp bool
HasGoldReward bool
GoldMultiple int
// GoldCost 是这次交付要**扣**的金币(PVF [int data] 里 ID 0 的那一项)。
//
// 与 HasGoldReward/GoldMultiple 是完全不同的两件事:后者是"完成任务发多少金币"
// (由 [reward int data] 的标记 + 等级表算),本字段是"完成任务要交多少金币"。
// 服主现场 2026-10-03:所有消耗金币的任务都不扣钱,就是因为 (0, 金额) 这条 pair
// 被当成非法 itemID 丢掉了。0 表示这个任务不要金币。
GoldCost int64
Items []RewardItemRule
RewardSelectionUsed bool
ProfessionRequest profession.Request
Profession profession.Transition
HasProfession bool
ExpertJobType byte
HasExpertJob bool
HasSlotExpansion bool
SlotExpansionIndex uint32
SlotExpansionBit byte
HasHellPartyUnlock bool
// InfernoChallengeDungeonID is the exact [inferno challenge] reward target.
// Completing it is the durable permission proof for a PVF [inferno dungeon].
InfernoChallengeDungeonID int64
}
// HasHellPartyUnlock reports whether a quest reward has the exact PVF
// [hell challenge] form that permits a region unlock. It does not plan the
// reward, so callers can load the world-map only for this reward class.
func (c *Catalog) HasHellPartyUnlock(questID int64) bool {
definition, ok := c.Find(questID)
return ok && normalizeQuestTag(definition.RewardType) == "hell challenge" &&
len(definition.RewardIntData) == 1 && definition.RewardIntData[0] == 1
}
// Definition is the subset of a PVF quest definition required to decide
// whether the quest belongs in ENUM_NOTIPACKET_ACCEPTABLE_QUEST_LIST.
type Definition struct {
ID int64
Path string
Name string
DependMessage string
ConditionMessage string
SolveMessage string
Grade string
Type string
SubType int
IntData []int64
CheckCount []int64
ConditionData []int64
DependGiveItemData []int64
HasDependGiveItem bool
CantGiveUp bool
LevelMin int
LevelMax int
Job string
TargetCharacter string
TargetCharacterRules []TargetCharacterRule
GrowType int
HasGrowType bool
JobChangeQuest int
ExposedByNPC int64
IsEvent bool
HasCreatureRequirement bool
HasExpertRequirement bool
MainQuestID int64
PreRequiredGroups [][]int64
PreRequiredAnswers []QuestAnswerRequirement
MalformedPreAnswers bool
CollisionQuests []int64
Difficulty string
IgnoreQuestLevel4Exp bool
NoExperience bool
RewardType string
RewardIntData []int64
HasGoldReward bool
GoldMultiple int
RewardItems []RewardItemRule
RewardSelectionItems []RewardItemRule
RewardParseError string
MonsterRewardItems []MonsterRewardItemEntry
EnemyRewardItems []EnemyRewardItemEntry
DungeonScopes []DungeonScope
}
type Catalog struct {
ordered []Definition
byID map[int64]Definition
scopeHints []Definition
externalProfessionPieces []Definition
storyAwakeningByCanonical map[int64][]int64
storyAwakeningCanonicalFor map[int64]int64
}
type Snapshot struct {
Definitions int
Epic int
Normal int
ScopeHints int
}
type CharacterEligibility struct {
Level int
Job int
GrowType int
Wedding bool
}
// WeddingEligible exposes marriage preparation only while the character is engaged.
func WeddingEligible(character dnfrepo.CharacterRecord) bool {
return character.Stats["wedding_state"] == 1 && character.Stats["wedding_id"] > 0 &&
character.Stats["wedding_partner_id"] > 0 && character.Stats["delete_flag"] == 0
}
type EligibilityResult struct {
IDs []int32
EpicCount int
ActiveCount int
}
type AcceptPlan struct {
QuestID int64
Path string
Type string
InitTrigger uint32
EventItems []RewardItemRule
LinkedSubQuests []AcceptLinkedSubQuestPlan
}
// AcceptMutationPlan is the repository-ready state transition for callers
// that must combine quest acceptance with another durable asset mutation.
// Ordinary op31 acceptance continues to use Owner; story-book use consumes
// its source item and applies this plan in one CharacterSettlement.
type AcceptMutationPlan struct {
Quest dnfrepo.QuestRecord
QuestFields []dnfrepo.QuestField
QuestID int64
InitTrigger uint32
}
type AcceptLinkedSubQuestPlan struct {
QuestID int64
Path string
Type string
InitTrigger uint32
}
// MeetingNPCTrigger describes one current-client op33 transition that becomes
// eligible when the selected character reaches a PVF-proven town NPC. Compound
// [seek n meet npc] quests intentionally expose the item probe before the NPC
// channel so an already-satisfied inventory requirement reaches terminal zero
// in the same order as the native client.
type MeetingNPCTrigger struct {
QuestID int64
TriggerType byte
}
func Load(ctx context.Context, index *dnfpvf.Index) (*Catalog, error) {
if ctx == nil {
ctx = context.Background()
}
entries := index.List(DefaultList)
if len(entries) == 0 {
return nil, ErrCatalogEmpty
}
catalog := &Catalog{
ordered: make([]Definition, 0, len(entries)),
byID: make(map[int64]Definition, len(entries)),
storyAwakeningByCanonical: make(map[int64][]int64),
storyAwakeningCanonicalFor: make(map[int64]int64),
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
if entry.ID <= 0 || entry.ID > math.MaxInt32 {
continue
}
if _, exists := catalog.byID[entry.ID]; exists {
continue
}
doc, ok := index.Document(entry.Path)
if !ok {
continue
}
definition := parseDefinition(entry, doc)
catalog.ordered = append(catalog.ordered, definition)
catalog.byID[definition.ID] = definition
}
if len(catalog.ordered) == 0 {
return nil, ErrCatalogEmpty
}
listedPaths := make(map[string]struct{}, len(catalog.ordered))
for _, definition := range catalog.ordered {
listedPaths[normalizeQuestPath(definition.Path)] = struct{}{}
}
for _, docPath := range index.Paths() {
if err := ctx.Err(); err != nil {
return nil, err
}
if !strings.EqualFold(path.Ext(docPath), ".qst") {
continue
}
if _, listed := listedPaths[normalizeQuestPath(docPath)]; listed {
continue
}
doc, ok := index.Document(docPath)
if !ok {
continue
}
hint := parseDefinition(dnfpvf.ListEntry{Path: docPath}, doc)
if isExternalProfessionQuestPiece(docPath, doc, hint) {
catalog.externalProfessionPieces = append(catalog.externalProfessionPieces, hint)
}
if len(doc.Ints("dungeon info")) < 2 {
continue
}
if normalizeQuestTag(hint.Type) == "seeking" && hint.Name != "" && len(hint.DungeonScopes) != 0 &&
len(seekingItemTargets(hint)) != 0 {
catalog.scopeHints = append(catalog.scopeHints, hint)
}
}
catalog.buildStoryAwakeningAliases()
return catalog, nil
}
func isExternalProfessionQuestPiece(docPath string, doc *dnfpvf.Document, definition Definition) bool {
if doc == nil || !strings.HasPrefix(normalizeQuestPath(docPath), "n_quest/time_piece/") || len(doc.Ints("quest piece")) == 0 ||
len(definition.PreRequiredGroups) == 0 {
return false
}
return jobmap.MatchesQuestTag(definition.TargetCharacter, 9) ||
jobmap.MatchesQuestTag(definition.TargetCharacter, 10)
}
// ExternalProfessionQuestPiecePrerequisites returns the legacy completion
// gates used by the current client to reveal Dark Knight and Creator time-piece
// rows. The corresponding .qst documents remain in the current PVF but were
// removed from quest.lst, so they cannot be accepted as ordinary server-owned
// quests. Projecting only their PVF-declared prerequisites preserves the
// client-owned progression UI without inventing quest IDs or completing the
// separate awakening entries.
func (c *Catalog) ExternalProfessionQuestPiecePrerequisites(character CharacterEligibility) []int64 {
if c == nil || (character.Job != 9 && character.Job != 10) || character.Level <= 0 {
return nil
}
seen := make(map[int64]struct{})
for _, definition := range c.externalProfessionPieces {
if character.Level < definition.LevelMin || character.Level > definition.LevelMax ||
!definitionMatchesCharacter(definition, character) {
continue
}
for _, group := range definition.PreRequiredGroups {
for _, questID := range group {
if questID > 0 && questID <= math.MaxInt32 {
seen[questID] = struct{}{}
}
}
}
}
ids := make([]int64, 0, len(seen))
for questID := range seen {
ids = append(ids, questID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (c *Catalog) Snapshot() Snapshot {
if c == nil {
return Snapshot{}
}
snapshot := Snapshot{Definitions: len(c.ordered), ScopeHints: len(c.scopeHints)}
for _, definition := range c.ordered {
switch normalizeQuestTag(definition.Grade) {
case "epic":
snapshot.Epic++
case "", "normal":
snapshot.Normal++
}
}
return snapshot
}
// Definitions returns the PVF quest definitions in quest.lst order. The
// returned values are deep clones, so offline analyzers cannot mutate the
// catalog used by live quest handlers.
func (c *Catalog) Definitions() []Definition {
if c == nil || len(c.ordered) == 0 {
return nil
}
out := make([]Definition, len(c.ordered))
for index, definition := range c.ordered {
out[index] = cloneDefinition(definition)
}
return out
}
// Search returns a bounded, PVF-order-stable view for the local GM browser.
func (c *Catalog) Search(query string, limit int) []Definition {
if c == nil {
return nil
}
if limit <= 0 {
limit = 30
}
if limit > 200 {
limit = 200
}
query = strings.ToLower(strings.TrimSpace(query))
numericID, numericErr := strconv.ParseInt(query, 10, 64)
out := make([]Definition, 0, limit)
for _, definition := range c.ordered {
if query != "" && !(numericErr == nil && definition.ID == numericID) &&
!strings.Contains(strings.ToLower(definition.Name), query) &&
!strings.Contains(strings.ToLower(definition.Path), query) {
continue
}
out = append(out, cloneDefinition(definition))
if len(out) >= limit {
break
}
}
return out
}
// MatchesCharacter exposes the same PVF job/grow-type predicate used by task
// acceptance. It intentionally does not evaluate level, prerequisites, or
// persisted state.
func (c *Catalog) MatchesCharacter(questID int64, character CharacterEligibility) bool {
if c == nil {
return false
}
definition, ok := c.byID[questID]
return ok && definitionMatchesCharacter(definition, character)
}
// buildStoryAwakeningAliases identifies PVF story nodes where the client
// presents one canonical epic task while a mutually-exclusive profession
// sibling owns the first-awakening reward. The match is intentionally strict:
// ambiguous or partially-defined pairs remain independent definitions.
func (c *Catalog) buildStoryAwakeningAliases() {
if c == nil {
return
}
if c.storyAwakeningByCanonical == nil {
c.storyAwakeningByCanonical = make(map[int64][]int64)
}
if c.storyAwakeningCanonicalFor == nil {
c.storyAwakeningCanonicalFor = make(map[int64]int64)
}
for _, variant := range c.ordered {
if !firstAwakeningStoryVariant(variant) {
continue
}
matches := make([]int64, 0, 1)
for _, collisionID := range variant.CollisionQuests {
canonical, found := c.byID[collisionID]
if found && storyAwakeningDefinitionsMatch(canonical, variant) {
matches = append(matches, canonical.ID)
}
}
if len(matches) != 1 {
continue
}
canonicalID := matches[0]
c.storyAwakeningByCanonical[canonicalID] = append(c.storyAwakeningByCanonical[canonicalID], variant.ID)
c.storyAwakeningCanonicalFor[variant.ID] = canonicalID
}
for canonicalID := range c.storyAwakeningByCanonical {
slices.Sort(c.storyAwakeningByCanonical[canonicalID])
}
}
func firstAwakeningStoryVariant(definition Definition) bool {
return normalizeQuestTag(definition.Grade) == "epic" && definition.JobChangeQuest == 2 &&
definition.HasGrowType && definition.GrowType > 0 && definition.GrowType <= 0x0f &&
normalizeQuestTag(definition.RewardType) == "awakening type" &&
len(definition.RewardIntData) == 1 && definition.RewardIntData[0] == 1 &&
len(definition.RewardItems) == 0 && len(definition.RewardSelectionItems) == 0 && !definition.HasGoldReward
}
func storyAwakeningDefinitionsMatch(canonical, variant Definition) bool {
if normalizeQuestTag(canonical.Grade) != "epic" || canonical.JobChangeQuest != 0 ||
normalizeQuestTag(canonical.RewardType) != "item" || canonical.HasGoldReward ||
!emptyRewardDataValid(canonical.RewardIntData) || len(canonical.RewardItems) != 0 ||
len(canonical.RewardSelectionItems) != 0 || len(canonical.TargetCharacterRules) == 0 ||
!slices.Contains(canonical.CollisionQuests, variant.ID) || !slices.Contains(variant.CollisionQuests, canonical.ID) ||
normalizeQuestTag(canonical.Type) != normalizeQuestTag(variant.Type) || canonical.SubType != variant.SubType ||
canonical.LevelMin != variant.LevelMin || canonical.LevelMax != variant.LevelMax ||
normalizeQuestTag(canonical.Difficulty) != normalizeQuestTag(variant.Difficulty) ||
canonical.MainQuestID != variant.MainQuestID || !slices.Equal(canonical.IntData, variant.IntData) ||
!slices.Equal(canonical.CheckCount, variant.CheckCount) ||
!slices.Equal(canonical.ConditionData, variant.ConditionData) ||
!equalIntGroups(canonical.PreRequiredGroups, variant.PreRequiredGroups) {
return false
}
variantJob := strings.ToLower(strings.TrimSpace(variant.Job))
for _, rule := range canonical.TargetCharacterRules {
if rule.FirstGrowType == variant.GrowType && rule.AwakeningStage == 1 &&
strings.Contains(variantJob, "["+normalizeQuestTag(rule.JobTag)+"]") {
return true
}
}
return false
}
func equalIntGroups(left, right [][]int64) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if !slices.Equal(left[index], right[index]) {
return false
}
}
return true
}
func (c *Catalog) storyAwakeningCompanion(canonical Definition, character CharacterEligibility) (Definition, bool) {
if c == nil || (character.GrowType>>4)&0x0f != 0 {
return Definition{}, false
}
firstGrowType := character.GrowType & 0x0f
if firstGrowType == 0 {
return Definition{}, false
}
for _, variantID := range c.storyAwakeningByCanonical[canonical.ID] {
variant, found := c.byID[variantID]
if found && variant.GrowType == firstGrowType && definitionMatchesCharacter(variant, character) {
return cloneDefinition(variant), true
}
}
return Definition{}, false
}
// FinishRewardDefinition resolves a parent task's final reward-bearing child
// without changing the client-facing quest ID. It is used by the settlement
// owner to select child materials/rewards and to archive the child atomically.
func (c *Catalog) FinishRewardDefinition(record dnfrepo.QuestRecord, character CharacterEligibility, questID int64) (Definition, []int64, bool) {
parent, found := c.Find(questID)
if !found {
return Definition{}, nil, false
}
children, ok := c.parentBoundChildren(parent)
if !ok || !zeroItemRewardPlaceholder(parent) {
return parent, nil, false
}
parentState, parentActive := questStateFor(record, questID)
if !parentActive || !isActiveQuestStatus(parentState.Status) || parentState.ProgressValue != 0 {
return parent, nil, false
}
completed, _ := questStateSets(record)
for index := 0; index < len(children)-1; index++ {
child := children[index]
if _, done := completed[child.ID]; done {
continue
}
state, known := questStateFor(record, child.ID)
if !known || !isActiveQuestStatus(state.Status) || state.ProgressValue != 0 || !linkedObjectiveQuest(child) {
return parent, nil, false
}
}
final := children[len(children)-1]
if !rewardBearingParentChild(final) {
return parent, nil, false
}
finalState, finalActive := questStateFor(record, final.ID)
if !finalActive || !isActiveQuestStatus(finalState.Status) || finalState.ProgressValue != 0 ||
!definitionMatchesCharacter(final, character) {
return parent, nil, false
}
additional := c.completedLinkedChildrenForParentFinish(parent, record)
additional = append(additional, final.ID)
slices.Sort(additional)
additional = slices.Compact(additional)
return final, additional, true
}
// ExpertJobTransitionQuestIDs returns every quest explicitly assigned to the
// PVF expert-job transition chain, preserving quest-list order.
func (c *Catalog) ExpertJobTransitionQuestIDs() []int64 {
if c == nil {
return nil
}
ids := make([]int64, 0)
seen := make(map[int64]struct{})
for _, definition := range c.ordered {
if definition.ID <= 0 || definition.JobChangeQuest != 20 {
continue
}
if _, exists := seen[definition.ID]; exists {
continue
}
seen[definition.ID] = struct{}{}
ids = append(ids, definition.ID)
}
return ids
}
// ExpertJobTransitionTerminalQuestIDs returns transition quests that are not
// prerequisites of another transition quest in the same PVF chain.
func (c *Catalog) ExpertJobTransitionTerminalQuestIDs() []int64 {
ids := c.ExpertJobTransitionQuestIDs()
if len(ids) == 0 {
return nil
}
members := make(map[int64]struct{}, len(ids))
for _, questID := range ids {
members[questID] = struct{}{}
}
nonTerminal := make(map[int64]struct{})
for _, questID := range ids {
for _, successor := range c.Successors(questID) {
if _, ok := members[successor.ID]; ok {
nonTerminal[questID] = struct{}{}
break
}
}
}
terminal := make([]int64, 0, len(ids))
for _, questID := range ids {
if _, exists := nonTerminal[questID]; !exists {
terminal = append(terminal, questID)
}
}
return terminal
}
func (c *Catalog) Find(id int64) (Definition, bool) {
if c == nil {
return Definition{}, false
}
definition, ok := c.byID[id]
return cloneDefinition(definition), ok
}
// SeekingItemTargets returns the item/count pairs carried by a plain PVF
// [seeking] quest. Other quest types can use [int data] for unrelated tuple
// layouts, so they deliberately do not enter this fallback path.
func (c *Catalog) SeekingItemTargets(questID int64) []SeekingItemTarget {
if c == nil {
return nil
}
definition, ok := c.byID[questID]
if !ok || normalizeQuestTag(definition.Type) != "seeking" {
return nil
}
return seekingItemTargets(definition)
}
// SeekingDungeonScopes returns the explicit PVF [dungeon info] for a seeking
// quest. Event copies in the current archive sometimes omit that section while
// retaining the same name and target item as a regular quest. In that exact
// case, scopes are inherited from the matching sibling definition. Counts may
// differ between the event and regular copies and therefore are not part of
// the identity.
func (c *Catalog) SeekingDungeonScopes(questID int64) []DungeonScope {
if c == nil {
return nil
}
definition, ok := c.byID[questID]
if !ok || normalizeQuestTag(definition.Type) != "seeking" {
return nil
}
if len(definition.DungeonScopes) != 0 {
return cloneDungeonScopes(definition.DungeonScopes)
}
name := normalizeQuestName(definition.Name)
targetIDs := seekingItemTargetIDs(definition)
if name == "" || len(targetIDs) == 0 {
return nil
}
var scopes []DungeonScope
seen := make(map[DungeonScope]struct{})
siblings := make([]Definition, 0, len(c.ordered)+len(c.scopeHints))
siblings = append(siblings, c.ordered...)
siblings = append(siblings, c.scopeHints...)
for _, sibling := range siblings {
if sibling.ID == definition.ID || normalizeQuestTag(sibling.Type) != "seeking" ||
normalizeQuestName(sibling.Name) != name ||
!slices.Equal(seekingItemTargetIDs(sibling), targetIDs) {
continue
}
for _, scope := range sibling.DungeonScopes {
if scope.DungeonID <= 0 || scope.Difficulty < -1 {
continue
}
if _, duplicate := seen[scope]; duplicate {
continue
}
seen[scope] = struct{}{}
scopes = append(scopes, scope)
}
}
return scopes
}
func seekingItemTargets(definition Definition) []SeekingItemTarget {
if len(definition.IntData) < 2 {
return nil
}
targets := make([]SeekingItemTarget, 0, len(definition.IntData)/2)
for index := 0; index+1 < len(definition.IntData); index += 2 {
itemID, count := definition.IntData[index], definition.IntData[index+1]
if itemID <= 0 || count <= 0 {
continue
}
targets = append(targets, SeekingItemTarget{ItemID: itemID, Count: count})
}
return targets
}
func seekingItemTargetIDs(definition Definition) []int64 {
targets := seekingItemTargets(definition)
ids := make([]int64, len(targets))
for index, target := range targets {
ids[index] = target.ItemID
}
return ids
}
func normalizeQuestName(value string) string {
return strings.ToLower(strings.Join(strings.Fields(value), " "))
}
func normalizeQuestPath(value string) string {
return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")))
}
func cloneDungeonScopes(scopes []DungeonScope) []DungeonScope {
return append([]DungeonScope(nil), scopes...)
}
// IsCompleted reports whether the repository's durable quest state contains a
// terminal completion for questID. Entry systems use this rather than a
// synthetic unlock table when the active PVF itself names the prerequisite.
func IsCompleted(record dnfrepo.QuestRecord, questID int64) bool {
if questID <= 0 {
return false
}
if state, ok := record.States[questID]; ok && isCompletedQuestStatus(state.Status) {
return true
}
if state, ok := record.Progress[questID]; ok && isCompletedQuestStatus(state.Status) {
return true
}
return false
}
// ActiveMeetingNPCTriggers returns deterministic trigger transitions for
// active quests whose PVF objective names npcID. The repository record remains
// authoritative for active/completed ownership; the catalog supplies only the
// objective type and target NPC.
func (c *Catalog) ActiveMeetingNPCTriggers(record dnfrepo.QuestRecord, npcID int64) []MeetingNPCTrigger {
if c == nil || npcID <= 0 {
return nil
}
active := make(map[int64]dnfrepo.QuestState)
collect := func(states map[int64]dnfrepo.QuestState) {
for questID, state := range states {
if questID > 0 && isActiveQuestStatus(state.Status) {
active[questID] = state
}
}
}
collect(record.Progress)
collect(record.States)
if len(active) == 0 {
return nil
}
questIDs := make([]int64, 0, len(active))
for questID := range active {
questIDs = append(questIDs, questID)
}
sort.Slice(questIDs, func(i, j int) bool { return questIDs[i] < questIDs[j] })
triggers := make([]MeetingNPCTrigger, 0, len(questIDs))
for _, questID := range questIDs {
definition, ok := c.byID[questID]
if !ok {
continue
}
state := active[questID]
switch normalizeQuestTag(definition.Type) {
case "meet npc", "meet secret npc":
if len(definition.IntData) >= 1 && definition.IntData[0] == npcID && state.ProgressValue > 0 {
triggers = append(triggers, MeetingNPCTrigger{QuestID: questID})
}
case "seek n meet npc":
if len(definition.IntData) >= 3 && definition.IntData[2] == npcID {
progress := uint32(state.ProgressValue)
if state.ProgressValue >= 0 && state.ProgressValue <= int64(^uint32(0)) && progress&0x1ff != 0 {
triggers = append(triggers, MeetingNPCTrigger{QuestID: questID, TriggerType: 0x10})
}
if state.ProgressValue >= 0 && state.ProgressValue <= int64(^uint32(0)) && (progress>>9)&0x1ff != 0 {
triggers = append(triggers, MeetingNPCTrigger{QuestID: questID, TriggerType: 0x20})
}
}
}
}
return triggers
}
// Successors returns the definitions that list questID in any prerequisite
// group, preserving catalog order. It is the static PVF chain step used to
// resolve the next quest after a completed one; it does not inspect character
// quest state.
func (c *Catalog) Successors(questID int64) []Definition {
if c == nil {
return nil
}
var successors []Definition
for _, definition := range c.ordered {
for _, group := range definition.PreRequiredGroups {
for _, prerequisite := range group {
if prerequisite == questID {
successors = append(successors, cloneDefinition(definition))
goto nextDefinition
}
}
}
for _, requirement := range definition.PreRequiredAnswers {
if requirement.QuestID == questID {
successors = append(successors, cloneDefinition(definition))
goto nextDefinition
}
}
nextDefinition:
}
return successors
}
// EligibleSuccessors filters the static PVF successor relation through the
// same character and persisted quest-state rules used by the op21 task list.
// It intentionally includes an already active successor because settlement
// continuation may run after the client has accepted the next task.
func (c *Catalog) EligibleSuccessors(questID int64, character CharacterEligibility, record dnfrepo.QuestRecord) []Definition {
if c == nil {
return nil
}
completed, active := questStateSets(record)
answers := questAnswerSelections(record)
var successors []Definition
for _, definition := range c.Successors(questID) {
if c.definitionAcceptable(definition, character, completed, active, answers, true) {
successors = append(successors, definition)
}
}
return successors
}
func (c *Catalog) Acceptable(character CharacterEligibility, record dnfrepo.QuestRecord) EligibilityResult {
return c.eligibility(character, record, false)
}
// QuestList returns the PVF-backed IDs carried by the current EXE's
// ENUM_NOTIPACKET_ACCEPTABLE_QUEST_LIST notification. The task manual's All
// view combines this definition list with the active-progress snapshot, so an
// already active quest must remain present here even though PlanAccept must
// continue to reject accepting it a second time.
func (c *Catalog) QuestList(character CharacterEligibility, record dnfrepo.QuestRecord) EligibilityResult {
return c.eligibility(character, record, true)
}
// TitleAchievementDefinitions returns the automatic title-book achievements
// that the current character can see. Title quests are not ordinary
// acceptable quests: the client advances them through op417 without op31, so
// keep them out of QuestList/PlanAccept while still applying their PVF level,
// job, grow-type, and target-character gates.
func (c *Catalog) TitleAchievementDefinitions(character CharacterEligibility) []Definition {
if c == nil || !jobmap.Valid(character.Job) {
return nil
}
if character.Level <= 0 {
character.Level = 1
}
definitions := make([]Definition, 0, 64)
for _, definition := range c.ordered {
if normalizeQuestTag(definition.Grade) != "title" ||
character.Level < definition.LevelMin || character.Level > definition.LevelMax ||
!definitionMatchesCharacter(definition, character) {
continue
}
definitions = append(definitions, definition)
}
return definitions
}
func (c *Catalog) eligibility(character CharacterEligibility, record dnfrepo.QuestRecord, includeActive bool) EligibilityResult {
if c == nil || !jobmap.Valid(character.Job) {
return EligibilityResult{}
}
if character.Level <= 0 {
character.Level = 1
}
completed, active := questStateSets(record)
answers := questAnswerSelections(record)
result := EligibilityResult{IDs: make([]int32, 0, 64)}
for _, definition := range c.ordered {
if !c.definitionAcceptable(definition, character, completed, active, answers, includeActive) {
continue
}
result.IDs = append(result.IDs, int32(definition.ID))
if _, exists := active[definition.ID]; exists {
result.ActiveCount++
}
if normalizeQuestTag(definition.Grade) == "epic" {
result.EpicCount++
}
}
return result
}
// PlanAccept reuses the same PVF/character/database eligibility decision used
// for the acceptable-quest snapshot and derives only initial-trigger rules
// that are represented in the typed catalog. Acceptance-time event items stay
// in the plan so the owner can commit them with the quest state in one
// character-settlement transaction.
func (c *Catalog) PlanAccept(character CharacterEligibility, record dnfrepo.QuestRecord, questID int64) (AcceptPlan, error) {
definition, ok := c.Find(questID)
if !ok {
return AcceptPlan{}, ErrQuestDefinitionMissing
}
if !jobmap.Valid(character.Job) {
return AcceptPlan{}, ErrQuestNotAcceptable
}
if character.Level <= 0 {
character.Level = 1
}
completed, active := questStateSets(record)
answers := questAnswerSelections(record)
if !c.definitionAcceptable(definition, character, completed, active, answers, false) {
return AcceptPlan{}, ErrQuestNotAcceptable
}
eventItems, err := acceptEventItemRules(definition)
if err != nil {
return AcceptPlan{}, err
}
// Keep the accepted task's trigger in the same domain state as the C#
// QuestData.ComputeInitTrigger implementation. This is deliberately a
// PVF/state calculation only; it does not infer any packet fields.
trigger := definitionInitialTrigger(definition, completed)
return AcceptPlan{
QuestID: definition.ID,
Path: definition.Path,
Type: definition.Type,
InitTrigger: trigger,
EventItems: eventItems,
LinkedSubQuests: c.linkedSubQuestAcceptPlans(definition, character, completed, active, answers),
}, nil
}
// PlanAcceptMutation applies the same PVF eligibility and initial-trigger
// rules as native op31 without writing a repository. Event-item quests remain
// owned by the normal quest accept settlement because this narrow path has no
// reward allocator.
func (c *Catalog) PlanAcceptMutation(
character CharacterEligibility,
record dnfrepo.QuestRecord,
questID int64,
acceptedAt time.Time,
) (AcceptMutationPlan, error) {
definition, ok := c.Find(questID)
if !ok {
return AcceptMutationPlan{}, ErrQuestDefinitionMissing
}
plan, err := c.PlanAccept(character, record, questID)
if err != nil {
return AcceptMutationPlan{}, err
}
if len(plan.EventItems) != 0 {
return AcceptMutationPlan{}, fmt.Errorf("%w: quest=%d story-book accept cannot allocate depend items", ErrQuestRewardUnsupported, questID)
}
if acceptedAt.IsZero() {
acceptedAt = time.Now().UTC()
}
updated := dnfrepo.CloneQuest(record)
applyAcceptPlan(&updated, plan, definition, record.CharacterID, acceptedAt)
return AcceptMutationPlan{
Quest: updated,
QuestFields: []dnfrepo.QuestField{dnfrepo.QuestFieldStates},
QuestID: plan.QuestID,
InitTrigger: plan.InitTrigger,
}, nil
}
func acceptEventItemRules(definition Definition) ([]RewardItemRule, error) {
if !definition.HasDependGiveItem {
return nil, nil
}
values := definition.DependGiveItemData
if len(values) == 0 || len(values)%2 != 0 {
return nil, fmt.Errorf("%w: quest=%d depend give item data=%v", ErrQuestRewardMalformed, definition.ID, values)
}
rules := make([]RewardItemRule, 0, len(values)/2)
for index := 0; index < len(values); index += 2 {
itemID, count := values[index], values[index+1]
if itemID <= 0 || itemID > math.MaxUint32 || count <= 0 || count > math.MaxUint32 {
return nil, fmt.Errorf("%w: quest=%d depend give item pair=(%d,%d)", ErrQuestRewardMalformed, definition.ID, itemID, count)
}
rules = append(rules, RewardItemRule{ItemID: itemID, Count: count})
}
return rules, nil
}
// PlanFinishReward parses the detached PVF reward for the current character.
// Profession rewards remain requests until the settlement owner resolves them
// through the job profile and current grow_type inside the repository UoW. It
// does not allocate slots, mutate repositories, calculate EXP, or emit an ACK.
func (c *Catalog) PlanFinishReward(character CharacterEligibility, questID int64, rewardSelectIndex uint16, hasRewardSelect bool) (FinishRewardPlan, error) {
definition, ok := c.Find(questID)
if !ok {
return FinishRewardPlan{}, ErrQuestDefinitionMissing
}
if !jobmap.Valid(character.Job) {
return FinishRewardPlan{}, ErrQuestNotAcceptable
}
if normalizeQuestTag(definition.Grade) == "wedding" && !character.Wedding {
return FinishRewardPlan{}, ErrQuestNotAcceptable
}
// The client submits the canonical story task ID. When PVF declares a
// unique profession collision sibling, use that sibling only as the reward
// source; the settlement and ACK continue to use questID.
if companion, active := c.storyAwakeningCompanion(definition, character); active {
definition = companion
}
plan := FinishRewardPlan{
QuestID: questID,
PVFPath: definition.Path,
QuestLevel: definition.LevelMin,
Difficulty: 'G',
IgnoreLevel4Exp: definition.IgnoreQuestLevel4Exp,
HasGoldReward: definition.HasGoldReward,
GoldMultiple: definition.GoldMultiple,
// 交付金币([int data] 里的 ID 0):与金币**奖励**互不相干,两者可以同时存在。
GoldCost: finishDefinitionGoldCost(definition),
}
if difficulty := []rune(strings.TrimSpace(definition.Difficulty)); len(difficulty) == 1 {
plan.Difficulty = difficulty[0]
}
rewardType := normalizeQuestTag(definition.RewardType)
if rewardType == "" {
if !emptyRewardDataValid(definition.RewardIntData) {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d empty reward type has data=%v", ErrQuestRewardMalformed, questID, definition.RewardIntData)
}
if hasRewardSelect {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d has no selectable reward", ErrQuestRewardSelectionInvalid, questID)
}
return plan, nil
}
if rewardType == "grow type" || rewardType == "awakening type" {
request, err := profession.ParseReward(definition.JobChangeQuest, definition.RewardType, definition.RewardIntData)
if err != nil {
if errors.Is(err, profession.ErrRewardMalformed) {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d: %v", ErrQuestRewardMalformed, questID, err)
}
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d: %v", ErrQuestRewardUnsupported, questID, err)
}
plan.ProfessionRequest = request
plan.HasProfession = true
if hasRewardSelect {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d profession reward has no selection", ErrQuestRewardSelectionInvalid, questID)
}
return plan, nil
}
if rewardType == "expert job" {
// The current PVF encodes the chosen auxiliary-profession type as one
// positive byte (for example, quest 2710 supplies 3 for disjointer).
// Current EXE op34 consumes that value under chain type 20.
if len(definition.RewardIntData) != 1 || definition.RewardIntData[0] <= 0 || definition.RewardIntData[0] > math.MaxUint8 {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d expert job values=%v", ErrQuestRewardMalformed, questID, definition.RewardIntData)
}
if hasRewardSelect {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d expert job reward has no selection", ErrQuestRewardSelectionInvalid, questID)
}
plan.ExpertJobType = byte(definition.RewardIntData[0])
plan.HasExpertJob = true
return plan, nil
}
if rewardType == "slot expansion" {
if hasRewardSelect {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d slot expansion reward has no selection", ErrQuestRewardSelectionInvalid, questID)
}
// The current runtime PVF stores the slot-expansion index in
// [reward int data]: 0=support, 1=magic stone, 2=earring. The durable
// character column and current global actor snapshots store the
// corresponding bit.
if len(definition.RewardIntData) != 1 || definition.RewardIntData[0] < 0 || definition.RewardIntData[0] > 2 {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d slot expansion values=%v", ErrQuestRewardMalformed, questID, definition.RewardIntData)
}
plan.HasSlotExpansion = true
plan.SlotExpansionIndex = uint32(definition.RewardIntData[0])
plan.SlotExpansionBit, _ = ExEquipSlotBitForPVFIndex(plan.SlotExpansionIndex)
return plan, nil
}
if rewardType == "hell challenge" {
if hasRewardSelect || len(definition.RewardIntData) != 1 || definition.RewardIntData[0] != 1 {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d hell challenge values=%v", ErrQuestRewardMalformed, questID, definition.RewardIntData)
}
plan.HasHellPartyUnlock = true
return plan, nil
}
if rewardType == "inferno challenge" {
if hasRewardSelect || len(definition.RewardIntData) != 1 || definition.RewardIntData[0] <= 0 {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d inferno challenge values=%v", ErrQuestRewardMalformed, questID, definition.RewardIntData)
}
plan.InfernoChallengeDungeonID = definition.RewardIntData[0]
return plan, nil
}
if rewardType != "item" {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d type=%q", ErrQuestRewardUnsupported, questID, definition.RewardType)
}
if definition.RewardParseError != "" {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d: %s", ErrQuestRewardMalformed, questID, definition.RewardParseError)
}
for _, rule := range definition.RewardItems {
if rewardRuleMatches(rule, character) {
plan.Items = append(plan.Items, rule)
}
}
selectable := make([]RewardItemRule, 0, len(definition.RewardSelectionItems))
for _, rule := range definition.RewardSelectionItems {
if rewardRuleMatches(rule, character) {
selectable = append(selectable, rule)
}
}
if len(selectable) == 0 {
if hasRewardSelect {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d has no selectable reward", ErrQuestRewardSelectionInvalid, questID)
}
return plan, nil
}
if !hasRewardSelect || int(rewardSelectIndex) >= len(selectable) {
return FinishRewardPlan{}, fmt.Errorf("%w: quest=%d selected=%d choices=%d", ErrQuestRewardSelectionInvalid, questID, rewardSelectIndex, len(selectable))
}
plan.Items = append(plan.Items, selectable[int(rewardSelectIndex)])
plan.RewardSelectionUsed = true
return plan, nil
}
// FinishRewardNeedsProfessionResources reports whether the reward that the
// settlement owner will actually parse needs the PVF profession tables. The
// client submits the canonical quest ID, but a story awakening companion (or
// a reward-bearing linked child) can be the final reward source. Keep this
// decision in the catalog so the bridge does not duplicate hidden reward
// routing rules.
func (c *Catalog) FinishRewardNeedsProfessionResources(
record dnfrepo.QuestRecord,
character CharacterEligibility,
questID int64,
rewardSelectIndex uint16,
hasRewardSelect bool,
) bool {
if c == nil {
return false
}
rewardQuestID := questID
if definition, _, child := c.FinishRewardDefinition(record, character, questID); child {
rewardQuestID = definition.ID
}
plan, err := c.PlanFinishReward(character, rewardQuestID, rewardSelectIndex, hasRewardSelect)
return err == nil && plan.HasProfession
}
func emptyRewardDataValid(values []int64) bool {
for _, value := range values {
if value != 0 {
return false
}
}
return true
}
func parseDefinition(entry dnfpvf.ListEntry, doc *dnfpvf.Document) Definition {
definition := Definition{
ID: entry.ID,
Path: entry.Path,
LevelMin: 1,
LevelMax: 99,
GrowType: -1,
ExposedByNPC: -1,
}
definition.Name, _ = doc.Text("name")
definition.DependMessage = questPresentationText(doc, "depend message")
definition.ConditionMessage = questPresentationText(doc, "condition message")
definition.SolveMessage = questPresentationText(doc, "solve message")
definition.Grade, _ = doc.Text("grade")
definition.Type, _ = doc.Text("type")
definition.Difficulty, _ = doc.Text("difficulty")
definition.RewardType, _ = doc.Text("reward type")
definition.RewardIntData = append([]int64(nil), doc.Ints("reward int data")...)
if value, ok := doc.Int("gold multiple"); ok {
definition.GoldMultiple = boundedInt(value)
}
if tokens, ok := doc.Section("ignore quest level 4 exp"); ok {
definition.IgnoreQuestLevel4Exp = len(tokens) == 0
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
definition.IgnoreQuestLevel4Exp = token.Int != 0
break
}
}
}
for _, attribute := range doc.Texts("attribute") {
if normalizeQuestTag(attribute) == "not give exp quest" {
definition.NoExperience = true
break
}
}
if normalizeQuestTag(definition.RewardType) == "item" {
definition.RewardItems, definition.HasGoldReward, definition.RewardParseError = parseRewardItemRules(doc, "reward int data")
definition.RewardSelectionItems, definition.RewardParseError = parseRewardItemRulesAfter(definition.RewardParseError, doc, "reward selection int data")
}
if value, ok := doc.Int("sub type"); ok {
definition.SubType = boundedInt(value)
}
definition.IntData = append([]int64(nil), doc.Ints("int data")...)
definition.CheckCount = append([]int64(nil), doc.Ints("check count")...)
definition.ConditionData = append([]int64(nil), doc.Ints("condition data")...)
if tokens, ok := doc.Section("depend give item"); ok {
// 真实 PVF 里有些任务带一个**空的** [depend give item] 段(例如婚约任务的
// 第二步 20738):段在、但没有要发放的道具,语义上等于"无依赖"。
// 早先这里无条件置 true,于是这类任务在接取时被 applyAcceptEventItemSettlement
// 判成 "depend give item data=[]" 而直接拒绝 —— 婚约任务链因此断在第二步。
// 与上游一致:空段不算依赖。
definition.HasDependGiveItem = len(tokens) != 0
definition.DependGiveItemData = append([]int64(nil), doc.Ints("depend give item")...)
}
_, definition.CantGiveUp = doc.Section("cant giveup")
definition.Job = strings.Join(doc.Texts("job"), " ")
definition.TargetCharacter = strings.Join(doc.Texts("target character"), " ")
if tokens, ok := doc.Section("target character"); ok {
definition.TargetCharacterRules = parseTargetCharacterRules(tokens)
}
if levels := doc.Ints("level"); len(levels) > 0 {
definition.LevelMin = boundedInt(levels[0])
if len(levels) > 1 {
definition.LevelMax = boundedInt(levels[1])
}
}
if value, ok := doc.Int("grow type"); ok {
definition.GrowType = boundedInt(value)
definition.HasGrowType = true
}
if value, ok := doc.Int("job change quest"); ok {
definition.JobChangeQuest = boundedInt(value)
}
if value, ok := doc.Int("exposed by npc"); ok {
definition.ExposedByNPC = value
}
if value, ok := doc.Int("main quest"); ok {
definition.MainQuestID = value
}
if value, ok := doc.Int("event"); ok && value != 0 {
definition.IsEvent = true
}
if value, ok := doc.Int("creature kind"); ok && value >= 0 {
definition.HasCreatureRequirement = true
}
if values := doc.Ints("expertjob level"); len(values) >= 2 && values[0] >= 0 && values[1] >= 0 {
definition.HasExpertRequirement = true
}
definition.PreRequiredGroups = sectionIntGroups(doc, "pre required quest")
definition.PreRequiredAnswers, definition.MalformedPreAnswers = parseQuestAnswerRequirements(sectionIntGroups(doc, "pre required quest answer"))
definition.CollisionQuests = flattenIntGroups(sectionIntGroups(doc, "collision quest"))
definition.MonsterRewardItems = parseMonsterRewardItems(doc)
definition.EnemyRewardItems = parseEnemyRewardItems(doc)
definition.DungeonScopes = parseDungeonScopes(doc)
return definition
}
func questPresentationText(doc *dnfpvf.Document, section string) string {
if doc == nil {
return ""
}
parts := make([]string, 0, 1)
for _, value := range doc.Texts(section) {
if value = strings.TrimSpace(value); value != "" {
parts = append(parts, strings.ReplaceAll(value, "%%", "%"))
}
}
return strings.Join(parts, "\n")
}
func parseDungeonScopes(doc *dnfpvf.Document) []DungeonScope {
values := doc.Ints("dungeon info")
if len(values) < 2 {
return nil
}
scopes := make([]DungeonScope, 0, len(values)/2)
for index := 0; index+1 < len(values); index += 2 {
if values[index] <= 0 || values[index+1] < -1 {
continue
}
scopes = append(scopes, DungeonScope{DungeonID: values[index], Difficulty: values[index+1]})
}
return scopes
}
func parseRewardItemRulesAfter(previous string, doc *dnfpvf.Document, section string) ([]RewardItemRule, string) {
rules, _, parseError := parseRewardItemRules(doc, section)
if previous != "" {
return rules, previous
}
return rules, parseError
}
func parseRewardItemRules(doc *dnfpvf.Document, section string) ([]RewardItemRule, bool, string) {
tokens, ok := doc.Section(section)
if !ok || len(tokens) == 0 {
return nil, false, ""
}
rules := make([]RewardItemRule, 0, len(tokens)/2)
hasGoldReward := false
for index := 0; index < len(tokens); {
if tokens[index].Kind != dnfpvf.TokenInt {
return nil, false, fmt.Sprintf("[%s] token %d must be item id, got kind=%s raw=%q", section, index, tokens[index].Kind, tokens[index].Raw)
}
rule := RewardItemRule{ItemID: tokens[index].Int, Job: -1, GrowType: -1}
index++
if index < len(tokens) && rewardJobMarker(tokens[index]) {
if index+3 >= len(tokens) || tokens[index+1].Kind != dnfpvf.TokenInt || tokens[index+2].Kind != dnfpvf.TokenInt || tokens[index+3].Kind != dnfpvf.TokenInt {
return nil, false, fmt.Sprintf("[%s] item %d has incomplete [job] tuple", section, rule.ItemID)
}
rule.HasJobFilter = true
rule.Job = boundedInt(tokens[index+1].Int)
rule.GrowType = boundedInt(tokens[index+2].Int)
rule.Count = tokens[index+3].Int
index += 4
} else {
if index >= len(tokens) || tokens[index].Kind != dnfpvf.TokenInt {
return nil, false, fmt.Sprintf("[%s] item %d is missing count", section, rule.ItemID)
}
rule.Count = tokens[index].Int
index++
}
if rule.ItemID == 0 {
hasGoldReward = hasGoldReward || rule.Count > 0
continue
}
if rule.ItemID < 0 || rule.Count <= 0 || rule.Count > math.MaxUint32 {
return nil, false, fmt.Sprintf("[%s] invalid item/count %d/%d", section, rule.ItemID, rule.Count)
}
rules = append(rules, rule)
}
return rules, hasGoldReward, ""
}
func rewardJobMarker(token dnfpvf.Token) bool {
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
return false
}
return normalizeQuestTag(token.Value) == "job"
}
// parseMonsterRewardItems parses the PVF [monster reward item] section.
// Each entry is a 7-tuple: monster_code dungeon_id difficulty item_id count
// drop_rate max_stack. dungeon_id -1 and difficulty -1 are wildcards.
func parseMonsterRewardItems(doc *dnfpvf.Document) []MonsterRewardItemEntry {
tokens, ok := doc.Section("monster reward item")
if !ok || len(tokens) == 0 {
return nil
}
ints := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
ints = append(ints, token.Int)
}
}
entries := make([]MonsterRewardItemEntry, 0, len(ints)/7)
for i := 0; i+6 < len(ints); i += 7 {
entries = append(entries, MonsterRewardItemEntry{
MonsterCode: ints[i],
DungeonID: ints[i+1],
Difficulty: ints[i+2],
ItemID: ints[i+3],
Count: ints[i+4],
DropRate: ints[i+5],
MaxStack: ints[i+6],
})
}
return entries
}
// parseEnemyRewardItems parses the PVF [enemy reward item] section.
// Each entry is an 8-tuple: enemy_code enemy_type dungeon_id difficulty
// item_id count drop_rate max_stack. enemy_type 2 = AI character,
// 3 = passive object.
func parseEnemyRewardItems(doc *dnfpvf.Document) []EnemyRewardItemEntry {
tokens, ok := doc.Section("enemy reward item")
if !ok || len(tokens) == 0 {
return nil
}
ints := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
ints = append(ints, token.Int)
}
}
entries := make([]EnemyRewardItemEntry, 0, len(ints)/8)
for i := 0; i+7 < len(ints); i += 8 {
entries = append(entries, EnemyRewardItemEntry{
EnemyCode: ints[i],
EnemyType: ints[i+1],
DungeonID: ints[i+2],
Difficulty: ints[i+3],
ItemID: ints[i+4],
Count: ints[i+5],
DropRate: ints[i+6],
MaxStack: ints[i+7],
})
}
return entries
}
func parseTargetCharacterRules(tokens []dnfpvf.Token) []TargetCharacterRule {
rules := make([]TargetCharacterRule, 0, len(tokens)/3)
for index := 0; index+2 < len(tokens); {
jobToken := tokens[index]
if (jobToken.Kind != dnfpvf.TokenString && jobToken.Kind != dnfpvf.TokenIdent) ||
tokens[index+1].Kind != dnfpvf.TokenInt || tokens[index+2].Kind != dnfpvf.TokenInt {
index++
continue
}
rules = append(rules, TargetCharacterRule{
JobTag: jobToken.Value, FirstGrowType: boundedInt(tokens[index+1].Int), AwakeningStage: boundedInt(tokens[index+2].Int),
})
index += 3
}
return rules
}
func rewardRuleMatches(rule RewardItemRule, character CharacterEligibility) bool {
if rule.ItemID <= 0 || rule.Count <= 0 {
return false
}
if !rule.HasJobFilter {
return true
}
return rule.Job == character.Job && (rule.GrowType < 0 || rule.GrowType == character.GrowType&0x0f)
}
// definitionInitialTrigger ports the C# QuestData.ComputeInitTrigger domain
// rules. The current EXE accepts the resulting u32 through the existing
// typed accept ACK; C# packet layouts are not used here.
//
// C# intentionally uses one as the fallback for a recognised-but-not-yet
// specialised quest type. Returning a fake "unsupported" error after the
// task was advertised in the acceptable-quest list made the second main-line
// task (3146) impossible to accept.
func definitionInitialTrigger(definition Definition, completed map[int64]struct{}) uint32 {
tag := normalizeQuestTag(definition.Type)
switch tag {
case "pvp quest":
// 决斗场胜利任务([pvp quest] + [sub type] 1):int data[0] 既是初始
// 触发值,也是需要累计的胜场数。
if isPVPWinDefinition(definition) {
return uint32(definition.IntData[0])
}
case "seek n meet npc":
return seekAndMeetNPCInitialTrigger(definition.IntData)
case "quest clear", "clear quest":
return questClearInitialTrigger(definition.IntData, completed)
case "condition under clear":
return conditionUnderClearInitialTrigger(definition)
case "clear map":
return 1
case "condition under clear2":
return triggerFromIntData(definition.IntData, 5)
case "normal clear":
return packTriggerChannels(1, 1, 0)
case "raid phase clear":
return raidPhaseClearInitialTrigger(definition.IntData)
case "hunt monster":
return triggerFromIntData(definition.IntData, 4)
case "hunt enemy":
return triggerFromIntData(definition.IntData, 5)
}
if csharpTypeOneQuest(tag) && definition.SubType == 6 && len(definition.IntData) >= 3 && definition.IntData[2] > 0 {
return boundedTriggerChannel(definition.IntData[2])
}
return 1
}
func conditionUnderClearInitialTrigger(definition Definition) uint32 {
values := definition.IntData
switch definition.SubType {
// 22 是婚约任务([condition under clear],要求"正好这对夫妻通关"),与 6 共用
// 同一套 int data 主线;漏掉它会退回 default 的 stride-4 解析,得到错误的
// 初始 trigger(实测 1,应为 5),婚约任务因此无法按原生进度推进。
case 6, 22:
return conditionUnderClearSubtype6InitialTrigger(values)
case 23:
if len(values) > 0 && values[0] == -2 {
return triggerFromIntData(values, 4)
}
return conditionUnderClearSubtype23InitialTrigger(values)
case 11:
return 1
default:
return triggerFromIntData(values, 4)
}
}
func conditionUnderClearSubtype6InitialTrigger(values []int64) uint32 {
if len(values) == 0 || len(values) == 2 {
return 1
}
return triggerFromIntData(values, 3)
}
func conditionUnderClearSubtype23InitialTrigger(values []int64) uint32 {
if len(values) == 0 {
return 1
}
return triggerFromIntData(values, 3)
}
func (c *Catalog) linkedSubQuestAcceptPlans(definition Definition, character CharacterEligibility, completed, active map[int64]struct{}, answers map[int64]int64) []AcceptLinkedSubQuestPlan {
if c == nil {
return nil
}
tag := normalizeQuestTag(definition.Type)
if tag != "quest clear" && tag != "clear quest" {
return nil
}
// The parent is being accepted by this plan. Make that pending activation
// visible to the shared eligibility predicate so its linked children do not
// have to be exposed as standalone op21 tasks before the parent exists.
active[definition.ID] = struct{}{}
seen := make(map[int64]struct{}, len(definition.IntData))
plans := make([]AcceptLinkedSubQuestPlan, 0, len(definition.IntData))
for _, childID := range definition.IntData {
if childID <= 0 {
continue
}
if _, duplicate := seen[childID]; duplicate {
continue
}
seen[childID] = struct{}{}
if _, exists := completed[childID]; exists {
continue
}
if _, exists := active[childID]; exists {
continue
}
child, ok := c.byID[childID]
if !ok || child.MainQuestID != definition.ID || child.HasDependGiveItem || !shouldAutoCompleteNoRewardSubQuest(child) {
continue
}
if !c.definitionAcceptable(child, character, completed, active, answers, false) {
continue
}
active[childID] = struct{}{}
plans = append(plans, AcceptLinkedSubQuestPlan{
QuestID: child.ID,
Path: child.Path,
Type: child.Type,
InitTrigger: definitionInitialTrigger(child, completed),
})
}
return plans
}
func seekAndMeetNPCInitialTrigger(values []int64) uint32 {
if len(values) < 3 {
return 1
}
itemCount := values[1]
if itemCount <= 0 {
itemCount = 1
}
meetNPCCount := int64(0)
if values[2] > 0 {
meetNPCCount = 1
}
return packTriggerChannels(itemCount, meetNPCCount, 0)
}
func questClearInitialTrigger(values []int64, completed map[int64]struct{}) uint32 {
missing := int64(0)
for _, questID := range values {
if questID > 0 {
if _, exists := completed[questID]; !exists {
missing++
}
}
}
if missing == 0 {
return 1
}
return boundedTriggerChannel(missing)
}
// raidPhaseClearInitialTrigger maps each PVF four-value phase rule to the
// matching packed trigger channel. The current Anton mission uses
// phase/count tuples 0/*/5/* and 1/*/5/*, so accepting it must publish 5/5
// rather than the generic fallback trigger 1.
func raidPhaseClearInitialTrigger(values []int64) uint32 {
if len(values) == 0 || len(values)%4 != 0 {
return 1
}
channels := [3]int64{}
found := false
for offset := 0; offset+3 < len(values); offset += 4 {
phase := values[offset]
count := values[offset+2]
if phase < 0 || phase >= int64(len(channels)) || count <= 0 {
continue
}
channels[phase] = count
found = true
}
if !found {
return 1
}
return packTriggerChannels(channels[0], channels[1], channels[2])
}
func csharpTypeOneQuest(tag string) bool {
switch tag {
case "seeking", "hunt monster", "meet npc", "hunt enemy", "use item", "get item", "get score",
"clear quest", "quest clear", "custom quest", "send chatting", "check life", "amplify item",
"disjoint item", "equipped item", "check time", "use fortune coin", "meet secret npc",
"turn gold card", "ui click", "seek n meet npc", "assault count", "mobile":
return true
default:
return false
}
}
func triggerFromIntData(values []int64, stride int) uint32 {
if len(values) == 0 || stride <= 0 {
return 1
}
counts := make([]int64, 0, 3)
for offset := stride - 1; offset < len(values) && len(counts) < 3; offset += stride {
counts = append(counts, values[offset])
}
if len(counts) == 0 {
return 1
}
return packTriggerChannels(counts...)
}
func packTriggerChannels(values ...int64) uint32 {
var packed uint32
for index, value := range values {
if index == 3 {
break
}
packed |= boundedTriggerChannel(value) << (index * 9)
}
return packed
}
func boundedTriggerChannel(value int64) uint32 {
if value <= 0 {
return 0
}
if value > 0x1ff {
return 0x1ff
}
return uint32(value)
}
func (c *Catalog) definitionAcceptable(definition Definition, character CharacterEligibility, completed, active map[int64]struct{}, answers map[int64]int64, includeActive bool) bool {
// A first-awakening story variant is a reward companion, not a second
// client-facing task. Keep the canonical epic definition in op21/op31 and
// project the matching variant only during finish settlement.
if canonicalID, projected := c.storyAwakeningCanonicalFor[definition.ID]; projected {
if canonical, found := c.byID[canonicalID]; found {
if _, activeCompanion := c.storyAwakeningCompanion(canonical, character); activeCompanion {
return false
}
}
}
return definitionAcceptable(definition, character, completed, active, answers, includeActive)
}
func definitionAcceptable(definition Definition, character CharacterEligibility, completed, active map[int64]struct{}, answers map[int64]int64, includeActive bool) bool {
if definition.ExposedByNPC == 0 || definition.IsEvent || definition.HasCreatureRequirement || definition.HasExpertRequirement {
return false
}
grade := normalizeQuestTag(definition.Grade)
if !selectableGrade(grade) || grade == "training" {
return false
}
if grade == "wedding" && !character.Wedding {
return false
}
if character.Level < definition.LevelMin || character.Level > definition.LevelMax {
return false
}
if !definitionMatchesCharacter(definition, character) {
return false
}
if definition.MainQuestID > 0 {
if _, parentActive := active[definition.MainQuestID]; !parentActive {
return false
}
}
if _, exists := active[definition.ID]; exists && !includeActive {
return false
}
// A completed task leaves the acceptable list unless its grade is genuinely
// repeatable. A daily is therefore gone from op21 for the rest of its service
// day and reappears only after ReconcileDailyReset clears the row at 06:00;
// an already-completed one-shot never comes back.
if _, exists := completed[definition.ID]; exists && !isRepeatableGrade(grade) {
return false
}
if len(definition.PreRequiredGroups) > 0 {
matched := false
for _, group := range definition.PreRequiredGroups {
if completedAll(group, completed) {
matched = true
break
}
}
if !matched {
return false
}
}
if definition.MalformedPreAnswers {
return false
}
for _, requirement := range definition.PreRequiredAnswers {
answer, ok := answers[requirement.QuestID]
if !ok || answer != requirement.Answer {
return false
}
}
for _, collision := range definition.CollisionQuests {
if collision <= 0 {
continue
}
if _, exists := completed[collision]; exists {
return false
}
}
return true
}
// definitionMatchesCharacter is the shared PVF job/grow-type predicate for
// ordinary acceptance and server-owned main-quest skip planning. It contains
// no level, prerequisite, collision, event, or quest-state policy.
func definitionMatchesCharacter(definition Definition, character CharacterEligibility) bool {
if job := strings.TrimSpace(definition.Job); job != "" && normalizeQuestTag(job) != "all" && !jobmap.MatchesQuestTag(job, character.Job) {
return false
}
if len(definition.TargetCharacterRules) != 0 && definition.JobChangeQuest >= 1 && definition.JobChangeQuest <= 3 {
matched := false
firstGrowType := character.GrowType & 0x0f
awakeningStage := (character.GrowType >> 4) & 0x0f
for _, rule := range definition.TargetCharacterRules {
if jobmap.MatchesQuestTag(rule.JobTag, character.Job) && rule.FirstGrowType == firstGrowType && rule.AwakeningStage == awakeningStage {
matched = true
break
}
}
if !matched {
return false
}
} else if target := strings.TrimSpace(definition.TargetCharacter); target != "" && !jobmap.MatchesQuestTag(target, character.Job) {
return false
}
if definition.HasGrowType && definition.GrowType >= 0 {
switch definition.JobChangeQuest {
case 1, 10, 20:
case 2, 3:
if definition.GrowType != character.GrowType&0x0f {
return false
}
default:
if definition.GrowType != character.GrowType {
return false
}
}
}
return true
}
func questStateSets(record dnfrepo.QuestRecord) (map[int64]struct{}, map[int64]struct{}) {
completed := make(map[int64]struct{})
active := make(map[int64]struct{})
collect := func(states map[int64]dnfrepo.QuestState) {
for id, state := range states {
if id <= 0 {
continue
}
if isCompletedQuestStatus(state.Status) {
completed[id] = struct{}{}
continue
}
if isActiveQuestStatus(state.Status) {
active[id] = struct{}{}
}
}
}
collect(record.States)
collect(record.Progress)
return completed, active
}
const questAnswerExtraKey = "question_answer"
func questAnswerSelections(record dnfrepo.QuestRecord) map[int64]int64 {
answers := make(map[int64]int64)
collect := func(states map[int64]dnfrepo.QuestState) {
for questID, state := range states {
raw := strings.TrimSpace(state.Extra[questAnswerExtraKey])
if raw != "" {
answer, err := strconv.ParseInt(raw, 10, 64)
if err == nil && answer >= 0 {
answers[questID] = answer
continue
}
}
// Compatibility for question quests completed before answer
// persistence: the old handler could only complete answer zero.
if isCompletedQuestStatus(state.Status) && state.TriggerType == 0 {
answers[questID] = 0
}
}
}
collect(record.Progress)
collect(record.States)
return answers
}
func isCompletedQuestStatus(status string) bool {
switch normalizeStatus(status) {
case "complete", "completed", "cleared", "finished", "done":
return true
default:
return false
}
}
func normalizeStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "_", "")
value = strings.ReplaceAll(value, "-", "")
value = strings.ReplaceAll(value, " ", "")
return value
}
// isDailyGrade reports whether a normalized PVF [grade] makes the quest a daily
// task. The client's task manual treats the whole daily family as one class, so
// the test is the shared "daily" token rather than one exact value: the live PVF
// carries `daily`, `special daily`, `daily mission`, `auto daily mission` and
// the legacy `daily random`. `normaly repeat` deliberately does not match - it is
// a genuinely repeatable task, not a daily.
func isDailyGrade(grade string) bool {
return strings.Contains(grade, "daily")
}
// isRepeatableGrade reports whether a completed quest may be taken again
// immediately, with no service-day boundary. Only `normaly repeat` qualifies: a
// daily task is locked for the rest of its service day (the row is cleared at
// 06:00 by ReconcileDailyReset), and every other grade is one-shot.
func isRepeatableGrade(grade string) bool {
return grade == "normaly repeat"
}
// ticketExcludedGrade reports whether a grade disqualifies a quest from bulk
// completion tickets. A ticket must not consume a daily task or a repeatable
// one, so this is deliberately wider than either predicate above.
func ticketExcludedGrade(grade string) bool {
return isDailyGrade(grade) || isRepeatableGrade(grade)
}
func selectableGrade(grade string) bool {
switch grade {
case "", "normal", "side", "sub", "epic", "realization", "training", "achievement", "common unique", "system", "mission", "wedding":
return true
}
// Every member of the daily family is selectable, and `normaly repeat` is
// selectable because it is repeatable - not because it is a daily.
return isDailyGrade(grade) || isRepeatableGrade(grade)
}
func normalizeQuestTag(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if len(value) >= 2 && value[0] == '[' && value[len(value)-1] == ']' {
value = strings.TrimSpace(value[1 : len(value)-1])
}
return value
}
func sectionIntGroups(doc *dnfpvf.Document, name string) [][]int64 {
if doc == nil {
return nil
}
want := normalizeQuestTag(name)
groups := make([][]int64, 0, 1)
for _, section := range doc.Sections {
if normalizeQuestTag(section.Name) != want || section.Start < 0 || section.End > len(doc.Tokens) || section.Start > section.End {
continue
}
group := make([]int64, 0, section.End-section.Start)
for _, token := range doc.Tokens[section.Start:section.End] {
if token.Kind == dnfpvf.TokenInt {
group = append(group, token.Int)
}
}
if len(group) > 0 {
groups = append(groups, group)
}
}
return groups
}
func flattenIntGroups(groups [][]int64) []int64 {
var values []int64
for _, group := range groups {
values = append(values, group...)
}
return values
}
func parseQuestAnswerRequirements(groups [][]int64) ([]QuestAnswerRequirement, bool) {
var requirements []QuestAnswerRequirement
for _, group := range groups {
if len(group)%2 != 0 {
return nil, true
}
for index := 0; index < len(group); index += 2 {
if group[index] <= 0 || group[index+1] < 0 {
return nil, true
}
requirements = append(requirements, QuestAnswerRequirement{QuestID: group[index], Answer: group[index+1]})
}
}
return requirements, false
}
func completedAll(ids []int64, completed map[int64]struct{}) bool {
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := completed[id]; !exists {
return false
}
}
return true
}
func boundedInt(value int64) int {
if value > int64(math.MaxInt) {
return math.MaxInt
}
if value < int64(math.MinInt) {
return math.MinInt
}
return int(value)
}
func cloneDefinition(definition Definition) Definition {
definition.IntData = append([]int64(nil), definition.IntData...)
definition.CheckCount = append([]int64(nil), definition.CheckCount...)
definition.ConditionData = append([]int64(nil), definition.ConditionData...)
definition.DependGiveItemData = append([]int64(nil), definition.DependGiveItemData...)
definition.PreRequiredGroups = append([][]int64(nil), definition.PreRequiredGroups...)
for index := range definition.PreRequiredGroups {
definition.PreRequiredGroups[index] = append([]int64(nil), definition.PreRequiredGroups[index]...)
}
definition.PreRequiredAnswers = append([]QuestAnswerRequirement(nil), definition.PreRequiredAnswers...)
definition.CollisionQuests = append([]int64(nil), definition.CollisionQuests...)
definition.RewardIntData = append([]int64(nil), definition.RewardIntData...)
definition.RewardItems = append([]RewardItemRule(nil), definition.RewardItems...)
definition.RewardSelectionItems = append([]RewardItemRule(nil), definition.RewardSelectionItems...)
definition.TargetCharacterRules = append([]TargetCharacterRule(nil), definition.TargetCharacterRules...)
definition.MonsterRewardItems = append([]MonsterRewardItemEntry(nil), definition.MonsterRewardItems...)
definition.EnemyRewardItems = append([]EnemyRewardItemEntry(nil), definition.EnemyRewardItems...)
definition.DungeonScopes = cloneDungeonScopes(definition.DungeonScopes)
return definition
}
func SortedIDs(result EligibilityResult) []int32 {
ids := append([]int32(nil), result.IDs...)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// ExEquipSlotStat is the durable current-client bitset selected by the runtime
// PVF [slot expansion] reward index. Current Script.pvf uses indices 0, 1, and
// 2, but the current NoPack UI bits are deliberately not contiguous: its
// support/magic-stone/earring checks use 0x01, 0x02, and 0x10 respectively.
const (
ExEquipSlotSupport byte = 0x01
ExEquipSlotMagicStone byte = 0x02
ExEquipSlotEarring byte = 0x10
ExEquipSlotAll byte = ExEquipSlotSupport | ExEquipSlotMagicStone | ExEquipSlotEarring
)
func ExEquipSlotBitForPVFIndex(index uint32) (byte, bool) {
switch index {
case 0:
return ExEquipSlotSupport, true
case 1:
return ExEquipSlotMagicStone, true
case 2:
return ExEquipSlotEarring, true
default:
return 0, false
}
}