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,176 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user