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

417 lines
14 KiB
Go

package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"time"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
var errDungeonHuntEnemyCompletionUnavailable = errors.New("dnf dungeon hunt-enemy completion owner is unavailable")
type currentDungeonHuntEnemyPersistencePlan struct {
sequence uint64
characterID uint16
characterGeneration uint64
characterKey string
dungeonID int64
difficulty int64
mazeIndex int
mapID int64
seed uint32
enemyCode int64
enemyType int64
objectKey uint32
completedAt time.Time
completionKey string
source string
repositories dnfrepo.Group
}
type currentDungeonHuntEnemyPersistenceResult struct {
persisted dnfquest.HuntEnemyPersistResult
record dnfrepo.QuestRecord
body []byte
elapsed time.Duration
err error
}
// persistCurrentDungeonHuntEnemyKillPhaseA mirrors 86JP's kill-trigger domain
// path for [hunt enemy]. It only updates the quest trigger/pending marker. Item
// and EXP rewards remain behind the normal FinishQuest atomic owner.
func (s *Service) persistCurrentDungeonHuntEnemyKillPhaseA(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
monster runtimeDungeonMonster,
objectKey uint32,
) error {
if session == nil || runtime == nil || runtime.Session == nil || session.dungeon.runtime != runtime ||
monster.Spawn.MonsterID <= 0 || !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return errDungeonHuntEnemyCompletionUnavailable
}
if currentDungeonRuntimeIsPractice(runtime) {
return nil
}
return s.persistCurrentDungeonHuntEnemyTargetPhaseA(
ctx,
session,
runtime,
scene,
monster.Spawn.MonsterID,
currentDungeonQuestEnemyTypeForMonster(monster),
objectKey,
"runtime_monster_op39",
)
}
// persistCurrentDungeonHuntMonsterKillPhaseA is the sibling path for PVF
// [hunt monster] quests. Earlier code initialized those quest triggers but
// never consumed ordinary monster deaths, which left targets such as 巨枪多里安
// stuck at 0/1 forever.
func (s *Service) persistCurrentDungeonHuntMonsterKillPhaseA(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
monster runtimeDungeonMonster,
objectKey uint32,
) error {
if session == nil || runtime == nil || runtime.Session == nil || session.dungeon.runtime != runtime ||
monster.Spawn.MonsterID <= 0 || !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return errDungeonHuntEnemyCompletionUnavailable
}
if currentDungeonRuntimeIsPractice(runtime) {
return nil
}
if ctx == nil {
ctx = context.Background()
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil || repositories.Quest == nil {
return errDungeonHuntEnemyCompletionUnavailable
}
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
}
if _, found, err := repositories.Quest.Load(ctx, characterID); err != nil {
return err
} else if !found {
return nil
}
catalog, err := s.loadQuestCatalog(ctx)
if err != nil {
return fmt.Errorf("load hunt-monster quest catalog: %w", err)
}
owner, err := dnfquest.NewOwner(repositories)
if err != nil {
return fmt.Errorf("create hunt-monster quest owner: %w", err)
}
completedAt := s.gameplayNow().UTC()
completionKey := fmt.Sprintf(
"op39:char:%d:dungeon:%d:maze:%d:map:%d:seed:%d:monster:%d:object:%d:completed_at_unix_nano:%d",
session.selectedCharacterID,
runtime.Dungeon.ID,
runtime.MazeIndex,
scene.Map.Map.ID,
runtime.Seed,
monster.Spawn.MonsterID,
objectKey,
completedAt.UnixNano(),
)
result, err := owner.ApplyHuntMonsterKill(ctx, catalog, characterID, dnfquest.HuntMonsterKillInput{
DungeonID: runtime.Dungeon.ID,
Difficulty: int64(runtime.Request.Difficulty),
MonsterCode: monster.Spawn.MonsterID,
CompletionKey: completionKey,
CompletedAt: completedAt,
})
if err != nil {
return fmt.Errorf("persist hunt-monster quest phase A: %w", err)
}
if len(result.ChangedFields) == 0 && len(result.Completions) == 0 {
return nil
}
record, found, err := repositories.Quest.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return dnfquest.ErrQuestPersistVerify
}
body := buildCurrentActiveQuestSnapshotBody(record, true)
questIDs := make([]int64, 0, len(result.Completions))
for _, completion := range result.Completions {
questIDs = append(questIDs, completion.QuestID)
}
s.logGameEvent(session, "game-dungeon-hunt-monster-phase-a-persisted",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"map_id", scene.Map.Map.ID,
"monster_id", monster.Spawn.MonsterID,
"monster_object_key", objectKey,
"completion_key", completionKey,
"completion_count", len(result.Completions),
"quest_ids", questIDs,
"changed_fields", result.ChangedFields,
"idempotent", result.Idempotent,
"reward_granted", currentDungeonHuntMonsterResultAutoGranted(record, result),
"snapshot_msg_id", currentActiveQuestSnapshotMsgID,
"snapshot_body_len", len(body),
"source", "runtime_monster_op39")
return s.sendGameUpperRawClass(session, currentActiveQuestSnapshotMsgID, body, 0)
}
func (s *Service) persistCurrentDungeonHuntEnemyTargetPhaseA(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
enemyCode int64,
enemyType int64,
objectKey uint32,
source string,
) error {
if session == nil || runtime == nil || runtime.Session == nil || session.dungeon.runtime != runtime ||
enemyCode <= 0 || enemyType <= 0 || !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return errDungeonHuntEnemyCompletionUnavailable
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil || repositories.Quest == nil {
return errDungeonHuntEnemyCompletionUnavailable
}
completedAt := s.gameplayNow().UTC()
plan := currentDungeonHuntEnemyPersistencePlan{
characterID: session.selectedCharacterID,
characterGeneration: session.characterGeneration,
characterKey: strconv.FormatUint(uint64(session.selectedCharacterID), 10),
dungeonID: runtime.Dungeon.ID,
difficulty: int64(runtime.Request.Difficulty),
mazeIndex: runtime.MazeIndex,
mapID: scene.Map.Map.ID,
seed: runtime.Seed,
enemyCode: enemyCode,
enemyType: enemyType,
objectKey: objectKey,
completedAt: completedAt,
source: source,
repositories: repositories,
}
plan.completionKey = fmt.Sprintf(
"op39:char:%d:dungeon:%d:maze:%d:map:%d:seed:%d:enemy:%d:object:%d:completed_at_unix_nano:%d",
plan.characterID, plan.dungeonID, plan.mazeIndex, plan.mapID, plan.seed,
plan.enemyCode, plan.objectKey,
completedAt.UnixNano(),
)
plan.sequence = reserveCurrentDungeonQuestProgress(session)
if session.events != nil && session.events.loop != nil && session.events.active.Load() {
go s.runCurrentDungeonHuntEnemyPersistence(session, plan)
return nil
}
var runErr error
runCurrentDungeonQuestProgressOrdered(session, plan.sequence, func() {
if ctx == nil {
ctx = context.Background()
}
result := s.executeCurrentDungeonHuntEnemyPersistence(ctx, plan)
if result.err != nil {
runErr = result.err
return
}
runErr = s.completeCurrentDungeonHuntEnemyPersistence(session, plan, result)
})
return runErr
}
func (s *Service) runCurrentDungeonHuntEnemyPersistence(
session *gameSession,
plan currentDungeonHuntEnemyPersistencePlan,
) {
runCurrentDungeonQuestProgressOrdered(session, plan.sequence, func() {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
result := s.executeCurrentDungeonHuntEnemyPersistence(ctx, plan)
cancel()
if result.err != nil {
s.logCurrentDungeonHuntEnemyPersistenceFailure(session, plan, result)
return
}
if len(result.body) == 0 {
return
}
err := s.postGameSessionCharacterEvent(
session,
"dungeon-hunt-enemy-persistence-complete",
plan.characterID,
plan.characterGeneration,
func() error { return s.completeCurrentDungeonHuntEnemyPersistence(session, plan, result) },
)
if err != nil && !isClosedGameSessionEventError(err) {
s.logPacketEvent("game-session-event-submit-failed",
"conn_id", session.connID,
"source", "dungeon-hunt-enemy-persistence-complete",
"char_id", plan.characterID,
"quest_progress_sequence", plan.sequence,
"error", err)
}
})
}
func (s *Service) executeCurrentDungeonHuntEnemyPersistence(
ctx context.Context,
plan currentDungeonHuntEnemyPersistencePlan,
) (result currentDungeonHuntEnemyPersistenceResult) {
started := time.Now()
defer func() { result.elapsed = time.Since(started) }()
if _, found, err := plan.repositories.Character.Load(ctx, plan.characterKey); err != nil {
result.err = err
return result
} else if !found {
result.err = dnfquest.ErrCharacterNotFound
return result
}
if _, found, err := plan.repositories.Quest.Load(ctx, plan.characterKey); err != nil {
result.err = err
return result
} else if !found {
return result
}
catalog, err := s.loadQuestCatalog(ctx)
if err != nil {
result.err = fmt.Errorf("load hunt-enemy quest catalog: %w", err)
return result
}
owner, err := dnfquest.NewOwner(plan.repositories)
if err != nil {
result.err = fmt.Errorf("create hunt-enemy quest owner: %w", err)
return result
}
result.persisted, err = owner.ApplyHuntEnemyKill(ctx, catalog, plan.characterKey, dnfquest.HuntEnemyKillInput{
DungeonID: plan.dungeonID,
Difficulty: plan.difficulty,
EnemyCode: plan.enemyCode,
EnemyType: plan.enemyType,
CompletionKey: plan.completionKey,
CompletedAt: plan.completedAt,
})
if err != nil {
result.err = fmt.Errorf("persist hunt-enemy quest phase A: %w", err)
return result
}
if len(result.persisted.ChangedFields) == 0 && len(result.persisted.Completions) == 0 {
return result
}
var found bool
result.record, found, err = plan.repositories.Quest.Load(ctx, plan.characterKey)
if err != nil {
result.err = err
return result
}
if !found {
result.err = dnfquest.ErrQuestPersistVerify
return result
}
result.body = buildCurrentActiveQuestSnapshotBody(result.record, true)
return result
}
func (s *Service) completeCurrentDungeonHuntEnemyPersistence(
session *gameSession,
plan currentDungeonHuntEnemyPersistencePlan,
result currentDungeonHuntEnemyPersistenceResult,
) error {
if len(result.body) == 0 {
return nil
}
questIDs := make([]int64, 0, len(result.persisted.Completions))
for _, completion := range result.persisted.Completions {
questIDs = append(questIDs, completion.QuestID)
}
s.logGameEvent(session, "game-dungeon-hunt-enemy-phase-a-persisted",
"char_id", plan.characterID,
"dungeon_id", plan.dungeonID,
"maze_index", plan.mazeIndex,
"map_id", plan.mapID,
"enemy_code", plan.enemyCode,
"monster_object_key", plan.objectKey,
"enemy_type", plan.enemyType,
"completion_key", plan.completionKey,
"completion_count", len(result.persisted.Completions),
"quest_ids", questIDs,
"changed_fields", result.persisted.ChangedFields,
"idempotent", result.persisted.Idempotent,
"reward_granted", currentDungeonHuntEnemyResultAutoGranted(result.record, result.persisted),
"snapshot_msg_id", currentActiveQuestSnapshotMsgID,
"snapshot_body_len", len(result.body),
"quest_progress_sequence", plan.sequence,
"elapsed_ms", result.elapsed.Milliseconds(),
"source", plan.source)
return s.sendGameUpperRawClass(session, currentActiveQuestSnapshotMsgID, result.body, 0)
}
func (s *Service) logCurrentDungeonHuntEnemyPersistenceFailure(
session *gameSession,
plan currentDungeonHuntEnemyPersistencePlan,
result currentDungeonHuntEnemyPersistenceResult,
) {
s.logGameEvent(session, "game-dungeon-hunt-enemy-progress-deferred",
"char_id", plan.characterID,
"dungeon_id", plan.dungeonID,
"maze_index", plan.mazeIndex,
"map_id", plan.mapID,
"enemy_code", plan.enemyCode,
"enemy_type", plan.enemyType,
"monster_object_key", plan.objectKey,
"quest_progress_sequence", plan.sequence,
"elapsed_ms", result.elapsed.Milliseconds(),
"error", result.err,
"source", plan.source)
}
func currentDungeonHuntEnemyResultAutoGranted(record dnfrepo.QuestRecord, result dnfquest.HuntEnemyPersistResult) bool {
for _, completion := range result.Completions {
state, known := currentDungeonHuntEnemyQuestState(record, completion.QuestID)
if known && state.Status == "completed" && state.Extra["reward_state"] == "granted" {
return true
}
}
return false
}
func currentDungeonHuntMonsterResultAutoGranted(record dnfrepo.QuestRecord, result dnfquest.HuntMonsterPersistResult) bool {
for _, completion := range result.Completions {
state, known := currentDungeonHuntEnemyQuestState(record, completion.QuestID)
if known && state.Status == "completed" && state.Extra["reward_state"] == "granted" {
return true
}
}
return false
}
func currentDungeonHuntEnemyQuestState(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 currentDungeonQuestEnemyTypeForMonster(monster runtimeDungeonMonster) int64 {
// 86JP's hunt-enemy domain uses type 1 for monsters and type 3 for passive
// objects. Dungeon actor wire types/ranks are a different namespace: a boss
// actor must not be reclassified as passive-object quest target type 3.
_ = monster
return 1
}