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