服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
177 lines
5.9 KiB
Go
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
|
|
}
|