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

177 lines
5.9 KiB
Go

package dnfbridge
import (
"errors"
"fmt"
"math"
"slices"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
"longheng.io/server/internal/modules/dnf/worldmap"
)
const (
currentDespairTowerOpponentMsgID uint16 = 347
currentDespairTowerClearRewardMsgID uint16 = 348
currentGriefTowerClearRewardMsgID uint16 = 1263
)
// The current EXE waits at 0x3423D22 for byte_51B2871 on PVF [npc layer]
// floors. op347/sub_19B1250 sets that flag; op28/op29 alone never release it.
func (s *Service) sendCurrentTowerOfDespairOpponent(session *gameSession, runtime *runtimeDungeonState) error {
if runtime == nil || !currentDungeonIsTowerOfDespair(runtime.Dungeon) {
return nil
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return err
}
floor, err := resolveCurrentDungeonTowerFloor(archive, runtime.Dungeon, currentDespairTowerClearRewardMsgID)
if err != nil {
return err
}
document, err := parseDungeonCardPVFDocument(archive, "etc/towerofdespair.etc")
if err != nil {
return err
}
layers, err := dungeonCardPVFSectionInts(document, "npc layer")
if err != nil {
return err
}
if !slices.Contains(layers, int64(floor)) {
return nil
}
// Native empty-name fallback (0x19B1508 -> sub_17F0950) copies the current
// player's real name, level, job/growth, equipment and pet. The existing
// PVF dummy APC 31505 is resolved from that descriptor at 0x2363556.
// Wire: active:u8, name:str32, level/job/growth:3*u8, str32, u32,
// equipment:26*u32, str32, pet:u32, tail:u8. All strings are empty.
body := make([]byte, 129)
body[0] = 1
if err := s.sendGameUpperRawClass(session, currentDespairTowerOpponentMsgID, body, 0); err != nil {
return err
}
s.logGameEvent(session, "game-despair-tower-opponent-sent",
"dungeon_id", runtime.Dungeon.ID, "floor", floor,
"msg_id", currentDespairTowerOpponentMsgID, "body_len", len(body),
"source", "current_exe_empty_name_self_opponent",
"packet_order", "op28-op347-op29")
return nil
}
// resolveCurrentDungeonTowerFloor maps a tower dungeon ID back to its PVF floor
// using [top layer] and [each layer matching dungeon]. A dungeon mapped twice,
// or not mapped at all, is a PVF fault rather than a silent default.
func resolveCurrentDungeonTowerFloor(source dnfpvf.Source, dungeon worldmap.Dungeon, msgID uint16) (uint16, error) {
path := "etc/towerofdespair.etc"
if msgID == currentGriefTowerClearRewardMsgID {
path = "etc/towerofgrief.etc"
} else if msgID != currentDespairTowerClearRewardMsgID {
return 0, errCurrentDungeonSettlementPVFInvalid
}
document, err := parseDungeonCardPVFDocument(source, path)
if err != nil {
return 0, err
}
top, ok := document.Int("top layer")
rows := document.Ints("each layer matching dungeon")
if !ok || top <= 0 || top > math.MaxUint16 || len(rows) == 0 || len(rows)%3 != 0 {
return 0, fmt.Errorf("%w: tower layer table %s", errCurrentDungeonSettlementPVFInvalid, path)
}
var floor uint16
for i := 0; i < len(rows); i += 3 {
if rows[i] <= 0 || rows[i] > top || rows[i+1] <= 0 {
return 0, fmt.Errorf("%w: tower layer row %d", errCurrentDungeonSettlementPVFInvalid, i/3)
}
if rows[i+1] == dungeon.ID {
if floor != 0 {
return 0, fmt.Errorf("%w: duplicate tower dungeon %d", errCurrentDungeonSettlementPVFInvalid, dungeon.ID)
}
floor = uint16(rows[i])
}
}
if floor == 0 {
return 0, fmt.Errorf("%w: tower dungeon %d is not mapped", errCurrentDungeonSettlementPVFInvalid, dungeon.ID)
}
return floor, nil
}
// resolveCurrentTowerOfDespairSoloDungeon advances over PVF floors that are
// defined for the official player-challenge service but have no complete
// single-player map/hostile actor in Script.pvf. The decision is derived from
// the resolved PVF layout, so the implementation does not maintain a floor-ID
// blacklist.
func resolveCurrentTowerOfDespairSoloDungeon(
table *worldmap.Table,
resolver *worldmap.Resolver,
requestedID int64,
) (int64, []int64, error) {
if table == nil || resolver == nil {
return requestedID, nil, errDungeonWorldMapUnavailable
}
dungeon, found := table.FindDungeon(requestedID)
if !found || !currentDungeonIsTowerOfDespair(dungeon) {
return requestedID, nil, nil
}
skipped := make([]int64, 0, 4)
for candidateID := requestedID; ; candidateID++ {
candidate, ok := table.FindDungeon(candidateID)
if !ok || !currentDungeonIsTowerOfDespair(candidate) {
return requestedID, skipped, fmt.Errorf("%w: tower floor after dungeon=%d", worldmap.ErrDungeonNotIndexed, candidateID-1)
}
playable, err := currentTowerOfDespairFloorSoloPlayable(resolver, candidate)
if err == nil && playable {
return candidateID, skipped, nil
}
if err != nil && !errors.Is(err, worldmap.ErrMapPoolEmpty) && !errors.Is(err, worldmap.ErrMapUnresolved) {
return requestedID, skipped, err
}
skipped = append(skipped, candidateID)
}
}
func currentDungeonIsTowerOfDespair(dungeon worldmap.Dungeon) bool {
if dungeon.Metadata.Flags["tower of despair"] {
return true
}
for _, section := range dungeon.SourceSections {
if normalizeDungeonPVFSymbol(section.Name) == "tower of despair" {
return true
}
}
return false
}
func currentTowerOfDespairFloorSoloPlayable(resolver *worldmap.Resolver, dungeon worldmap.Dungeon) (bool, error) {
topology, err := worldmap.BuildDungeonLayout(
resolver,
dungeon.ID,
0,
func(choice worldmap.DungeonMapChoice) (int64, error) {
if len(choice.Candidates) == 0 {
return 0, worldmap.ErrMapChoiceRequired
}
return choice.Candidates[0].ID, nil
},
)
if err != nil {
return false, err
}
for _, room := range topology.Rooms() {
if room.Map == nil {
continue
}
if len(room.Map.Map.Monsters) != 0 || len(room.Map.Map.MonsterTeam) != 0 {
return true, nil
}
for _, actor := range room.Map.Map.AICharacters {
if normalizeDungeonPVFSymbol(actor.Faction) == "monster" {
return true, nil
}
}
}
return false, nil
}