服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
564 lines
20 KiB
Go
564 lines
20 KiB
Go
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
|
|
}
|