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

493 lines
19 KiB
Go

package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
var errDungeonClearMapCompletionUnavailable = errors.New("dnf dungeon clear-map completion owner is unavailable")
type currentDungeonClearMapCompletionAuthority uint8
const (
currentDungeonClearMapCompletionAuthorityBossDieCheck currentDungeonClearMapCompletionAuthority = iota + 1
currentDungeonClearMapCompletionAuthorityScriptedAltar
)
// persistCurrentDungeonClearMapCompletionPhaseA closes only the quest-row
// Phase-A barrier. It may move real, active PVF clear-map quests to trigger
// zero with a pending reward marker, but it never grants a reward, experience,
// SP, currency, or an item.
//
// The caller holds session.dungeon.mu. A successful no-match is still retained
// on the runtime so an op115 write retry cannot repeat repository work. The
// stable key and timestamp are allocated before the first repository attempt,
// which makes a failed-persistence replay deterministic as well.
func (s *Service) persistCurrentDungeonClearMapCompletionPhaseA(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
snapshot worldmap.DungeonSessionSnapshot,
) error {
return s.persistCurrentDungeonClearMapCompletionPhaseAWithAuthority(
ctx,
session,
runtime,
snapshot,
currentDungeonClearMapCompletionAuthorityBossDieCheck,
)
}
// persistCurrentDungeonScriptedAltarClearMapCompletionPhaseA uses the same
// durable quest owner as an ordinary clear, but its authority is the exhausted
// server-owned altar round table rather than a fabricated boss object/op117.
func (s *Service) persistCurrentDungeonScriptedAltarClearMapCompletionPhaseA(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
snapshot worldmap.DungeonSessionSnapshot,
) error {
return s.persistCurrentDungeonClearMapCompletionPhaseAWithAuthority(
ctx,
session,
runtime,
snapshot,
currentDungeonClearMapCompletionAuthorityScriptedAltar,
)
}
func (s *Service) persistCurrentDungeonClearMapCompletionPhaseAWithAuthority(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
snapshot worldmap.DungeonSessionSnapshot,
authority currentDungeonClearMapCompletionAuthority,
) error {
if session == nil || runtime == nil || runtime.Session == nil || session.dungeon.runtime != runtime {
return errDungeonClearMapCompletionUnavailable
}
if runtime.clearMapCompletionPhaseAPersisted {
return nil
}
authorityAccepted := false
receiptSource := "clear_map_phase_a"
eventSource := "authoritative_completed_op117_before_op115_op31"
switch authority {
case currentDungeonClearMapCompletionAuthorityBossDieCheck:
authorityAccepted = runtime.bossDieCheckAccepted
case currentDungeonClearMapCompletionAuthorityScriptedAltar:
authorityAccepted = currentDungeonScriptedAltarClearMapCompletionAccepted(runtime)
receiptSource = "blood_altar_scripted_clear_map_phase_a"
eventSource = "authoritative_scripted_blood_altar_round_completion"
default:
return errDungeonClearMapCompletionUnavailable
}
if !authorityAccepted || snapshot.Run.Status != worldmap.DungeonRunCompleted ||
snapshot.Run.DungeonID != runtime.Dungeon.ID || snapshot.Run.MazeIndex != runtime.MazeIndex ||
snapshot.Scene.Map.Map.ID <= 0 || !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return fmt.Errorf(
"%w: authority=%d accepted=%t status=%s runtime_dungeon=%d snapshot_dungeon=%d runtime_maze=%d snapshot_maze=%d map=%d",
errDungeonClearMapCompletionUnavailable,
authority,
authorityAccepted,
snapshot.Run.Status,
runtime.Dungeon.ID,
snapshot.Run.DungeonID,
runtime.MazeIndex,
snapshot.Run.MazeIndex,
snapshot.Scene.Map.Map.ID,
)
}
if ctx == nil {
ctx = context.Background()
}
if err := ensureCurrentDungeonCompletionReceiptKey(
session,
runtime,
snapshot,
s.gameplayNow(),
receiptSource,
); err != nil {
return err
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil || repositories.Quest == nil {
return errDungeonClearMapCompletionUnavailable
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
if _, found, err := repositories.Character.Load(ctx, characterID); err != nil {
return err
} else if !found {
return dnfquest.ErrCharacterNotFound
}
// An absent quest row means there is no persisted active quest to mutate.
// Avoid loading PVF or creating a synthetic row in that case.
if _, found, err := repositories.Quest.Load(ctx, characterID); err != nil {
return err
} else if !found {
runtime.clearMapCompletionQuestIDs = nil
runtime.clearMapCompletionCompletedQuestIDs = nil
runtime.clearMapCompletionPendingQuestIDs = nil
runtime.clearMapCompletionPhaseAPersisted = true
s.logGameEvent(session, "game-dungeon-clear-map-phase-a-persisted",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"map_id", snapshot.Scene.Map.Map.ID,
"completion_key", runtime.clearMapCompletionKey,
"completion_count", 0,
"changed_fields", []dnfrepo.QuestField(nil),
"quest_record_found", false,
"reward_granted", false,
"source", eventSource)
return nil
}
catalog, err := s.loadQuestCatalog(ctx)
if err != nil {
return fmt.Errorf("load clear-map quest catalog: %w", err)
}
owner, err := dnfquest.NewOwner(repositories)
if err != nil {
return fmt.Errorf("create clear-map quest owner: %w", err)
}
table, _, worldMapErr := s.dungeonWorldMap()
worldMapID := int64(-1)
if worldMapErr == nil {
if area, found := table.FindAreaByDungeon(runtime.Dungeon.ID); found {
worldMapID = area.ID
}
}
hellPartyComplete := runtime.HellParty != nil &&
runtime.HellParty.Runtime != nil &&
runtime.HellParty.Runtime.Phase() == dnfhellparty.PhaseComplete
allRoomsCleared := currentDungeonAllRoomsCleared(runtime.Session.Rooms(), snapshot.Run.Cleared)
// The authoritative boss-death phase runs before the settlement path calls
// CompleteCurrentRoom, so Run.Cleared normally does not yet contain the
// room whose boss just died. Include the current snapshot room for the
// "clear every room" condition; otherwise subtype-11 quests such as
// 调查卢克的聚光镜 and 通向巨人的路 can never complete even when the boss
// room is the only room left.
clearedRooms := currentDungeonCompletedRooms(snapshot.Run.Cleared, snapshot.Scene.Coordinate)
allRoomsCleared = currentDungeonAllRoomsCleared(runtime.Session.Rooms(), clearedRooms)
clearedRoomCount := int64(len(clearedRooms))
// 婚约任务(subtype 22)只认"正好这对夫妻、两人、普通副本"的通关。判定用副本
// owner 的入场名册(不是城镇队伍列表),并在首次权威通关时冻结,
// 避免先回城的一方把自己的状态改掉、让另一方的结果失效。
weddingCouple, err := s.currentWeddingDungeonCouple(ctx, runtime)
if err != nil {
return err
}
result, err := owner.ApplyClearMapCompletion(ctx, catalog, characterID, dnfquest.ClearMapCompletionInput{
WeddingCouple: weddingCouple,
DungeonID: runtime.Dungeon.ID,
MapID: snapshot.Scene.Map.Map.ID,
WorldMapID: worldMapID,
Difficulty: int64(runtime.Request.Difficulty),
HellPartyComplete: hellPartyComplete,
AllRoomsCleared: allRoomsCleared,
ClearedRoomCount: clearedRoomCount,
CompletionKey: runtime.clearMapCompletionKey,
CompletedAt: runtime.clearMapCompletionAt,
})
if err != nil {
return fmt.Errorf("persist clear-map quest phase A: %w", err)
}
// Phase A 只写任务状态(gain=0),但支援兵的持久效果仍要镜像过去:
// 否则"清图任务完成了、支援兵的觉醒/装备槽却没跟上"这种历史数据
// 永远修不好。事件键带 :phase-a 与后面的结算回执区分开。
if record, found, questErr := repositories.Quest.Load(ctx, characterID); questErr == nil && found {
s.syncCurrentDungeonCompanionProgression(
ctx, session, runtime.clearMapCompletionKey+":phase-a", nil, 0, &record,
)
}
questIDs := make([]int64, 0, len(result.Completions))
completedQuestIDs := make([]int64, 0, len(result.Completions))
for _, completion := range result.Completions {
questIDs = append(questIDs, completion.QuestID)
if completion.Completed {
completedQuestIDs = append(completedQuestIDs, completion.QuestID)
}
}
pendingQuestIDs := make([]int64, 0, len(result.Completions))
if len(result.Completions) != 0 {
if persisted, found, loadErr := repositories.Quest.Load(ctx, characterID); loadErr != nil {
return loadErr
} else if found {
pendingQuestIDs = currentDungeonClearMapPendingRewardQuestIDs(persisted, result.Completions)
}
}
runtime.clearMapCompletionQuestIDs = append(runtime.clearMapCompletionQuestIDs[:0], questIDs...)
runtime.clearMapCompletionCompletedQuestIDs = append(runtime.clearMapCompletionCompletedQuestIDs[:0], completedQuestIDs...)
runtime.clearMapCompletionPendingQuestIDs = append(runtime.clearMapCompletionPendingQuestIDs[:0], pendingQuestIDs...)
runtime.clearMapCompletionPhaseAPersisted = true
s.logGameEvent(session, "game-dungeon-clear-map-phase-a-persisted",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"map_id", snapshot.Scene.Map.Map.ID,
"completion_key", runtime.clearMapCompletionKey,
"completion_count", len(result.Completions),
"quest_ids", questIDs,
"completed_quest_ids", completedQuestIDs,
"worldmap_id", worldMapID,
"difficulty", runtime.Request.Difficulty,
"hell_party_complete", hellPartyComplete,
"all_rooms_cleared", allRoomsCleared,
"changed_fields", result.ChangedFields,
"idempotent", result.Idempotent,
"quest_record_found", true,
"reward_granted", false,
"source", eventSource)
return nil
}
func currentDungeonScriptedAltarClearMapCompletionAccepted(runtime *runtimeDungeonState) bool {
if runtime == nil || runtime.altar == nil || runtime.altar.progress != currentDungeonAltarProgressDungeonComplete ||
runtime.altar.completedRounds != runtime.altar.MaxRounds {
return false
}
return (runtime.Dungeon.ID == 11006 && runtime.altar.Kind == worldmap.BloodAltarDungeonKindEndless && runtime.altar.MaxRounds == 15) ||
(runtime.Dungeon.ID == 11007 && runtime.altar.Kind == worldmap.BloodAltarDungeonKindUltimate && runtime.altar.MaxRounds == 7)
}
func currentDungeonCompletedRooms(cleared []worldmap.RoomCoordinate, current worldmap.RoomCoordinate) []worldmap.RoomCoordinate {
out := append([]worldmap.RoomCoordinate(nil), cleared...)
for _, coordinate := range out {
if coordinate == current {
return out
}
}
return append(out, current)
}
func currentDungeonAllRoomsCleared(rooms []worldmap.DungeonRoom, cleared []worldmap.RoomCoordinate) bool {
if len(rooms) == 0 {
return false
}
clearedSet := make(map[worldmap.RoomCoordinate]struct{}, len(cleared))
for _, coordinate := range cleared {
clearedSet[coordinate] = struct{}{}
}
for _, room := range rooms {
if _, ok := clearedSet[room.Coordinate]; !ok {
return false
}
}
return true
}
func ensureCurrentDungeonCompletionReceiptKey(
session *gameSession,
runtime *runtimeDungeonState,
snapshot worldmap.DungeonSessionSnapshot,
now time.Time,
source string,
) error {
if session == nil || runtime == nil || runtime.Session == nil ||
snapshot.Run.DungeonID != runtime.Dungeon.ID ||
snapshot.Run.MazeIndex != runtime.MazeIndex ||
snapshot.Scene.Map.Map.ID <= 0 ||
!dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return fmt.Errorf(
"%w: source=%s runtime_dungeon=%d snapshot_dungeon=%d runtime_maze=%d snapshot_maze=%d map=%d",
errDungeonClearMapCompletionUnavailable,
source,
runtime.Dungeon.ID,
snapshot.Run.DungeonID,
runtime.MazeIndex,
snapshot.Run.MazeIndex,
snapshot.Scene.Map.Map.ID,
)
}
if runtime.clearMapCompletionAt.IsZero() {
runtime.clearMapCompletionAt = now.UTC()
}
if runtime.clearMapCompletionKey == "" {
runtime.clearMapCompletionKey = fmt.Sprintf(
"dungeon-clear:char:%d:dungeon:%d:maze:%d:map:%d:seed:%d:completed_at_unix_nano:%d:target:%d:source:%s",
session.selectedCharacterID,
runtime.Dungeon.ID,
runtime.MazeIndex,
snapshot.Scene.Map.Map.ID,
runtime.Seed,
runtime.clearMapCompletionAt.UnixNano(),
runtime.bossDieCheckTargetObjectKey,
source,
)
}
return nil
}
// sendCurrentDungeonClearMapCompletionNotificationLocked publishes the real
// post-persistence active-quest snapshot before settlement starts. The current
// EXE's class0/op0x023E reader rebuilds the active task group and has an
// explicit trigger-zero completion branch, so this is a state notification,
// not a fabricated FINISH_QUEST ACK or reward grant.
//
// The caller holds session.dungeon.mu. A failed repository verification or
// socket write leaves the barrier open so the exact op117 replay resumes at
// this stage without repeating Phase A.
func (s *Service) sendCurrentDungeonClearMapCompletionNotificationLocked(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
) error {
if session == nil || runtime == nil || session.dungeon.runtime != runtime ||
!runtime.clearMapCompletionPhaseAPersisted {
return errDungeonClearMapCompletionUnavailable
}
if runtime.clearMapCompletionNotificationClosed {
return nil
}
if len(runtime.clearMapCompletionQuestIDs) == 0 {
runtime.clearMapCompletionNotificationClosed = true
s.logGameEvent(session, "game-dungeon-clear-map-active-quest-snapshot-skipped",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"completion_key", runtime.clearMapCompletionKey,
"reason", "no_matching_persisted_clear_map_quest",
"reward_granted", false)
return nil
}
if ctx == nil {
ctx = context.Background()
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Quest == nil {
return errDungeonClearMapCompletionUnavailable
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
record, found, err := repositories.Quest.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return dnfquest.ErrQuestPersistVerify
}
rows := currentSelectAckQuestRows(record, true)
for _, questID := range runtime.clearMapCompletionPendingQuestIDs {
visible := false
for _, row := range rows {
if int64(row.questID) == questID && row.triggerValue == 0 {
visible = true
break
}
}
if !visible {
return fmt.Errorf("%w: pending-reward quest %d is absent from trigger-zero active snapshot", dnfquest.ErrQuestPersistVerify, questID)
}
state, ok := currentDungeonClearMapQuestState(record, questID)
if !ok || state.ProgressValue != 0 || state.Extra["completion_key"] != runtime.clearMapCompletionKey ||
state.Extra["reward_state"] != "pending" {
return fmt.Errorf("%w: quest %d pending-reward marker mismatch", dnfquest.ErrQuestPersistVerify, questID)
}
}
body := buildCurrentActiveQuestSnapshotBody(record, true)
if !runtime.clearMapCompletionActiveSnapshotSent {
s.logGameEvent(session, "game-dungeon-clear-map-active-quest-snapshot-send",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"map_id", runtime.Session.Snapshot().Scene.Map.Map.ID,
"completion_key", runtime.clearMapCompletionKey,
"quest_ids", append([]int64(nil), runtime.clearMapCompletionQuestIDs...),
"pending_reward_quest_ids", append([]int64(nil), runtime.clearMapCompletionPendingQuestIDs...),
"active_snapshot_rows", len(rows),
"msg_id", currentActiveQuestSnapshotMsgID,
"classification", 0,
"body_len", len(body),
"ordering", "phase_a_persisted_then_op574_before_optional_completed_op21_and_op115_op31",
"reward_granted", false,
"body_source", "current_exe_sub_1D632D0_u32_count_u16_quest_u32_trigger")
if err := s.sendGameUpperRawClass(session, currentActiveQuestSnapshotMsgID, body, 0); err != nil {
return err
}
runtime.clearMapCompletionActiveSnapshotSent = true
}
if currentDungeonClearMapRequiresAcceptableQuestRefresh(
runtime.clearMapCompletionCompletedQuestIDs,
runtime.clearMapCompletionPendingQuestIDs,
) {
s.logGameEvent(session, "game-dungeon-clear-map-acceptable-quest-refresh-send",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"map_id", runtime.Session.Snapshot().Scene.Map.Map.ID,
"completion_key", runtime.clearMapCompletionKey,
"quest_ids", append([]int64(nil), runtime.clearMapCompletionQuestIDs...),
"pending_reward_quest_ids", append([]int64(nil), runtime.clearMapCompletionPendingQuestIDs...),
"msg_id", currentAcceptableQuestListMsgID,
"classification", 0,
"ordering", "completed_no_reward_quest_persisted_then_op574_then_op21_before_op115_op31",
"body_source", "s4a12_completion_projection_timing_with_current_exe_op21_builder")
if err := s.sendCurrentAcceptableQuestListOnlyForSession(
session,
"dungeon_clear_map_completed_no_reward_quest_after_active_snapshot_before_settlement",
); err != nil {
return err
}
}
runtime.clearMapCompletionNotificationClosed = true
return nil
}
func currentDungeonClearMapRequiresAcceptableQuestRefresh(completedQuestIDs, pendingRewardQuestIDs []int64) bool {
if len(completedQuestIDs) == 0 {
return false
}
pending := make(map[int64]struct{}, len(pendingRewardQuestIDs))
for _, questID := range pendingRewardQuestIDs {
pending[questID] = struct{}{}
}
for _, questID := range completedQuestIDs {
if _, stillPending := pending[questID]; !stillPending {
return true
}
}
return false
}
func currentDungeonClearMapPendingRewardQuestIDs(record dnfrepo.QuestRecord, completions []dnfquest.ClearMapCompletion) []int64 {
if len(completions) == 0 {
return nil
}
pending := make([]int64, 0, len(completions))
for _, completion := range completions {
state, ok := currentDungeonClearMapQuestState(record, completion.QuestID)
if !ok || state.ProgressValue != 0 || state.Extra == nil {
continue
}
if state.Extra["reward_state"] == "pending" && stringsEqualFoldTrim(state.Status, "active") {
pending = append(pending, completion.QuestID)
}
}
return pending
}
func currentDungeonClearMapQuestState(record dnfrepo.QuestRecord, questID int64) (dnfrepo.QuestState, bool) {
if state, ok := record.States[questID]; ok {
return state, true
}
if state, ok := record.Progress[questID]; ok {
return state, true
}
return dnfrepo.QuestState{}, false
}
func stringsEqualFoldTrim(left, right string) bool {
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right))
}