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,300 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
currentSetQuestTriggerRequestBodySize = dnfquest.CurrentSetTriggerRequestBodySize
|
||||
currentSetQuestTriggerMaskedRequestBodySize = dnfquest.CurrentMaskedSetTriggerRequestBodySize
|
||||
)
|
||||
|
||||
func currentSetQuestTriggerRequestBodyValid(body []byte) bool {
|
||||
if len(body) != currentSetQuestTriggerRequestBodySize && len(body) != currentSetQuestTriggerMaskedRequestBodySize {
|
||||
return false
|
||||
}
|
||||
return binary.LittleEndian.Uint16(body[:2]) == uint16(dnfenum.CmdPacketSetQuestTrigger)
|
||||
}
|
||||
|
||||
// currentSetQuestTriggerReplayKey is deliberately scoped to one game TCP
|
||||
// session. It avoids repeatedly loading and publishing an active quest whose
|
||||
// persisted trigger has already reached the exact requested state.
|
||||
type currentSetQuestTriggerReplayKey struct {
|
||||
characterID uint16
|
||||
questID uint16
|
||||
triggerType byte
|
||||
argument byte
|
||||
}
|
||||
|
||||
func newCurrentSetQuestTriggerReplayKey(characterID uint16, request dnfquest.SetTriggerRequest) currentSetQuestTriggerReplayKey {
|
||||
return currentSetQuestTriggerReplayKey{
|
||||
characterID: characterID,
|
||||
questID: request.QuestID,
|
||||
triggerType: request.TriggerType,
|
||||
argument: request.TriggerArgument,
|
||||
}
|
||||
}
|
||||
|
||||
func (session *gameSession) currentSetQuestTriggerReplaySuppressed(key currentSetQuestTriggerReplayKey) bool {
|
||||
if session == nil {
|
||||
return false
|
||||
}
|
||||
session.questReplay.triggerMu.Lock()
|
||||
defer session.questReplay.triggerMu.Unlock()
|
||||
_, ok := session.questReplay.triggerNoop[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (session *gameSession) suppressCurrentSetQuestTriggerReplay(key currentSetQuestTriggerReplayKey) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.questReplay.triggerMu.Lock()
|
||||
defer session.questReplay.triggerMu.Unlock()
|
||||
if session.questReplay.triggerNoop == nil {
|
||||
session.questReplay.triggerNoop = make(map[currentSetQuestTriggerReplayKey]struct{}, 1)
|
||||
}
|
||||
session.questReplay.triggerNoop[key] = struct{}{}
|
||||
}
|
||||
|
||||
// clearCurrentSetQuestTriggerReplayForQuest advances the session-local replay
|
||||
// gate when op31 has durably activated a new generation of the same quest ID.
|
||||
// Without this invalidation a repeatable quest can inherit the previous
|
||||
// generation's terminal op33 key and silently lose its first valid trigger.
|
||||
func (session *gameSession) clearCurrentSetQuestTriggerReplayForQuest(characterID, questID uint16) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.questReplay.triggerMu.Lock()
|
||||
defer session.questReplay.triggerMu.Unlock()
|
||||
for key := range session.questReplay.triggerNoop {
|
||||
if key.characterID == characterID && key.questID == questID {
|
||||
delete(session.questReplay.triggerNoop, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) sendCurrentSetQuestTriggerSuccess(
|
||||
session *gameSession,
|
||||
request dnfquest.SetTriggerRequest,
|
||||
result dnfquest.PlanResult,
|
||||
bodySource string,
|
||||
) error {
|
||||
var payload packetWriter
|
||||
payload.writeUint16(request.QuestID)
|
||||
payload.writeUint32(uint32(result.ProgressValue))
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-success-send",
|
||||
"quest_id", request.QuestID,
|
||||
"trigger_type", request.TriggerType,
|
||||
"increment", request.IsIncrement,
|
||||
"trigger_value", result.ProgressValue,
|
||||
"status", result.Status,
|
||||
"msg_id", uint16(dnfenum.CmdPacketSetQuestTrigger),
|
||||
"classification", dnfproto.DefaultChannelClassification,
|
||||
"plain_body_len", 7,
|
||||
"body_source", bodySource)
|
||||
return s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketSetQuestTrigger), payload.bytes())
|
||||
}
|
||||
|
||||
// handleCurrentSetQuestTrigger closes the current NPC/dialogue quest step.
|
||||
// The client owns which active quest and trigger channel an NPC interaction
|
||||
// selects; the server owns the durable update and exact ACK. Nonterminal
|
||||
// updates may publish the active-quest snapshot; terminal zero must not.
|
||||
// Same-session no-op replays are intentionally silent because ACKing each
|
||||
// replay and resending op574 creates a client/server feedback loop.
|
||||
func (s *Service) handleCurrentSetQuestTrigger(session *gameSession, body []byte) error {
|
||||
if session == nil || session.selectedCharacterID == 0 {
|
||||
return nil
|
||||
}
|
||||
if !currentSetQuestTriggerRequestBodyValid(body) {
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-blocked",
|
||||
"body_len", len(body),
|
||||
"reason", "current_exe_op33_requires_scalar6_or_masked7_body")
|
||||
return nil
|
||||
}
|
||||
request, err := dnfquest.DecodeSetTriggerRequest(body)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-blocked",
|
||||
"body_len", len(body),
|
||||
"reason", "request_decode_failed",
|
||||
"error", err)
|
||||
return nil
|
||||
}
|
||||
replayKey := newCurrentSetQuestTriggerReplayKey(session.selectedCharacterID, request)
|
||||
if session.currentSetQuestTriggerReplaySuppressed(replayKey) {
|
||||
return nil
|
||||
}
|
||||
if currentSessionDungeonIsPractice(session) {
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Quest == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
record, found, loadErr := repositories.Quest.Load(ctx, strconv.FormatUint(uint64(session.selectedCharacterID), 10))
|
||||
cancel()
|
||||
if loadErr != nil || !found {
|
||||
return nil
|
||||
}
|
||||
state, exists := record.States[int64(request.QuestID)]
|
||||
if !exists {
|
||||
state, exists = record.Progress[int64(request.QuestID)]
|
||||
}
|
||||
if !exists {
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketSetQuestTrigger), 22)
|
||||
}
|
||||
result := dnfquest.PlanResult{
|
||||
QuestID: int64(request.QuestID), Known: true, StateChanged: false,
|
||||
Status: state.Status, TriggerType: state.TriggerType, ProgressValue: state.ProgressValue,
|
||||
RewardSelectIndex: state.RewardSelectIndex, Multiplier: state.Multiplier,
|
||||
}
|
||||
if err := s.sendCurrentSetQuestTriggerSuccess(session, request, result, "current_exe_op33_practice_authoritative_snapshot"); err != nil {
|
||||
return err
|
||||
}
|
||||
session.suppressCurrentSetQuestTriggerReplay(replayKey)
|
||||
return nil
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Character == nil || repositories.Quest == nil {
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"reason", "character_or_quest_repository_unavailable")
|
||||
return nil
|
||||
}
|
||||
owner, err := dnfquest.NewOwner(repositories)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
catalog, catalogErr := s.loadQuestCatalog(ctx)
|
||||
if catalogErr != nil {
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"reason", "quest_catalog_unavailable",
|
||||
"error", catalogErr)
|
||||
return nil
|
||||
}
|
||||
if request.TriggerType == 0 && !request.IsIncrement {
|
||||
linked, linkedErr := s.applyCurrentTownLinkedProgress(ctx, session, owner, catalog, request)
|
||||
if linkedErr != nil {
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-linked-progress-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"reason", "atomic_linked_progress_failed",
|
||||
"error", linkedErr)
|
||||
return nil
|
||||
}
|
||||
if linked.Applied {
|
||||
progress := linked.ParentProgress
|
||||
if linked.ParentQuestID != int64(request.QuestID) {
|
||||
progress = 0
|
||||
}
|
||||
result := dnfquest.PlanResult{
|
||||
QuestID: int64(request.QuestID), Known: true, StateChanged: true,
|
||||
Status: "active", TriggerType: request.TriggerType, ProgressValue: progress,
|
||||
}
|
||||
if err := s.sendCurrentSetQuestTriggerSuccess(
|
||||
session,
|
||||
request,
|
||||
result,
|
||||
"current_exe_op33_parent_after_atomic_linked_objective_archive",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sendCurrentLinkedObjectiveSnapshots(
|
||||
session,
|
||||
linked.PostCommitQuest,
|
||||
"current_exe_op33_linked_objective_after_atomic_archive",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-linked-progress-committed",
|
||||
"quest_id", request.QuestID,
|
||||
"parent_quest_id", linked.ParentQuestID,
|
||||
"parent_progress", linked.ParentProgress,
|
||||
"completed_quest_ids", linked.CompletedQuestIDs,
|
||||
"packet_order", "op33_op574_op356")
|
||||
if progress == 0 {
|
||||
session.suppressCurrentSetQuestTriggerReplay(replayKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result, err := owner.ApplySetTriggerWithCatalog(ctx, catalog, dnfquest.NewTriggerCommand(alignedcmd.Request{
|
||||
AccountID: s.accountIDForSession(session),
|
||||
SelectedCharacterID: session.selectedCharacterID,
|
||||
}, request))
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"trigger_type", request.TriggerType,
|
||||
"increment", request.IsIncrement,
|
||||
"reason", "quest_trigger_persist_failed",
|
||||
"error", err)
|
||||
return nil
|
||||
}
|
||||
if !result.Known || result.ProgressValue < 0 || result.ProgressValue > math.MaxUint32 {
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"known", result.Known,
|
||||
"status", result.Status,
|
||||
"progress", result.ProgressValue,
|
||||
"reason", "quest_trigger_result_not_acknowledgeable")
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketSetQuestTrigger), 22)
|
||||
}
|
||||
if !result.StateChanged {
|
||||
// Clear-map persistence can reach terminal zero before the current EXE
|
||||
// emits its first op33. Acknowledge that first request once so the
|
||||
// dungeon result handler can cross its transition boundary, without
|
||||
// publishing another op574 snapshot.
|
||||
if result.ProgressValue == 0 && result.Status == "active" {
|
||||
if err := s.sendCurrentSetQuestTriggerSuccess(
|
||||
session,
|
||||
request,
|
||||
result,
|
||||
"current_exe_op33_success_after_pre_persisted_active_zero",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
session.suppressCurrentSetQuestTriggerReplay(replayKey)
|
||||
return nil
|
||||
}
|
||||
if result.ProgressValue == 0 {
|
||||
session.suppressCurrentSetQuestTriggerReplay(replayKey)
|
||||
}
|
||||
s.logGameEvent(session, "game-upper-set-quest-trigger-noop-suppressed",
|
||||
"quest_id", request.QuestID,
|
||||
"trigger_type", request.TriggerType,
|
||||
"increment", request.IsIncrement,
|
||||
"trigger_value", result.ProgressValue,
|
||||
"status", result.Status,
|
||||
"reason", "persisted_trigger_state_unchanged_no_ack_or_op574")
|
||||
return nil
|
||||
}
|
||||
if err := s.sendCurrentSetQuestTriggerSuccess(
|
||||
session,
|
||||
request,
|
||||
result,
|
||||
"current_exe_op33_success_quest_and_trigger_after_durable_state_change",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// The current client enters its completion transition after an op33 zero.
|
||||
// Rebuilding the active list at this boundary feeds the same trigger back
|
||||
// into the client before that transition closes.
|
||||
if result.ProgressValue == 0 {
|
||||
session.suppressCurrentSetQuestTriggerReplay(replayKey)
|
||||
return nil
|
||||
}
|
||||
if err := s.sendCurrentActiveQuestSnapshotForSession(session, "current_exe_op33_after_nonterminal_durable_state_change_ack"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user