package quest import ( "errors" "fmt" "sort" "strings" "time" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) var ( ErrCompletionTicketActionInvalid = errors.New("quest completion ticket action is invalid") ErrCompletionTicketLevelInvalid = errors.New("quest completion ticket level requirement is not met") ErrCompletionTicketNoApplicableQuest = errors.New("quest completion ticket has no applicable quest") ErrCompletionTicketSelectionRequired = errors.New("quest completion ticket requires an explicit quest selection") ) // CompletionTicketSpec is the PVF-derived policy attached to one stackable. // ActionValues are interpreted only by the corresponding action family. type CompletionTicketSpec struct { ActionType string ActionValues []int64 MinimumLevel int64 MaximumLevel int64 PreserveSideQuests bool PreserveHellPartyUnlocks bool } // CompletionTicketTitleGrant is one title-book reward unlocked by the ticket. // QuestID keys the titlebook.etc mapping when the quest is itself a [title] // achievement; ItemID is the reward item carried by the PVF so old // [achievement] quests that also reward a title can be resolved by item id. type CompletionTicketTitleGrant struct { QuestID int64 ItemID int32 } // CompletionTicketPlan contains only no-reward quest completion changes and, // for awakening coupons, the canonical grow_type stage transition. type CompletionTicketPlan struct { Quest dnfrepo.QuestRecord Character dnfrepo.CharacterRecord QuestFields []dnfrepo.QuestField CharacterFields []dnfrepo.CharacterField CompletedQuestIDs []int64 TitleGrants []CompletionTicketTitleGrant ProfessionChanged bool } // PlanCompletionTicket interprets every quest-ticket [action type] present in // the current PVF. It never grants ordinary quest rewards: the ticket itself is // the authorization to record the selected quests as completed. func (c *Catalog) PlanCompletionTicket( record dnfrepo.QuestRecord, characterRecord dnfrepo.CharacterRecord, character CharacterEligibility, spec CompletionTicketSpec, completedAt time.Time, ) (CompletionTicketPlan, error) { if c == nil || completedAt.IsZero() || !definitionCharacterJobValid(character) { return CompletionTicketPlan{}, ErrCompletionTicketActionInvalid } if characterRecord.Level != character.Level || character.Level <= 0 || (spec.MinimumLevel > 0 && int64(character.Level) < spec.MinimumLevel) || (spec.MaximumLevel > 0 && int64(character.Level) > spec.MaximumLevel) { return CompletionTicketPlan{}, ErrCompletionTicketLevelInvalid } action := normalizeTicketAction(spec.ActionType) if action == "[quest clear coupon]" { // This family opens the native selection UI; op1429 owns the selected // quest mutation and therefore must retain the selector from that packet. return CompletionTicketPlan{}, ErrCompletionTicketSelectionRequired } plan := CompletionTicketPlan{ Quest: dnfrepo.CloneQuest(record), Character: dnfrepo.CloneCharacter(characterRecord), } completed, _ := questStateSets(plan.Quest) answers := questAnswerSelections(plan.Quest) candidates := make([]int64, 0, 64) selected := make(map[int64]struct{}) projected := make(map[int64]struct{}, len(completed)+64) for questID := range completed { projected[questID] = struct{}{} } add := func(definition Definition, requirePrerequisites bool) bool { if definition.ID <= 0 || definition.LevelMin > character.Level || !definitionMatchesCharacter(definition, character) { return false } // Level-up tickets advance main progression only. Side/external stories // and equipment-slot unlocks stay player-selected, while Hell Party // unlocks must reach the normal finish path so each reward can write its // own durable permission. if (spec.PreserveSideQuests && ticketOptionalExternalQuest(definition)) || (spec.PreserveHellPartyUnlocks && c.HasHellPartyUnlock(definition.ID)) { return false } if _, done := projected[definition.ID]; done { return false } if _, exists := selected[definition.ID]; exists { return false } if requirePrerequisites && !ticketPrerequisitesMet(definition, projected, answers) { return false } selected[definition.ID] = struct{}{} projected[definition.ID] = struct{}{} candidates = append(candidates, definition.ID) // A [question] quest auto-completed by the ticket answers with the // legacy default 0 (the only answer the old handler could persist). // Record that projected answer so the branch depending on it // (for example 3868 -> 3870) can be selected in the same plan instead // of waiting for a second ticket. if normalizeQuestTag(definition.Type) == "question" { if _, answered := answers[definition.ID]; !answered { answers[definition.ID] = 0 } } return true } switch action { case "[any quest clear]": if len(spec.ActionValues) == 0 { return CompletionTicketPlan{}, ErrCompletionTicketActionInvalid } for _, questID := range spec.ActionValues { definition, found := c.Find(questID) if !found { continue } add(definition, false) } case "[gm quest clear]": c.collectTicketBulkCandidates(character, character.Level, func(definition Definition) bool { return normalizeQuestTag(definition.Grade) == "epic" }, add, true) case "[lv clear]": cutoff := character.Level if len(spec.ActionValues) > 0 && spec.ActionValues[0] > 0 && int64(cutoff) > spec.ActionValues[0] { cutoff = int(spec.ActionValues[0]) } c.collectTicketBulkCandidates(character, cutoff, func(definition Definition) bool { grade := normalizeQuestTag(definition.Grade) return grade == "epic" || grade == "title" || (grade == "achievement" && !ticketExcludedAchievement(definition)) }, add, true) case "[quest clear]": cutoff := character.Level if spec.MaximumLevel > 0 && int64(cutoff) > spec.MaximumLevel { cutoff = int(spec.MaximumLevel) } c.collectTicketBulkCandidates(character, cutoff, func(definition Definition) bool { switch normalizeQuestTag(definition.Grade) { case "epic", "", "normal", "side": return true default: return false } }, add, true) case "[achievement quest clear]", "[side quest clear]": c.collectTicketBulkCandidates(character, character.Level, func(definition Definition) bool { grade := normalizeQuestTag(definition.Grade) if grade == "title" { return true } return (grade == "achievement" || (action == "[side quest clear]" && grade == "side")) && !ticketExcludedAchievement(definition) }, add, true) case "[first awaken clear]", "[second awaken clear]": // 转职达成券 reuses [first awaken clear] with a single operand. The // item description excludes 外传 jobs (dark knight / creator). For the // normal jobs the client's native "转职状态" gate rejects use even with // an accepted branch, so 90CN.dll routes it through the op44 fallback // and the server completes the accepted branch (or the operand branch // when none is active). The two-operand form is the real awakening // ticket. if action == "[first awaken clear]" && len(spec.ActionValues) == 1 { if character.Job == currentExternalJobDarkKnight || character.Job == currentExternalJobCreator { return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest } wanted := spec.ActionValues[0] if wanted <= 0 { wanted = 1 } var chosen Definition chosenTarget := int64(-1) // 1) A branch already accepted at the trainer wins. _, active := questStateSets(record) for questID := range active { definition, found := c.Find(questID) if !found || definition.JobChangeQuest != 1 || !definitionMatchesCharacter(definition, character) { continue } target := classChangeGrowType(definition) if target < 0 { // "转职祝贺" and other [job change quest] 1 helpers do not // carry the [grow type] finish reward; only the actual // branch quest can be the ticket target. continue } chosen, chosenTarget = definition, target break } // 2) No accepted branch: use the operand branch, else the lowest. if chosenTarget < 0 { for _, definition := range c.ordered { if definition.JobChangeQuest != 1 || !definitionMatchesCharacter(definition, character) { continue } target := classChangeGrowType(definition) if target < 0 { continue } if target == wanted { chosen, chosenTarget = definition, target break } if chosenTarget < 0 || target < chosenTarget { chosen, chosenTarget = definition, target } } } if chosenTarget < 0 || !add(chosen, false) { return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest } if plan.Character.Stats == nil { plan.Character.Stats = make(map[string]int64) } newGrowType := int64(character.GrowType&0xf0) | chosenTarget if plan.Character.Stats["grow_type"] != newGrowType { plan.Character.Stats["grow_type"] = newGrowType plan.Character.UpdatedAt = completedAt if !containsCharacterField(plan.CharacterFields, dnfrepo.CharacterFieldStats) { plan.CharacterFields = append(plan.CharacterFields, dnfrepo.CharacterFieldStats) } plan.ProfessionChanged = true } break } targetStage := 1 jobChangeQuest := 2 if action == "[second awaken clear]" { targetStage = 2 jobChangeQuest = 3 } currentStage := (character.GrowType >> 4) & 0x0f if currentStage >= targetStage || currentStage+1 != targetStage { return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest } c.collectTicketBulkCandidates(character, character.Level, func(definition Definition) bool { return definition.JobChangeQuest == jobChangeQuest }, add, false) if len(candidates) > 0 { newGrowType := int64(targetStage<<4 | (character.GrowType & 0x0f)) if plan.Character.Stats == nil { plan.Character.Stats = make(map[string]int64) } if plan.Character.Stats["grow_type"] != newGrowType { plan.Character.Stats["grow_type"] = newGrowType plan.Character.UpdatedAt = completedAt plan.CharacterFields = append(plan.CharacterFields, dnfrepo.CharacterFieldStats) plan.ProfessionChanged = true } } default: return CompletionTicketPlan{}, fmt.Errorf("%w: %q", ErrCompletionTicketActionInvalid, spec.ActionType) } // Parent [quest clear] definitions own objective children. Completing the // parent via a coupon must also retire already-active child rows so the task // panel does not retain a ghost objective after its parent is cleared. for index := 0; index < len(candidates); index++ { parent, found := c.Find(candidates[index]) if !found || normalizeQuestTag(parent.Type) != "quest clear" { continue } for _, childID := range parent.IntData { child, childFound := c.Find(childID) if childFound && child.MainQuestID == parent.ID && definitionMatchesCharacter(child, character) { add(child, false) } } } if len(candidates) == 0 { return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest } for _, questID := range candidates { definition, found := c.Find(questID) if !found { continue } state, field, known := mutableQuestState(&plan.Quest, questID) isTitleBook := titleBookProgressDefinition(definition) isTitleRewardAchievement := !isTitleBook && titleAchievementDefinition(definition) if isTitleBook { // Title-book achievements (grade [title]) live in Quest.Progress // as title_book_* rows: the client's title page reads exactly this // snapshot plus inventory list 100. if plan.Quest.Progress == nil { plan.Quest.Progress = make(map[int64]dnfrepo.QuestState) } if existing, found := plan.Quest.Progress[questID]; found { state = existing } else { state = dnfrepo.QuestState{} } field = dnfrepo.QuestFieldProgress } else if !known { state = dnfrepo.QuestState{} field = dnfrepo.QuestFieldStates if plan.Quest.States == nil { plan.Quest.States = make(map[int64]dnfrepo.QuestState) } } if isTitleBook { state.Status = "title_book_completed" } else { state.Status = "completed" } state.ProgressValue = 0 state.RewardSelectIndex = 0 state.UpdatedAt = completedAt if state.Extra == nil { state.Extra = make(map[string]string, 8) } state.Extra["pvf_path"] = definition.Path state.Extra["quest_type"] = definition.Type state.Extra["reward_state"] = finishRewardGranted state.Extra["auto_completed"] = "true" state.Extra["auto_complete_reason"] = "pvf_quest_completion_ticket_no_reward" state.Extra["auto_complete_action"] = action // Persist the legacy answer for [question] quests so later tickets, // accept lists and successors resolve the same branch the plan took. if normalizeQuestTag(definition.Type) == "question" { state.Extra[questAnswerExtraKey] = "0" } if isTitleBook { // Same durable contract the achievement owner writes when a title // achievement completes: initialized plus all three remainders 0. state.Extra["title_book_initialized"] = "1" state.Extra["title_book_remain_1"] = "0" state.Extra["title_book_remain_2"] = "0" state.Extra["title_book_remain_3"] = "0" } if isTitleBook || isTitleRewardAchievement { plan.TitleGrants = append(plan.TitleGrants, CompletionTicketTitleGrant{ QuestID: questID, ItemID: titleRewardItemID(definition), }) } // Support/magic-stone/earring quests unlock their equipment slot in // the normal finish path. The bulk ticket bypasses that settlement, so // apply the same durable character bit here or the ticket would only // mark the quest complete while the slot stays locked. if normalizeQuestTag(definition.RewardType) == "slot expansion" && len(definition.RewardIntData) == 1 { if bit, ok := ExEquipSlotBitForPVFIndex(uint32(definition.RewardIntData[0])); ok { if plan.Character.Stats == nil { plan.Character.Stats = make(map[string]int64) } if plan.Character.Stats["ex_equip_slot_stat"]&int64(bit) == 0 { plan.Character.Stats["ex_equip_slot_stat"] |= int64(bit) plan.Character.UpdatedAt = completedAt if !containsCharacterField(plan.CharacterFields, dnfrepo.CharacterFieldStats) { plan.CharacterFields = append(plan.CharacterFields, dnfrepo.CharacterFieldStats) } } } } if field == dnfrepo.QuestFieldProgress { plan.Quest.Progress[questID] = state } else { plan.Quest.States[questID] = state } plan.QuestFields = mergeQuestChangedField(plan.QuestFields, field) plan.CompletedQuestIDs = append(plan.CompletedQuestIDs, questID) } if len(plan.QuestFields) == 0 { return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest } plan.Quest.UpdatedAt = completedAt sort.Slice(plan.CompletedQuestIDs, func(i, j int) bool { return plan.CompletedQuestIDs[i] < plan.CompletedQuestIDs[j] }) return plan, nil } func (c *Catalog) collectTicketBulkCandidates( character CharacterEligibility, cutoff int, match func(Definition) bool, add func(Definition, bool) bool, requirePrerequisites bool, ) { if c == nil || cutoff <= 0 || match == nil || add == nil { return } for progressed := true; progressed; { progressed = false for _, definition := range c.ordered { if definition.LevelMin <= 0 || definition.LevelMin > cutoff || definition.IsEvent || ticketExcludedGrade(normalizeQuestTag(definition.Grade)) || !match(definition) || !definitionMatchesCharacter(definition, character) { continue } // Achievement quests are never offered by an NPC: the current PVF // sets [exposed by npc] = 0 for the whole family (title-book // achievements and bounty quests alike), and the client advances // them through op417 instead of the normal accept list. The // title-book family (grade [title]) works the same way. Keep the // NPC-exposure requirement for story quests, but let both // families through so [lv clear] and the dedicated achievement // ticket actually complete them. isAchievementFamily := normalizeQuestTag(definition.Grade) == "achievement" || normalizeQuestTag(definition.Grade) == "title" if definition.ExposedByNPC == 0 && !isAchievementFamily { continue } // These families are counter-driven: their [pre required] // entries reference trackers (for example id 1892) that are not // quests at all, so requiring quest prerequisites would leave // them permanently locked. The QA completion ticket owns the // "complete everything" contract for this family. require := requirePrerequisites && !isAchievementFamily if add(definition, require) { progressed = true } } } } func ticketPrerequisitesMet(definition Definition, completed map[int64]struct{}, answers map[int64]int64) bool { if definition.MalformedPreAnswers { return false } if definition.MainQuestID > 0 { if _, found := completed[definition.MainQuestID]; !found { 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 } } for _, requirement := range definition.PreRequiredAnswers { if answer, found := answers[requirement.QuestID]; !found || answer != requirement.Answer { return false } } for _, collision := range definition.CollisionQuests { if collision > 0 { if _, found := completed[collision]; found { return false } } } return true } func ticketExcludedAchievement(definition Definition) bool { if definition.JobChangeQuest != 0 { return true } switch normalizeQuestTag(definition.RewardType) { case "grow type", "awakening type", "hell challenge": return true } // These IDs are completed only by their dedicated support-equipment, // magic-stone, earring, or Hell Party [any quest clear] tickets. switch definition.ID { case 649, 650, 674, 675, 2002, 2635, 2636, 2900, 4391, 4539: return true default: return false } } func ticketOptionalExternalQuest(definition Definition) bool { switch normalizeQuestTag(definition.Grade) { case "side", "common unique": return true } // Support equipment, magic stone, and earring quests are not consistently // graded across Script.pvf revisions. Their reward contract is authoritative // and must never be bypassed by a level-up ticket. return normalizeQuestTag(definition.RewardType) == "slot expansion" } func normalizeTicketAction(value string) string { return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "`", ""))) } // titleBookProgressDefinition identifies the title-book achievement family // (grade [title]). Its state is the title_book_* row in Quest.Progress, and its // reward item is the physical title in inventory list 100. func titleBookProgressDefinition(definition Definition) bool { return normalizeQuestTag(definition.Grade) == "title" } // titleAchievementDefinition identifies the older [achievement] quests that // reward a title item directly. They keep the ordinary completed state (they // are not titlebook.etc rows) but still materialise their title item. func titleAchievementDefinition(definition Definition) bool { return normalizeQuestTag(definition.Grade) == "achievement" && normalizeQuestTag(definition.RewardType) == "title" } // titleRewardItemID returns the PVF title item carried by the reward data of a // title-book or title-reward achievement quest. func titleRewardItemID(definition Definition) int32 { if len(definition.RewardIntData) == 0 { return 0 } itemID := definition.RewardIntData[0] if itemID <= 0 || itemID > int64(^uint32(0)>>1) { return 0 } return int32(itemID) } func containsCharacterField(fields []dnfrepo.CharacterField, want dnfrepo.CharacterField) bool { for _, field := range fields { if field == want { return true } } return false } // 外传 jobs never receive class-change tickets (the PVF item description // excludes them): dark knight (9) and creator (10). const ( currentExternalJobDarkKnight = 9 currentExternalJobCreator = 10 ) // classChangeGrowType extracts the subclass encoded in a [job change quest] 1 // finish reward ([grow type] N). It returns -1 when the definition cannot be // used as a class-change target. func classChangeGrowType(definition Definition) int64 { if normalizeQuestTag(definition.RewardType) != "grow type" || len(definition.RewardIntData) != 1 { return -1 } target := definition.RewardIntData[0] if target <= 0 || target > 0x0f { return -1 } return target }