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

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
}