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