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,402 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"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 currentAcceptQuestRequestBodySize = 4
|
||||
|
||||
// currentAcceptQuestReplayKey identifies an already-acknowledged active
|
||||
// quest in one TCP session. A quest cannot be accepted twice while active, so
|
||||
// no later player action can legitimately need another same-session accept
|
||||
// ACK for this key.
|
||||
type currentAcceptQuestReplayKey struct {
|
||||
characterID uint16
|
||||
questID uint16
|
||||
}
|
||||
|
||||
func newCurrentAcceptQuestReplayKey(characterID uint16, request dnfquest.QuestIDRequest) currentAcceptQuestReplayKey {
|
||||
return currentAcceptQuestReplayKey{characterID: characterID, questID: request.QuestID}
|
||||
}
|
||||
|
||||
func (session *gameSession) currentAcceptQuestReplaySuppressed(key currentAcceptQuestReplayKey) bool {
|
||||
if session == nil {
|
||||
return false
|
||||
}
|
||||
session.questReplay.acceptMu.Lock()
|
||||
defer session.questReplay.acceptMu.Unlock()
|
||||
_, ok := session.questReplay.acceptAcknowledged[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (session *gameSession) suppressCurrentAcceptQuestReplay(key currentAcceptQuestReplayKey) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.questReplay.acceptMu.Lock()
|
||||
defer session.questReplay.acceptMu.Unlock()
|
||||
if session.questReplay.acceptAcknowledged == nil {
|
||||
session.questReplay.acceptAcknowledged = make(map[currentAcceptQuestReplayKey]struct{}, 1)
|
||||
}
|
||||
session.questReplay.acceptAcknowledged[key] = struct{}{}
|
||||
}
|
||||
|
||||
func (session *gameSession) clearCurrentAcceptQuestReplay(key currentAcceptQuestReplayKey) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.questReplay.acceptMu.Lock()
|
||||
defer session.questReplay.acceptMu.Unlock()
|
||||
delete(session.questReplay.acceptAcknowledged, key)
|
||||
}
|
||||
|
||||
// shouldSuppressKnownQuestReplayBeforeGameUpperLog avoids logging an already
|
||||
// acknowledged terminal quest packet at retry-loop speed. It accepts only the
|
||||
// exact current plaintext request shapes; malformed or changed requests keep
|
||||
// their normal logging and validation path.
|
||||
func shouldSuppressKnownQuestReplayBeforeGameUpperLog(session *gameSession, msgID uint16, classification byte, body []byte) bool {
|
||||
if session == nil || classification != dnfproto.DefaultChannelClassification {
|
||||
return false
|
||||
}
|
||||
switch msgID {
|
||||
case uint16(dnfenum.CmdPacketAcceptQuest):
|
||||
if len(body) != currentAcceptQuestRequestBodySize ||
|
||||
binary.LittleEndian.Uint16(body[:2]) != uint16(dnfenum.CmdPacketAcceptQuest) {
|
||||
return false
|
||||
}
|
||||
request, err := dnfquest.DecodeQuestIDRequest(body)
|
||||
return err == nil && session.currentAcceptQuestReplaySuppressed(newCurrentAcceptQuestReplayKey(session.selectedCharacterID, request))
|
||||
case uint16(dnfenum.CmdPacketGiveupQuest):
|
||||
if len(body) != currentGiveUpQuestRequestBodySize ||
|
||||
binary.LittleEndian.Uint16(body[:2]) != uint16(dnfenum.CmdPacketGiveupQuest) {
|
||||
return false
|
||||
}
|
||||
request, err := dnfquest.DecodeQuestIDRequest(body)
|
||||
return err == nil && session.currentGiveUpQuestReplaySuppressed(newCurrentGiveUpQuestReplayKey(session.selectedCharacterID, request))
|
||||
case uint16(dnfenum.CmdPacketSetQuestTrigger):
|
||||
if !currentSetQuestTriggerRequestBodyValid(body) {
|
||||
return false
|
||||
}
|
||||
request, err := dnfquest.DecodeSetTriggerRequest(body)
|
||||
return err == nil && session.currentSetQuestTriggerReplaySuppressed(newCurrentSetQuestTriggerReplayKey(session.selectedCharacterID, request))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// activateAutoNextQuestDirect activates a direct PVF story successor without
|
||||
// going through the op31 scene-initialization suppression. The caller is
|
||||
// already past the completed-dungeon settlement, so this is a server-driven
|
||||
// continuation, not a login-time passive quest echo.
|
||||
func (s *Service) activateAutoNextQuestDirect(ctx context.Context, session *gameSession, questID uint32) error {
|
||||
if s == nil || session == nil || session.selectedCharacterID == 0 || questID == 0 {
|
||||
return nil
|
||||
}
|
||||
catalog, err := s.loadQuestCatalog(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load quest catalog: %w", err)
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Character == nil || repositories.Quest == nil {
|
||||
return fmt.Errorf("quest repositories are unavailable")
|
||||
}
|
||||
_, _, character, found := s.selectedCharacterForEnter(ctx, session)
|
||||
if !found {
|
||||
return fmt.Errorf("selected character %d is unavailable", session.selectedCharacterID)
|
||||
}
|
||||
job, validJob := characterJobByte(character)
|
||||
if !validJob || character.Level <= 0 {
|
||||
return fmt.Errorf("selected character job/level is invalid")
|
||||
}
|
||||
owner, err := dnfquest.NewOwner(repositories)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create quest owner: %w", err)
|
||||
}
|
||||
result, err := owner.ApplyAccept(ctx, catalog, dnfquest.CharacterEligibility{
|
||||
Level: character.Level,
|
||||
Job: int(job),
|
||||
GrowType: int(numericCharacterStatValue(character, "grow_type")),
|
||||
}, dnfquest.NewQuestIDCommand(alignedcmd.Request{
|
||||
AccountID: s.accountIDForSession(session),
|
||||
SelectedCharacterID: session.selectedCharacterID,
|
||||
}, "auto_next_story_accept", dnfquest.QuestIDRequest{QuestID: uint16(questID)}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("accept quest %d: %w", questID, err)
|
||||
}
|
||||
ackBody, err := buildCurrentAcceptQuestSuccessBody(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build quest accept ack: %w", err)
|
||||
}
|
||||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketAcceptQuest), ackBody); err != nil {
|
||||
return fmt.Errorf("send quest accept ack: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleCurrentAcceptQuest(session *gameSession, body []byte) error { if session == nil || session.selectedCharacterID == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(body) != currentAcceptQuestRequestBodySize || binary.LittleEndian.Uint16(body[:2]) != uint16(dnfenum.CmdPacketAcceptQuest) {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"body_len", len(body),
|
||||
"reason", "current_exe_op31_requires_exact_echo_and_quest_id")
|
||||
return nil
|
||||
}
|
||||
request, err := dnfquest.DecodeQuestIDRequest(body)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"body_len", len(body),
|
||||
"reason", "request_decode_failed",
|
||||
"error", err)
|
||||
return nil
|
||||
}
|
||||
replayKey := newCurrentAcceptQuestReplayKey(session.selectedCharacterID, request)
|
||||
if session.currentAcceptQuestReplaySuppressed(replayKey) {
|
||||
return nil
|
||||
}
|
||||
if suppressed, readiness, routeStage := s.suppressSceneInitializationAcceptQuestRequest(session); suppressed {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-scene-initialization-request-suppressed",
|
||||
"quest_id", request.QuestID,
|
||||
"route_stage", routeStage,
|
||||
"scene_readiness", readiness,
|
||||
"reason", "login_or_scene_initialization_trigger_is_not_player_accept_intent",
|
||||
"response", "none",
|
||||
"quest_mutation", "none",
|
||||
"preserved_scope", "player_accept_after_scene_actor_ready")
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
catalog, err := s.loadQuestCatalog(ctx)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"reason", "quest_catalog_unavailable",
|
||||
"error", err)
|
||||
return nil
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Character == nil || repositories.Quest == nil {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"reason", "character_or_quest_repository_unavailable")
|
||||
return nil
|
||||
}
|
||||
_, _, character, found := s.selectedCharacterForEnter(ctx, session)
|
||||
if !found {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"reason", "selected_character_not_found")
|
||||
return nil
|
||||
}
|
||||
job, validJob := characterJobByte(character)
|
||||
if !validJob || character.Level <= 0 {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"job", character.Job,
|
||||
"level", character.Level,
|
||||
"reason", "selected_character_job_or_level_invalid")
|
||||
return nil
|
||||
}
|
||||
owner, err := dnfquest.NewOwner(repositories)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create quest owner: %w", err)
|
||||
}
|
||||
var allocateItem dnfquest.FinishItemAllocator
|
||||
if definition, known := catalog.Find(int64(request.QuestID)); known && definition.HasDependGiveItem {
|
||||
itemCatalog, catalogErr := s.currentPVFItemCatalog()
|
||||
if catalogErr != nil {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"reason", "pvf_event_item_catalog_unavailable",
|
||||
"error", catalogErr)
|
||||
return nil
|
||||
}
|
||||
mainSlotCount := uint16(0)
|
||||
if repositories.Settings != nil {
|
||||
containerState := s.loadCurrentItemListContainerState(ctx, repositories.Settings, character.CharacterID, dnfrepo.MainInventoryListType)
|
||||
mainSlotCount = containerState.MainSlotCount
|
||||
}
|
||||
allocateItem = currentQuestFinishItemAllocator(itemCatalog, mainSlotCount)
|
||||
}
|
||||
result, err := owner.ApplyAcceptWithItems(ctx, catalog, dnfquest.CharacterEligibility{
|
||||
Level: character.Level,
|
||||
Job: int(job),
|
||||
GrowType: int(numericCharacterStatValue(character, "grow_type")),
|
||||
}, dnfquest.NewQuestIDCommand(alignedcmd.Request{
|
||||
AccountID: s.accountIDForSession(session),
|
||||
SelectedCharacterID: session.selectedCharacterID,
|
||||
}, "accept_quest", request), allocateItem)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-upper-accept-quest-blocked",
|
||||
"quest_id", request.QuestID,
|
||||
"job", job,
|
||||
"level", character.Level,
|
||||
"reason", "pvf_db_accept_preflight_or_persist_failed",
|
||||
"error", err)
|
||||
if errors.Is(err, dnfquest.ErrQuestNotAcceptable) {
|
||||
// The current client can repeat a passive op31 for a quest that was
|
||||
// already activated/completed by the PVF quest graph. Returning
|
||||
// current failure 23 for every such repeat opens the unsolicited
|
||||
// "cannot accept quest" popup after login. Keep this exact state
|
||||
// mismatch silent: it performs no mutation and does not disable the
|
||||
// ordinary eligible, player-driven accept path below.
|
||||
s.logGameEvent(session, "game-upper-accept-quest-passive-repeat-suppressed",
|
||||
"quest_id", request.QuestID,
|
||||
"job", job,
|
||||
"level", character.Level,
|
||||
"response", "none",
|
||||
"quest_mutation", "none",
|
||||
"reason", "known_quest_not_currently_acceptable_passive_client_repeat")
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, dnfquest.ErrQuestDefinitionMissing) ||
|
||||
errors.Is(err, dnfquest.ErrQuestInitialTriggerUnsupported) {
|
||||
// Current EXE sub_1D29B40 consumes no op31-specific failure
|
||||
// fields. Error 23 selects the current "cannot accept quest"
|
||||
// client resource. Repository/transaction gaps do not use this
|
||||
// business error because their state is not closed.
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAcceptQuest), 23)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !result.Idempotent {
|
||||
// ApplyAccept has committed a new active generation. Drop only this
|
||||
// character/quest's terminal op33 keys before any response write so a
|
||||
// retryable quest cannot inherit a stale same-session feedback-loop gate.
|
||||
session.clearCurrentSetQuestTriggerReplayForQuest(session.selectedCharacterID, result.QuestID)
|
||||
}
|
||||
if result.Idempotent {
|
||||
// Current NoPack's class1/op31 success reader sub_1D29B40 consumes the
|
||||
// same u16 quest + u32 trigger + u8 item-count body for a persisted
|
||||
// active quest. ACK it once with the DB state, but do not append op574:
|
||||
// a fresh snapshot here is the feedback loop observed in live traffic.
|
||||
body, buildErr := buildCurrentAcceptQuestSuccessBody(result)
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
s.logGameEvent(session, "game-upper-accept-quest-idempotent-ack-send",
|
||||
"quest_id", result.QuestID,
|
||||
"init_trigger", result.InitTrigger,
|
||||
"pvf_path", result.PVFPath,
|
||||
"quest_type", result.QuestType,
|
||||
"msg_id", uint16(dnfenum.CmdPacketAcceptQuest),
|
||||
"classification", dnfproto.DefaultChannelClassification,
|
||||
"plain_body_len", len(body)+1,
|
||||
"response", "current_exe_op31_success_no_op574",
|
||||
"quest_mutation", "none",
|
||||
"body_source", "current_exe_sub_1D29B40_persisted_active_quest_state")
|
||||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketAcceptQuest), body); err != nil {
|
||||
return err
|
||||
}
|
||||
session.clearCurrentGiveUpQuestReplay(newCurrentGiveUpQuestReplayKey(session.selectedCharacterID, request))
|
||||
session.suppressCurrentAcceptQuestReplay(replayKey)
|
||||
return nil
|
||||
}
|
||||
ackBody, err := buildCurrentAcceptQuestSuccessBody(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logGameEvent(session, "game-upper-accept-quest-success-send",
|
||||
"quest_id", result.QuestID,
|
||||
"init_trigger", result.InitTrigger,
|
||||
"event_item_count", len(result.EventItems),
|
||||
"idempotent", result.Idempotent,
|
||||
"pvf_path", result.PVFPath,
|
||||
"quest_type", result.QuestType,
|
||||
"msg_id", uint16(dnfenum.CmdPacketAcceptQuest),
|
||||
"classification", dnfproto.DefaultChannelClassification,
|
||||
"plain_body_len", len(ackBody)+1,
|
||||
"body_source", "current_exe_sub_1D29B40_and_pvf_db_accept_owner")
|
||||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketAcceptQuest), ackBody); err != nil {
|
||||
return err
|
||||
}
|
||||
// The op31 ACK activates the quest model, while op574 replaces only the
|
||||
// active trigger rows. The current client's route guide and dungeon-task
|
||||
// marker are rebuilt from the all-task op21 definition list, and the
|
||||
// completed-quest bitmap (op356) is part of its task/NPC visibility model.
|
||||
// A successor recovered by task-manual bulk completion or a level threshold
|
||||
// has never owned an active HUD generation: with op574 alone it is durable
|
||||
// and visible but has no route guidance until character re-selection runs
|
||||
// the full login snapshots. Publish the same authoritative order the
|
||||
// post-town-transition projection already uses: op21, then op574, then
|
||||
// op356. This is projection-only and never mutates quest persistence.
|
||||
s.logGameEvent(session, "game-upper-accept-quest-task-guide-refresh-send",
|
||||
"quest_id", result.QuestID,
|
||||
"sequence", "op31_then_op21_then_op574_then_optional_op356",
|
||||
"reason", "newly_active_quest_requires_fresh_route_guide_owner_and_completed_visibility")
|
||||
if err := s.sendCurrentAcceptableQuestListOnlyForSession(session, "current_exe_op31_after_persisted_success_ack_task_guide"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sendCurrentActiveQuestSnapshotForSession(session, "current_exe_op31_after_acceptable_op21"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.sendCurrentPersistedClearQuestListIfCompleted(
|
||||
ctx,
|
||||
session,
|
||||
repositories,
|
||||
"current_exe_op31_after_task_snapshots_completed_visibility",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
session.clearCurrentGiveUpQuestReplay(newCurrentGiveUpQuestReplayKey(session.selectedCharacterID, request))
|
||||
session.suppressCurrentAcceptQuestReplay(replayKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCurrentAcceptQuestSuccessBody(result dnfquest.AcceptResult) ([]byte, error) {
|
||||
if result.QuestID == 0 || len(result.EventItems) > math.MaxUint8 {
|
||||
return nil, fmt.Errorf("current EXE accept-quest ACK shape is invalid")
|
||||
}
|
||||
var payload packetWriter
|
||||
payload.writeUint16(result.QuestID)
|
||||
payload.writeUint32(result.InitTrigger)
|
||||
payload.writeByte(byte(len(result.EventItems)))
|
||||
for _, item := range result.EventItems {
|
||||
if item.ItemID <= 0 || item.ItemID > math.MaxUint32 || item.CountOrSeed == 0 {
|
||||
return nil, fmt.Errorf("current EXE accept-quest event item is invalid: item=%d", item.ItemID)
|
||||
}
|
||||
payload.writeUint16(item.SlotIndex)
|
||||
payload.writeUint32(uint32(item.ItemID))
|
||||
payload.writeUint32(item.CountOrSeed)
|
||||
}
|
||||
return payload.bytes(), nil
|
||||
}
|
||||
|
||||
// suppressSceneInitializationAcceptQuestRequest keeps quest acceptance
|
||||
// reactive to player input. An op31 emitted while login/scene initialization
|
||||
// is still constructing the selected actor is a passive client-side effect of
|
||||
// that initialization, not evidence of a click. This covers cold first-login
|
||||
// and tutorial/dungeon routes as well as the ordinary initial-town route. The
|
||||
// request receives no response and cannot mutate quest state. Once the actor is
|
||||
// fully ready, the normal player-driven op31 path remains available.
|
||||
func (s *Service) suppressSceneInitializationAcceptQuestRequest(session *gameSession) (bool, string, currentInitialTownRouteStage) {
|
||||
if session == nil {
|
||||
return false, "session_missing", currentInitialTownRouteIdle
|
||||
}
|
||||
ready, readiness := s.currentSceneActorReadyForState(session)
|
||||
session.townMu.Lock()
|
||||
stage := session.initialTownRouteStage
|
||||
townSceneReady := session.townSceneReadyCharacterID != 0 &&
|
||||
session.townSceneReadyCharacterID == session.selectedCharacterID
|
||||
session.townMu.Unlock()
|
||||
// The one-shot initial-town route is intentionally reset to idle after a
|
||||
// normal area movement or a completed-dungeon return. Its dedicated town
|
||||
// readiness marker still proves that the selected actor was fully built.
|
||||
// Treating op31 in that state as initialization noise silently discarded
|
||||
// genuine task-manual clicks.
|
||||
if townSceneReady {
|
||||
return false, "town_scene_player_state_finalized", stage
|
||||
}
|
||||
return session.selectedCharacterID != 0 && !ready, readiness, stage
|
||||
}
|
||||
Reference in new issue
Block a user