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,419 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
||||
)
|
||||
|
||||
var (
|
||||
errDeathTowerUnavailable = errors.New("dnf death tower is unavailable")
|
||||
errDeathTowerNotInTower = errors.New("dnf death tower session is not active")
|
||||
errDeathTowerAdvance = errors.New("dnf death tower stage advance rejected")
|
||||
)
|
||||
|
||||
const (
|
||||
currentDeathTowerDungeonID = 11000
|
||||
currentDeathTowerTotalStages = 45
|
||||
currentDeathTowerFirstMapID = 30001
|
||||
currentDeathTowerBasisLevel = 60
|
||||
currentDeathTowerInfoMsgID = 0x008E
|
||||
currentDeathTowerStageMapMsgID = 0x008F
|
||||
currentDeathTowerRankingMsgID = 0x0090
|
||||
currentDeathTowerRewardMsgID = 0x0091
|
||||
currentDeathTowerEplpMsgID = 0x0092
|
||||
currentDeathTowerNormalMode = 0
|
||||
currentDeathTowerBuffType = 11
|
||||
)
|
||||
|
||||
// deathTowerRuntime is the per-session tower state.
|
||||
type deathTowerRuntime struct {
|
||||
DungeonID int
|
||||
CurrentStage int // 0-based
|
||||
TotalStages int
|
||||
State int // 0=init, 1=fighting, 2=cleared
|
||||
MonsterSeq uint16
|
||||
StageMapIDs []int
|
||||
BasisLevel int
|
||||
}
|
||||
|
||||
func newDeathTowerRuntime(stageMapIDs []int, basisLevel int) *deathTowerRuntime {
|
||||
return &deathTowerRuntime{
|
||||
DungeonID: currentDeathTowerDungeonID,
|
||||
CurrentStage: 0,
|
||||
TotalStages: len(stageMapIDs),
|
||||
State: 0,
|
||||
MonsterSeq: 1,
|
||||
StageMapIDs: stageMapIDs,
|
||||
BasisLevel: basisLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *deathTowerRuntime) currentMapID() int {
|
||||
if rt == nil || rt.CurrentStage < 0 || rt.CurrentStage >= len(rt.StageMapIDs) {
|
||||
return 0
|
||||
}
|
||||
return rt.StageMapIDs[rt.CurrentStage]
|
||||
}
|
||||
|
||||
func (rt *deathTowerRuntime) isLastStage() bool {
|
||||
return rt != nil && rt.CurrentStage >= rt.TotalStages-1
|
||||
}
|
||||
|
||||
func (rt *deathTowerRuntime) tryAdvance() bool {
|
||||
if rt == nil || rt.State < 1 || rt.CurrentStage >= rt.TotalStages-1 {
|
||||
return false
|
||||
}
|
||||
rt.CurrentStage++
|
||||
rt.State = 0
|
||||
return true
|
||||
}
|
||||
|
||||
// --- Entry ---
|
||||
|
||||
func (s *Service) handleCurrentDeathTowerEntry(session *gameSession, difficulty byte) error {
|
||||
if session == nil || session.selectedCharacterID == 0 {
|
||||
return errDeathTowerUnavailable
|
||||
}
|
||||
stageMapIDs, basisLevel, err := s.currentDeathTowerPVFConfig()
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-death-tower-entry-rejected", "reason", err)
|
||||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketSelectDungeon), 22)
|
||||
}
|
||||
rt := newDeathTowerRuntime(stageMapIDs, basisLevel)
|
||||
session.dungeon.deathTower = rt
|
||||
|
||||
s.logGameEvent(session, "game-death-tower-entry",
|
||||
"dungeon_id", rt.DungeonID,
|
||||
"total_stages", rt.TotalStages,
|
||||
"basis_level", rt.BasisLevel,
|
||||
"first_map", rt.currentMapID())
|
||||
|
||||
// 0x008E DEATH_TOWER_INFO (8B)
|
||||
infoBody := buildCurrentDeathTowerInfo(rt.DungeonID, uint16(rt.TotalStages))
|
||||
if err := s.sendGameUpperRawClass(session, currentDeathTowerInfoMsgID, infoBody, dnfproto.DefaultChannelClassification); err != nil {
|
||||
return err
|
||||
}
|
||||
// 0x008F first stage map
|
||||
if err := s.sendCurrentDeathTowerStageMap(session, rt); err != nil {
|
||||
return err
|
||||
}
|
||||
// 0x001E FINISH_LOADING (empty)
|
||||
return s.sendGameUpperRawClass(session, csharpNotiFinishLoadingMsgID, nil, dnfproto.DefaultChannelClassification)
|
||||
}
|
||||
|
||||
// --- Stage Map ---
|
||||
|
||||
func (s *Service) sendCurrentDeathTowerStageMap(session *gameSession, rt *deathTowerRuntime) error {
|
||||
monsters, err := s.currentDeathTowerStageMonsters(rt)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-death-tower-stage-map-failed",
|
||||
"stage", rt.CurrentStage, "map_id", rt.currentMapID(), "reason", err)
|
||||
monsters = nil
|
||||
}
|
||||
body := buildCurrentDeathTowerStageMap(rt, monsters)
|
||||
return s.sendGameUpperRawClass(session, currentDeathTowerStageMapMsgID, body, dnfproto.DefaultChannelClassification)
|
||||
}
|
||||
|
||||
// --- Stage Command (op159 / CmdPacketDeathTowerStageCmd) ---
|
||||
|
||||
func (s *Service) handleCurrentDeathTowerStageCmd(session *gameSession, body []byte) error {
|
||||
rt := session.dungeon.deathTower
|
||||
if rt == nil {
|
||||
return nil
|
||||
}
|
||||
if len(body) < 1 {
|
||||
return nil
|
||||
}
|
||||
switch body[0] {
|
||||
case 1:
|
||||
rt.State = 1
|
||||
s.logGameEvent(session, "game-death-tower-fight-start", "stage", rt.CurrentStage)
|
||||
case 2:
|
||||
rt.State = 2
|
||||
s.logGameEvent(session, "game-death-tower-stage-clear", "stage", rt.CurrentStage, "map_id", rt.currentMapID(), "is_last", rt.isLastStage())
|
||||
s.syncCurrentDeathTowerClearMap(session, rt, "tower_stage_cmd")
|
||||
if rt.isLastStage() {
|
||||
return s.sendCurrentDeathTowerSettlement(session, rt)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- MOVE_MAP advance ---
|
||||
|
||||
func (s *Service) handleCurrentDeathTowerMoveMap(session *gameSession) (bool, error) {
|
||||
rt := session.dungeon.deathTower
|
||||
if rt == nil {
|
||||
return false, nil
|
||||
}
|
||||
if rt.State >= 1 {
|
||||
s.syncCurrentDeathTowerClearMap(session, rt, "tower_move_map")
|
||||
}
|
||||
if !rt.tryAdvance() {
|
||||
s.logGameEvent(session, "game-death-tower-advance-rejected",
|
||||
"stage", rt.CurrentStage, "state", rt.State, "is_last", rt.isLastStage())
|
||||
return true, nil
|
||||
}
|
||||
s.logGameEvent(session, "game-death-tower-advance",
|
||||
"stage", rt.CurrentStage, "map_id", rt.currentMapID())
|
||||
if err := s.sendCurrentDeathTowerStageMap(session, rt); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, s.sendGameUpperRawClass(session, csharpNotiFinishLoadingMsgID, nil, dnfproto.DefaultChannelClassification)
|
||||
}
|
||||
|
||||
// --- Settlement ---
|
||||
|
||||
func (s *Service) sendCurrentDeathTowerSettlement(session *gameSession, rt *deathTowerRuntime) error {
|
||||
s.logGameEvent(session, "game-death-tower-settlement", "dungeon_id", rt.DungeonID, "total_stages", rt.TotalStages)
|
||||
// 0x0090 ranking (empty)
|
||||
if err := s.sendGameUpperRawClass(session, currentDeathTowerRankingMsgID, buildCurrentDeathTowerEmptyRanking(rt.DungeonID), dnfproto.DefaultChannelClassification); err != nil {
|
||||
return err
|
||||
}
|
||||
// 0x0091 reward (empty)
|
||||
if err := s.sendGameUpperRawClass(session, currentDeathTowerRewardMsgID, buildCurrentDeathTowerEmptyReward(), dnfproto.DefaultChannelClassification); err != nil {
|
||||
return err
|
||||
}
|
||||
// 0x0092 eplp
|
||||
if err := s.sendGameUpperRawClass(session, currentDeathTowerEplpMsgID, []byte{1}, dnfproto.DefaultChannelClassification); err != nil {
|
||||
return err
|
||||
}
|
||||
session.dungeon.deathTower = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Quest sync ---
|
||||
|
||||
func (s *Service) syncCurrentDeathTowerClearMap(session *gameSession, rt *deathTowerRuntime, source string) {
|
||||
mapID := rt.currentMapID()
|
||||
if mapID <= 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||||
repos, ok := s.repositoryGroup()
|
||||
if !ok || repos.Quest == nil || repos.Character == nil {
|
||||
return
|
||||
}
|
||||
if _, found, err := repos.Quest.Load(ctx, characterID); err != nil || !found {
|
||||
return
|
||||
}
|
||||
catalog, err := s.loadQuestCatalog(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
owner, err := dnfquest.NewOwner(repos)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
completionKey := fmt.Sprintf("death-tower/%s/%d/%d", characterID, rt.CurrentStage, mapID)
|
||||
result, err := owner.ApplyClearMapCompletion(ctx, catalog, characterID, dnfquest.ClearMapCompletionInput{
|
||||
DungeonID: int64(rt.DungeonID),
|
||||
MapID: int64(mapID),
|
||||
CompletionKey: completionKey,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-death-tower-quest-sync-failed",
|
||||
"stage", rt.CurrentStage, "map_id", mapID, "source", source, "reason", err)
|
||||
return
|
||||
}
|
||||
if len(result.Completions) > 0 {
|
||||
s.logGameEvent(session, "game-death-tower-quest-synced",
|
||||
"stage", rt.CurrentStage, "map_id", mapID, "source", source, "completions", len(result.Completions))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Packet builders ---
|
||||
|
||||
func buildCurrentDeathTowerInfo(dungeonID int, endStage uint16) []byte {
|
||||
var w packetWriter
|
||||
w.writeUint32(uint32(dungeonID))
|
||||
w.writeUint16(endStage)
|
||||
w.writeByte(currentDeathTowerNormalMode)
|
||||
w.writeByte(currentDeathTowerBuffType)
|
||||
return w.bytes()
|
||||
}
|
||||
|
||||
type currentDeathTowerStageMonster struct {
|
||||
ListIndex uint32
|
||||
MonsterUnique uint16
|
||||
MonsterID uint32
|
||||
Level byte
|
||||
MonsterType byte // 0=normal, 5=APC
|
||||
IsBox byte
|
||||
BoxIndex byte
|
||||
}
|
||||
|
||||
func buildCurrentDeathTowerStageMap(rt *deathTowerRuntime, monsters []currentDeathTowerStageMonster) []byte {
|
||||
var w packetWriter
|
||||
w.writeUint16(uint16(rt.CurrentStage + 1)) // 1-based display
|
||||
w.writeUint32(rand.Uint32())
|
||||
w.writeUint16(uint16(rt.currentMapID()))
|
||||
w.writeByte(byte(len(monsters)))
|
||||
for _, m := range monsters {
|
||||
w.writeUint32(m.ListIndex)
|
||||
w.writeUint16(m.MonsterUnique)
|
||||
w.writeUint32(m.MonsterID)
|
||||
w.writeByte(m.Level)
|
||||
w.writeByte(m.MonsterType)
|
||||
w.writeByte(m.IsBox)
|
||||
w.writeByte(m.BoxIndex)
|
||||
}
|
||||
w.writeByte(0) // itemCount = 0
|
||||
return w.bytes()
|
||||
}
|
||||
|
||||
func buildCurrentDeathTowerEmptyRanking(dungeonID int) []byte {
|
||||
var w packetWriter
|
||||
w.writeByte(0) // flag0
|
||||
w.writeUint32(0) // clearTime
|
||||
w.writeUint32(0) // playTime
|
||||
w.writeByte(0) // flag3
|
||||
w.writeUint32(uint32(dungeonID))
|
||||
w.writeByte(0) // hasMyBestRecord = false
|
||||
for g := 0; g < 5; g++ {
|
||||
for r := 0; r < 8; r++ {
|
||||
w.writeUint32(0) // empty dstr
|
||||
w.writeByte(0)
|
||||
w.writeByte(0)
|
||||
}
|
||||
w.writeUint16(0)
|
||||
w.writeUint32(0)
|
||||
w.writeUint32(0)
|
||||
}
|
||||
return w.bytes()
|
||||
}
|
||||
|
||||
func buildCurrentDeathTowerEmptyReward() []byte {
|
||||
var w packetWriter
|
||||
w.writeUint32(0) // summaryValue
|
||||
w.writeByte(0) // group0 count
|
||||
w.writeByte(0) // group1 count
|
||||
w.writeByte(0) // group2 count
|
||||
w.writeByte(0) // group3 count
|
||||
return w.bytes()
|
||||
}
|
||||
|
||||
// --- PVF config ---
|
||||
|
||||
func (s *Service) currentDeathTowerPVFConfig() ([]int, int, error) {
|
||||
s.deathTowerMu.Lock()
|
||||
defer s.deathTowerMu.Unlock()
|
||||
if s.deathTowerStageMapIDs != nil {
|
||||
return s.deathTowerStageMapIDs, s.deathTowerBasisLevel, nil
|
||||
}
|
||||
s.initialEquipmentMu.Lock()
|
||||
archive, err := s.initialEquipmentArchiveLocked()
|
||||
s.initialEquipmentMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("%w: %v", errDeathTowerUnavailable, err)
|
||||
}
|
||||
text, err := archive.ReadText("dungeon/Towers/TowerOfDeath.dgn")
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("%w: read dgn: %v", errDeathTowerUnavailable, err)
|
||||
}
|
||||
doc, err := dnfpvf.Parse("dungeon/Towers/TowerOfDeath.dgn", text)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("%w: parse dgn: %v", errDeathTowerUnavailable, err)
|
||||
}
|
||||
mapIDs, err := parseDeathTowerMapIndexes(doc)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
basisLevel := 1
|
||||
if lv, found := doc.Int("basis level"); found && lv > 0 {
|
||||
basisLevel = int(lv)
|
||||
}
|
||||
s.deathTowerStageMapIDs = mapIDs
|
||||
s.deathTowerBasisLevel = basisLevel
|
||||
s.logPacketEvent("dnf-death-tower-pvf-loaded", "stages", len(mapIDs), "basis_level", basisLevel, "first_map", mapIDs[0], "last_map", mapIDs[len(mapIDs)-1])
|
||||
return mapIDs, basisLevel, nil
|
||||
}
|
||||
|
||||
func parseDeathTowerMapIndexes(doc *dnfpvf.Document) ([]int, error) {
|
||||
values := doc.Ints("death tower map indexes")
|
||||
if len(values) < 3 {
|
||||
return nil, fmt.Errorf("%w: death tower map indexes missing or too short (%d values)", errDeathTowerUnavailable, len(values))
|
||||
}
|
||||
totalStages := int(values[0])
|
||||
if totalStages <= 0 || len(values) < 1+totalStages*2 {
|
||||
return nil, fmt.Errorf("%w: death tower map indexes malformed: total=%d values=%d", errDeathTowerUnavailable, totalStages, len(values))
|
||||
}
|
||||
mapIDs := make([]int, totalStages)
|
||||
for i := 0; i < totalStages; i++ {
|
||||
mapIDs[i] = int(values[1+i*2+1])
|
||||
}
|
||||
return mapIDs, nil
|
||||
}
|
||||
|
||||
// --- Stage monster loading ---
|
||||
|
||||
func (s *Service) currentDeathTowerStageMonsters(rt *deathTowerRuntime) ([]currentDeathTowerStageMonster, error) {
|
||||
mapID := rt.currentMapID()
|
||||
if mapID <= 0 {
|
||||
return nil, errDeathTowerNotInTower
|
||||
}
|
||||
s.initialEquipmentMu.Lock()
|
||||
archive, err := s.initialEquipmentArchiveLocked()
|
||||
s.initialEquipmentMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapPath := fmt.Sprintf("map/DeadTower/%03dF.map", rt.CurrentStage+1)
|
||||
text, err := archive.ReadText(mapPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", mapPath, err)
|
||||
}
|
||||
doc, err := dnfpvf.Parse(mapPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", mapPath, err)
|
||||
}
|
||||
return parseDeathTowerMapMonsters(doc, rt)
|
||||
}
|
||||
|
||||
// parseDeathTowerMapMonsters reads the [monster] section from a tower map file.
|
||||
// Format per monster: monsterId groupIndex level x y direction countPerAI unk aiType rank
|
||||
// (9 tokens per entry based on PVF observation).
|
||||
func parseDeathTowerMapMonsters(doc *dnfpvf.Document, rt *deathTowerRuntime) ([]currentDeathTowerStageMonster, error) {
|
||||
values := doc.Ints("monster")
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
const stride = 9
|
||||
var monsters []currentDeathTowerStageMonster
|
||||
for offset := 0; offset+stride <= len(values); offset += stride {
|
||||
monsterID := values[offset]
|
||||
level := values[offset+2]
|
||||
if monsterID <= 0 {
|
||||
continue
|
||||
}
|
||||
if level <= 0 {
|
||||
level = int64(rt.BasisLevel)
|
||||
}
|
||||
if level > 255 {
|
||||
level = 255
|
||||
}
|
||||
monsters = append(monsters, currentDeathTowerStageMonster{
|
||||
ListIndex: uint32(len(monsters)),
|
||||
MonsterUnique: rt.MonsterSeq,
|
||||
MonsterID: uint32(monsterID),
|
||||
Level: byte(level),
|
||||
MonsterType: 0,
|
||||
IsBox: 0,
|
||||
BoxIndex: 0,
|
||||
})
|
||||
rt.MonsterSeq++
|
||||
}
|
||||
return monsters, nil
|
||||
}
|
||||
Reference in new issue
Block a user