Files
90CNServer/server/internal/services/dnfbridge/quest_auto_complete.go
T
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

435 lines
18 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentAutoCompleteMainQuestRequestSize = 4
currentAutoCompleteMainQuestAckSuccessSize = 1
currentAutoCompleteMainQuestAckFailureSize = 2
currentAutoCompleteMainQuestMaxLevel = 89
currentAutoCompleteMainQuestAckNeutralError = 0
// op1429's client handler draws nothing at all for a rejected request - it
// only releases the quest window - so every rejection has to carry its own
// notice or the button reads as dead input.
currentAutoCompleteClassChangeNoticeText = "需要先完成转职任务,转职后才能一键完成低级主线。"
currentAutoCompleteNoTargetNoticeText = "当前没有可以一键完成的主线任务。"
// The rest of the story chain sits behind an awakening quest, and a bulk
// pass never completes one for the player. Name it: the quest is still in
// the player's list, so "没有可以一键完成的主线任务" reads as a lie.
currentAutoCompleteAwakeningNoticeFormat = "剩下的主线卡在觉醒任务『%s』上,需要你自己完成,一键完成不会代做觉醒。"
currentAutoCompleteAwakeningNoticeText = "剩下的主线卡在觉醒任务上,需要你自己完成,一键完成不会代做觉醒。"
currentAutoCompleteUnavailableNoticeText = "一键完成主线暂时不可用,请稍后再试。"
// A [question] quest's answer decides which successors unlock, so the pass
// must not pick one. Same reason the awakening text exists: the quest is
// still in the list, so a bare "没有可以一键完成的主线任务" is wrong.
currentAutoCompleteChoiceNoticeFormat = "剩下的主线卡在抉择任务『%s』上,需要你自己找 NPC 做出选择,一键完成不会替你选。"
currentAutoCompleteChoiceNoticeText = "剩下的主线卡在抉择任务上,需要你自己找 NPC 做出选择,一键完成不会替你选。"
)
// currentAutoCompleteMainQuestNoticeSurface is where those rejections land.
// Magenta on the 系统 tab: the message is a hint for one player rather than a
// broadcast, and the default light blue proved too easy to miss.
var currentAutoCompleteMainQuestNoticeSurface = noticeSurfaceSystemMagenta
// op1429 answers through the ordinary class-1 envelope. Its reader
// (sub_2262150) takes one u8 status; when that status is zero and the
// per-opcode filter sub_7691D0 rejects the opcode - 1429 is outside its
// 0x12..0xD7 window, so it does - it takes one more u8 error code. Only then
// does it call the registered handler, sub_225C8A0 passing (opcode, status,
// error, context).
//
// The registered op1429 handler sub_1D0F340 is a pure thunk over the quest
// window at [0x51B2798]: status != 0 jumps to sub_1A03E90, which refreshes the
// window and, for the bulk request (the window's 0x52C flag == 1, set by the
// sender at 0x19EE332), asks the server for the refreshed list with op1456;
// status == 0 jumps to sub_19FE680, which just releases the window. Neither
// reads one byte of the remaining body.
//
// The packed 13-byte struct this used to send (selector, highest quest,
// cutoff, committed) was therefore never consumed by the game. It was read off
// sub_79EB70 - which is registered for 1429 in the class-0 *logging* table and
// only prints five fields - and it made the feature unusable: the envelope
// took its first byte as the status, and that byte is the low byte of the
// request selector, which the bulk request always sends as 0. Every bulk
// auto-complete, success or not, reached the client as a silent failure.
func buildCurrentAutoCompleteMainQuestAck(committed bool) []byte {
if committed {
return []byte{1}
}
// The envelope still reads an error byte here, but op1429's handler
// discards it - there is no client text to select - so send a neutral 0.
return []byte{0, currentAutoCompleteMainQuestAckNeutralError}
}
// sendCurrentAutoCompleteMainQuestRejection answers one refused op1429 the way
// the client needs it: the class-1 failure envelope, then a notice carrying the
// reason. The ACK alone releases the quest window without drawing anything, so
// without the notice the player only sees an unresponsive button.
func (s *Service) sendCurrentAutoCompleteMainQuestRejection(
session *gameSession,
event string,
noticeText string,
noticeSource string,
fields ...any,
) error {
response := buildCurrentAutoCompleteMainQuestAck(false)
fields = append(fields,
"notice_source", noticeSource,
"notice_surface", currentAutoCompleteMainQuestNoticeSurface.Name,
"msg_id", uint16(dnfenum.CmdPacketClearQuestTicket),
"classification", dnfproto.DefaultChannelClassification,
"body_len", len(response))
s.logGameEvent(session, event, fields...)
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketClearQuestTicket),
response,
dnfproto.DefaultChannelClassification,
); err != nil {
return err
}
return s.sendNotice(session, noticeRequest{
Surface: currentAutoCompleteMainQuestNoticeSurface,
Text: noticeText,
Source: noticeSource,
})
}
// sendCurrentAutoCompleteMainQuestUnavailable answers the rejections that are
// not the player's doing - a repository or PVF catalog that is not ready, or a
// completion the domain refused. They used to return silently, which left the
// quest window waiting on an ACK that never came.
func (s *Service) sendCurrentAutoCompleteMainQuestUnavailable(session *gameSession, fields ...any) error {
return s.sendCurrentAutoCompleteMainQuestRejection(
session,
"game-auto-complete-main-quest-blocked",
currentAutoCompleteUnavailableNoticeText,
"auto_complete_main_quest_unavailable",
fields...,
)
}
func (s *Service) handleCurrentAutoCompleteMainQuest(session *gameSession, body []byte) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
if len(body) != currentAutoCompleteMainQuestRequestSize {
s.logGameEvent(session, "game-auto-complete-main-quest-blocked",
"body_len", len(body),
"reason", "current_exe_op1429_requires_exact_u32_request")
return nil
}
requestSelector := binary.LittleEndian.Uint32(body)
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterSettlement == nil {
return s.sendCurrentAutoCompleteMainQuestUnavailable(session,
"request_selector", requestSelector,
"reason", "character_settlement_transaction_unavailable")
}
_, _, character, found := s.selectedCharacterForEnter(ctx, session)
if !found || character.Level <= 1 {
return s.sendCurrentAutoCompleteMainQuestUnavailable(session,
"request_selector", requestSelector,
"character_found", found,
"level", character.Level,
"reason", "selected_character_or_previous_level_unavailable")
}
job, validJob := characterJobByte(character)
if !validJob {
return s.sendCurrentAutoCompleteMainQuestUnavailable(session,
"request_selector", requestSelector,
"job", character.Job,
"reason", "selected_character_job_invalid")
}
cutoffLevel := character.Level - 1
if cutoffLevel > currentAutoCompleteMainQuestMaxLevel {
cutoffLevel = currentAutoCompleteMainQuestMaxLevel
}
catalog, err := s.loadQuestCatalog(ctx)
if err != nil {
return s.sendCurrentAutoCompleteMainQuestUnavailable(session,
"request_selector", requestSelector,
"reason", "quest_catalog_unavailable",
"error", err)
}
owner, err := dnfquest.NewOwner(repositories)
if err != nil {
return err
}
characterID := character.CharacterID
if characterID == "" {
characterID = strconv.Itoa(int(session.selectedCharacterID))
}
targetQuestID := int64(requestSelector)
targetSource := "current_exe_nonzero_per_entry_selector"
if requestSelector == 0 {
targetSource = "zero_selector_bulk_eligible_pvf_main_quests"
}
input := dnfquest.AutoCompleteMainInput{
CharacterID: characterID,
Eligibility: dnfquest.CharacterEligibility{
Level: character.Level,
Job: int(job),
GrowType: int(numericCharacterStatValue(character, "grow_type")),
},
CutoffLevel: cutoffLevel,
TargetQuestID: targetQuestID,
CompletedAt: time.Now().UTC(),
}
// Story items an outstanding epic still needs (3226's letter for 3610, the
// 3509 fragments) are carried forward inside the same transaction. Without
// the item catalog the quest side still completes; only a character that
// actually needs such an item then fails, loudly, instead of getting stuck.
if itemCatalog, itemCatalogErr := s.currentPVFItemCatalog(); itemCatalogErr == nil {
mainSlotCount := uint16(0)
if repositories.Settings != nil {
containerState := s.loadCurrentItemListContainerState(ctx, repositories.Settings, characterID, dnfrepo.MainInventoryListType)
mainSlotCount = containerState.MainSlotCount
}
input.AllocateItem = currentQuestFinishItemAllocator(itemCatalog, mainSlotCount)
} else {
s.logGameEvent(session, "game-auto-complete-main-quest-carry-forward-catalog-unavailable",
"request_selector", requestSelector,
"error", itemCatalogErr)
}
result, err := owner.ApplyAutoCompleteMain(ctx, catalog, input)
if err != nil {
if errors.Is(err, dnfquest.ErrAutoCompleteClassRequired) {
return s.sendCurrentAutoCompleteMainQuestRejection(
session,
"game-auto-complete-main-quest-class-change-required",
currentAutoCompleteClassChangeNoticeText,
"auto_complete_main_quest_class_change_required",
"request_selector", requestSelector,
"job", job,
"grow_type", input.Eligibility.GrowType,
"cutoff_level", cutoffLevel,
"reason", err,
"quest_mutation", "none",
)
}
var awakening *dnfquest.AwakeningRequiredError
if errors.As(err, &awakening) {
text := currentAutoCompleteAwakeningNoticeText
if awakening.QuestName != "" {
text = fmt.Sprintf(currentAutoCompleteAwakeningNoticeFormat, awakening.QuestName)
}
return s.sendCurrentAutoCompleteMainQuestRejection(
session,
"game-auto-complete-main-quest-awakening-required",
text,
"auto_complete_main_quest_awakening_required",
"request_selector", requestSelector,
"blocking_quest_id", awakening.QuestID,
"blocking_quest_name", awakening.QuestName,
"cutoff_level", cutoffLevel,
"quest_mutation", "none",
)
}
var choice *dnfquest.ChoiceRequiredError
if errors.As(err, &choice) {
text := currentAutoCompleteChoiceNoticeText
if choice.QuestName != "" {
text = fmt.Sprintf(currentAutoCompleteChoiceNoticeFormat, choice.QuestName)
}
return s.sendCurrentAutoCompleteMainQuestRejection(
session,
"game-auto-complete-main-quest-choice-required",
text,
"auto_complete_main_quest_choice_required",
"request_selector", requestSelector,
"blocking_quest_id", choice.QuestID,
"blocking_quest_name", choice.QuestName,
"cutoff_level", cutoffLevel,
"quest_mutation", "none",
)
}
if errors.Is(err, dnfquest.ErrAutoCompleteTargetAmbiguous) ||
errors.Is(err, dnfquest.ErrAutoCompleteTargetInvalid) {
return s.sendCurrentAutoCompleteMainQuestRejection(
session,
"game-auto-complete-main-quest-single-target-required",
currentAutoCompleteNoTargetNoticeText,
"auto_complete_main_quest_no_eligible_target",
"request_selector", requestSelector,
"target_quest_id", targetQuestID,
"target_source", targetSource,
"cutoff_level", cutoffLevel,
"reason", err,
"quest_mutation", "none",
)
}
return s.sendCurrentAutoCompleteMainQuestUnavailable(session,
"request_selector", requestSelector,
"target_quest_id", targetQuestID,
"target_source", targetSource,
"cutoff_level", cutoffLevel,
"reason", "pvf_db_atomic_selected_main_quest_completion_rejected",
"error", err)
}
if result.HighestCompletedQuest < 0 || result.HighestCompletedQuest > int64(^uint32(0)) {
return fmt.Errorf("auto-complete highest quest id out of wire range: %d", result.HighestCompletedQuest)
}
// 批量主线补完只同步支援兵的**状态**(gain=0)。职业/觉醒跃迁不在这里
// 合成,仍由正常任务结算与清票路径负责 —— 否则一次批量补完会把支援兵
// 的职业分支随便定下来。这个调用只用于修复"支援兵任务行复制了、但持久
// 效果没跟上"的历史数据。
s.syncCurrentDungeonCompanionProgression(
ctx,
session,
fmt.Sprintf("quest-auto-complete:%d:%d", requestSelector, result.HighestCompletedQuest),
nil,
0,
&result.PostCommitQuest,
)
response := buildCurrentAutoCompleteMainQuestAck(true)
s.logGameEvent(session, "game-auto-complete-main-quest-success-send",
"request_selector", requestSelector,
"target_quest_id", result.HighestCompletedQuest,
"target_source", targetSource,
"cutoff_level", cutoffLevel,
"targeted_main_count", len(result.EligibleQuestIDs),
"changed_main_count", len(result.ChangedQuestIDs),
"changed_linked_sub_count", len(result.ChangedLinkedSubIDs),
"active_completed_count", result.ActiveCompletedCount,
"highest_completed_quest", result.HighestCompletedQuest,
"idempotent", result.Idempotent,
"msg_id", uint16(dnfenum.CmdPacketClearQuestTicket),
"classification", dnfproto.DefaultChannelClassification,
"body_len", len(response),
"body_source", "current_exe_class1_status_envelope_sub_1D0F340_and_atomic_pvf_main_quest_completion_without_rewards")
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketClearQuestTicket),
response,
dnfproto.DefaultChannelClassification,
); err != nil {
return err
}
if len(result.CarryForwardItems) > 0 {
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(result.CarryForwardItems))
for _, item := range result.CarryForwardItems {
if item.SlotIndex > math.MaxInt16 {
return fmt.Errorf("auto-complete carry-forward slot out of wire range: %d", item.SlotIndex)
}
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
ListType: dnfrepo.MainInventoryListType,
SlotIndex: int16(item.SlotIndex),
})
}
if err := s.sendSelectedIncrementalItemSlotRefreshes(
session,
"auto_complete_main_carry_forward",
refreshes,
); err != nil {
return err
}
s.logGameEvent(session, "game-auto-complete-main-quest-carry-forward-items-send",
"request_selector", requestSelector,
"item_count", len(result.CarryForwardItems),
"source", "pvf_skipped_reward_required_by_outstanding_story_quest")
}
if err := s.sendCurrentClearQuestListFromCommittedQuest(
session,
result.PostCommitQuest,
"current_exe_op1429_after_atomic_no_reward_main_quest_completion",
); err != nil {
return err
}
if err := s.sendCurrentActiveQuestSnapshotForSession(
session,
"current_exe_op1429_after_completed_quest_bitmap",
); err != nil {
return err
}
if err := s.sendCurrentAcceptableQuestListOnlyForSession(
session,
"current_exe_op1429_after_active_quest_snapshot",
); err != nil {
return err
}
// The bulk closure can finish a map [quest condition] or client-feature gate
// quest inside its changed set even when that quest is not the highest one
// completed. Rebuild the same-area town scene when ANY changed quest gates
// the currently loaded map; an idempotent close changed nothing and needs no
// rebuild.
if result.Idempotent {
return nil
}
// 注意:这里**不**推进 story digest。
//
// op1429 批量补完成会关闭大量低阶主线任务,但"关掉任务"不等于"看过章节摘要"。
// 章节过场只应由「城镇区域开放任务」触发;把等级同步当成看过证据会永久压掉
// 低等级段摘要,并让客户端内部值(只在收到 op1378 时更新)与服务端脱节。
return s.sendCurrentQuestGatedTownSceneRefreshForQuestIDs(
session,
currentAutoCompleteFinishCommitBridge(result, character),
currentAutoCompleteChangedQuestIDs(result),
"current_exe_op1429_after_atomic_no_reward_main_quest_completion",
)
}
// currentAutoCompleteFinishCommitBridge adapts the op1429 bulk-completion
// receipt into the minimal committed finish shape consumed by the town scene
// refresh gate. Bulk completion never changes the character record, so the
// pre-applied character snapshot already carries the authoritative town
// location and area state for the scene check.
func currentAutoCompleteFinishCommitBridge(
result dnfquest.AutoCompleteMainResult,
character dnfrepo.CharacterRecord,
) dnfquest.FinishCommitResult {
return dnfquest.FinishCommitResult{
CharacterID: result.CharacterID,
QuestID: result.HighestCompletedQuest,
AtomicCommitted: true,
PostCommitCharacter: character,
PostCommitQuest: result.PostCommitQuest,
}
}
// currentAutoCompleteChangedQuestIDs returns the deduplicated set of quests the
// op1429 close actually changed, including linked no-reward sub quests. The
// scene refresh gate scans this whole set so a [quest condition] belonging to a
// non-highest quest still triggers the same-area rebuild.
func currentAutoCompleteChangedQuestIDs(result dnfquest.AutoCompleteMainResult) []int64 {
size := len(result.ChangedQuestIDs) + len(result.ChangedLinkedSubIDs)
questIDs := make([]int64, 0, size+1)
seen := make(map[int64]struct{}, size+1)
appendOnce := func(questID int64) {
if questID <= 0 {
return
}
if _, duplicate := seen[questID]; duplicate {
return
}
seen[questID] = struct{}{}
questIDs = append(questIDs, questID)
}
for _, questID := range result.ChangedQuestIDs {
appendOnce(questID)
}
for _, questID := range result.ChangedLinkedSubIDs {
appendOnce(questID)
}
appendOnce(result.HighestCompletedQuest)
return questIDs
}