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,277 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||||
)
|
||||
|
||||
var (
|
||||
errDungeonRandomizedObjectGroup = errors.New("dnf dungeon randomized object group is invalid")
|
||||
errDungeonRandomizedObjectDefinition = errors.New("dnf dungeon randomized object definition is invalid")
|
||||
errDungeonRandomizedObjectTeam = errors.New("dnf dungeon randomized object team is unsupported")
|
||||
errDungeonRandomizedObjectPosition = errors.New("dnf dungeon randomized object position is invalid")
|
||||
errDungeonRandomizedObjectKeyRange = errors.New("dnf dungeon randomized object key exceeds current EXE range")
|
||||
)
|
||||
|
||||
const (
|
||||
currentDungeonRandomizedMonsterFamily uint32 = 0
|
||||
currentDungeonRandomizedPassiveFamily uint32 = 1
|
||||
currentDungeonExplicitPositionMode byte = 5
|
||||
)
|
||||
|
||||
// currentDungeonRandomizedObjectSelection is one DGN [randomized object
|
||||
// creation] choice. The current client consumes the same choice in three
|
||||
// different packets: op28 publishes its maze coordinate, op479 materializes
|
||||
// eligible explicitly-positioned creatures, and op29 associates the resulting
|
||||
// object key with the family/definition pair.
|
||||
type currentDungeonRandomizedObjectSelection struct {
|
||||
GroupIndex int
|
||||
CandidateIndex int
|
||||
Room worldmap.RoomCoordinate
|
||||
Position worldmap.Point
|
||||
Family uint32
|
||||
DefinitionID uint32
|
||||
ObjectKey uint32
|
||||
Team string
|
||||
Level byte
|
||||
}
|
||||
|
||||
// currentDungeonRandomizedObjectSkip records one [randomized object creation]
|
||||
// candidate this server does not materialize. Skipping a kind is deliberately not
|
||||
// fatal to the map: EnergyCut (Anton phase two map 218) has a group of eight
|
||||
// candidates that are ALL the same passive object with team "monster"
|
||||
// (18865 ActionObject/Map/Anton/Phase2/Transmission/Transmission_S.obj), so
|
||||
// failing the group closed made the gate permanently unenterable -- live
|
||||
// 2026-09-16T13:13Z, four attempts, each
|
||||
// "dnf dungeon randomized object team is unsupported: group=0 candidate=N family=1
|
||||
// team=\"monster\"". The unsupported actors are simply not announced; every other
|
||||
// candidate in the map still is.
|
||||
type currentDungeonRandomizedObjectSkip struct {
|
||||
GroupIndex int
|
||||
CandidateIndex int
|
||||
DefinitionID uint32
|
||||
Family uint32
|
||||
Team string
|
||||
}
|
||||
|
||||
func planCurrentDungeonRandomizedObjects(
|
||||
groups []worldmap.RandomizedObjectScript,
|
||||
seed uint32,
|
||||
objectKeyBase uint32,
|
||||
basis worldmap.OptionalInt,
|
||||
catalog *pvfDungeonMonsterCatalog,
|
||||
) ([][]currentDungeonRandomizedObjectSelection, [][]currentDungeonStartMapRidableEntry, uint32, []currentDungeonRandomizedObjectSkip, error) {
|
||||
if len(groups) == 0 {
|
||||
return nil, nil, objectKeyBase, nil, nil
|
||||
}
|
||||
if catalog == nil {
|
||||
return nil, nil, objectKeyBase, nil, errDungeonMonsterCatalogUnavailable
|
||||
}
|
||||
if len(groups) > int(^uint8(0)) {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf("%w: group_count=%d", errDungeonRandomizedObjectGroup, len(groups))
|
||||
}
|
||||
if !basis.Set || basis.Value <= 1 || basis.Value > int64(^uint8(0))+1 {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf("%w: basis_level=%d set=%t", errDungeonRandomizedObjectDefinition, basis.Value, basis.Set)
|
||||
}
|
||||
|
||||
selectedCount := uint32(0)
|
||||
for groupIndex, group := range groups {
|
||||
selectCount := int(group.SelectCount.Value)
|
||||
if !group.SelectCount.Set || selectCount <= 0 || selectCount > len(group.Objects) || len(group.Objects) > int(^uint8(0)) {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf(
|
||||
"%w: group=%d select=%d candidates=%d set=%t",
|
||||
errDungeonRandomizedObjectGroup, groupIndex, selectCount, len(group.Objects), group.SelectCount.Set,
|
||||
)
|
||||
}
|
||||
selectedCount += uint32(selectCount)
|
||||
}
|
||||
const objectKeyLimit = uint32(^uint16(0)) + 1
|
||||
if selectedCount != 0 && (objectKeyBase == 0 || objectKeyBase >= objectKeyLimit || selectedCount > objectKeyLimit-objectKeyBase) {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf(
|
||||
"%w: first=%d count=%d", errDungeonRandomizedObjectKeyRange, objectKeyBase, selectedCount,
|
||||
)
|
||||
}
|
||||
|
||||
selections := make([][]currentDungeonRandomizedObjectSelection, len(groups))
|
||||
associations := make([][]currentDungeonStartMapRidableEntry, len(groups))
|
||||
var skipped []currentDungeonRandomizedObjectSkip
|
||||
nextObjectKey := objectKeyBase
|
||||
for groupIndex, group := range groups {
|
||||
indices := currentDungeonRandomizedObjectSelectionIndices(
|
||||
len(group.Objects), int(group.SelectCount.Value), seed, groupIndex,
|
||||
)
|
||||
selections[groupIndex] = make([]currentDungeonRandomizedObjectSelection, 0, len(indices))
|
||||
associations[groupIndex] = make([]currentDungeonStartMapRidableEntry, 0, len(indices))
|
||||
for _, candidateIndex := range indices {
|
||||
candidate := group.Objects[candidateIndex]
|
||||
if candidate.MazeCoordinate == nil || candidate.MazeCoordinate.X < 0 || candidate.MazeCoordinate.X > 0xff ||
|
||||
candidate.MazeCoordinate.Y < 0 || candidate.MazeCoordinate.Y > 0xff {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf(
|
||||
"%w: group=%d candidate=%d map=%v", errDungeonRandomizedObjectPosition, groupIndex, candidateIndex, candidate.MazeCoordinate,
|
||||
)
|
||||
}
|
||||
if candidate.Position == nil || candidate.Position.X < -1<<31 || candidate.Position.X > 1<<31-1 ||
|
||||
candidate.Position.Y < -1<<31 || candidate.Position.Y > 1<<31-1 {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf(
|
||||
"%w: group=%d candidate=%d position=%v", errDungeonRandomizedObjectPosition, groupIndex, candidateIndex, candidate.Position,
|
||||
)
|
||||
}
|
||||
if !candidate.ObjectIndex.Set || candidate.ObjectIndex.Value <= 0 || candidate.ObjectIndex.Value > int64(^uint32(0)) {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf(
|
||||
"%w: group=%d candidate=%d definition=%d set=%t",
|
||||
errDungeonRandomizedObjectDefinition, groupIndex, candidateIndex, candidate.ObjectIndex.Value, candidate.ObjectIndex.Set,
|
||||
)
|
||||
}
|
||||
team := normalizeDungeonPVFSymbol(candidate.Team)
|
||||
family, found, err := catalog.randomizedObjectFamily(candidate.ObjectIndex.Value)
|
||||
if err != nil {
|
||||
return nil, nil, objectKeyBase, nil, fmt.Errorf("resolve randomized object group=%d candidate=%d: %w", groupIndex, candidateIndex, err)
|
||||
}
|
||||
if !found {
|
||||
skipped = append(skipped, currentDungeonRandomizedObjectSkip{
|
||||
GroupIndex: groupIndex, CandidateIndex: candidateIndex,
|
||||
DefinitionID: uint32(candidate.ObjectIndex.Value), Team: team,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// family is the spawn mode the client branches on (86JP
|
||||
// DungeonRandomizedObjectDefinition.ResolveSpawnMode: 0 = monster,
|
||||
// 1 = passive object; ResolveSpawnMode(18865) == 1, the EnergyCut
|
||||
// transmission). Neither the team nor the family filters the candidate
|
||||
// any more: the row's fifth value is what decides, so filtering here
|
||||
// would only hide objects the client can render.
|
||||
spawnMode := family
|
||||
selection := currentDungeonRandomizedObjectSelection{
|
||||
GroupIndex: groupIndex, CandidateIndex: candidateIndex,
|
||||
Room: worldmap.RoomCoordinate{X: candidate.MazeCoordinate.X, Y: candidate.MazeCoordinate.Y},
|
||||
Position: *candidate.Position, Family: family,
|
||||
DefinitionID: uint32(candidate.ObjectIndex.Value), ObjectKey: nextObjectKey,
|
||||
Team: team,
|
||||
// The explicit-creature descriptor uses the current EXE's zero-based
|
||||
// dungeon level byte. For Gent North Gate basis 69 this is the
|
||||
// captured 0x44 descriptor used by AT-5T.
|
||||
Level: byte(basis.Value - 1),
|
||||
}
|
||||
selections[groupIndex] = append(selections[groupIndex], selection)
|
||||
associations[groupIndex] = append(associations[groupIndex], currentDungeonStartMapRidableEntry{
|
||||
RoomX: uint32(selection.Room.X), RoomY: uint32(selection.Room.Y),
|
||||
// The wire row wants the PVF `[pos]` pair, and the room pair above
|
||||
// only scopes the row to the room that owns this candidate.
|
||||
PosX: selection.Position.X,
|
||||
PosY: selection.Position.Y,
|
||||
ObjectIndex: selection.DefinitionID,
|
||||
Faction: currentDungeonRidableFaction(team),
|
||||
SpawnMode: spawnMode,
|
||||
// Server-side bookkeeping only; the client never sees this.
|
||||
RuntimeObjectKey: selection.ObjectKey,
|
||||
})
|
||||
nextObjectKey++
|
||||
}
|
||||
}
|
||||
return selections, associations, nextObjectKey, skipped, nil
|
||||
}
|
||||
|
||||
// currentDungeonRidableFaction maps the PVF team word onto the fourth value of an
|
||||
// op29 association row. 86JP writes item.Faction there; the current client does
|
||||
// not branch on it (only on SpawnMode), so the mapping stays conservative: the
|
||||
// neutral/none team is 0 and everything else is the hostile value.
|
||||
func currentDungeonRidableFaction(team string) uint32 {
|
||||
switch team {
|
||||
case "", "neutral", "none":
|
||||
return 0
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func currentDungeonRandomizedObjectSelectionIndices(candidateCount, selectCount int, seed uint32, groupIndex int) []int {
|
||||
indices := make([]int, candidateCount)
|
||||
for index := range indices {
|
||||
indices[index] = index
|
||||
}
|
||||
state := seed ^ uint32(groupIndex+1)*0x9e3779b9
|
||||
if state == 0 {
|
||||
state = 0x6d2b79f5
|
||||
}
|
||||
for index := candidateCount - 1; index > 0; index-- {
|
||||
state ^= state << 13
|
||||
state ^= state >> 17
|
||||
state ^= state << 5
|
||||
swapIndex := int(state % uint32(index+1))
|
||||
indices[index], indices[swapIndex] = indices[swapIndex], indices[index]
|
||||
}
|
||||
return indices[:selectCount]
|
||||
}
|
||||
|
||||
func currentDungeonRandomizedObjectInfoGroups(
|
||||
groups [][]currentDungeonRandomizedObjectSelection,
|
||||
) [][]currentDungeonInfoPair {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([][]currentDungeonInfoPair, len(groups))
|
||||
for groupIndex, group := range groups {
|
||||
result[groupIndex] = make([]currentDungeonInfoPair, 0, len(group))
|
||||
for _, selection := range group {
|
||||
result[groupIndex] = append(result[groupIndex], currentDungeonInfoPair{
|
||||
First: byte(selection.Room.X), Second: byte(selection.Room.Y),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildCurrentDungeonRandomizedCreatureQueue builds the current class0/op479
|
||||
// room-explicit creature queue. Association rows alone do not instantiate an
|
||||
// actor; op28 clears this queue, so callers must send it after op28 and before
|
||||
// the matching full op29 room payload.
|
||||
//
|
||||
// This queue carries NEUTRAL creatures only, and the team filter is what keeps it
|
||||
// that way: dropping it (tried live 2026-09-16T23:3xZ) spawned a *friendly* 托梅尔
|
||||
// next to the hostile one the association row already materializes, because the
|
||||
// op479 descriptor has no faction field at all -- the client treats everything in
|
||||
// the queue as team-neutral. Hostile randomized monsters must stay on the
|
||||
// association row (fifth value 0), which is where 218's group-1 58530 comes from.
|
||||
func buildCurrentDungeonRandomizedCreatureQueue(
|
||||
room worldmap.RoomCoordinate,
|
||||
groups [][]currentDungeonRandomizedObjectSelection,
|
||||
) ([]byte, int, error) {
|
||||
selected := make([]currentDungeonRandomizedObjectSelection, 0)
|
||||
for _, group := range groups {
|
||||
for _, selection := range group {
|
||||
if selection.Room != room || selection.Family != currentDungeonRandomizedMonsterFamily ||
|
||||
!strings.EqualFold(selection.Team, "neutral") {
|
||||
continue
|
||||
}
|
||||
if selection.ObjectKey == 0 || selection.ObjectKey > uint32(^uint16(0)) {
|
||||
return nil, 0, fmt.Errorf("%w: object_key=%d", errDungeonRandomizedObjectKeyRange, selection.ObjectKey)
|
||||
}
|
||||
if selection.DefinitionID == 0 || selection.Level == 0 {
|
||||
return nil, 0, fmt.Errorf("%w: definition=%d level=%d", errDungeonRandomizedObjectDefinition, selection.DefinitionID, selection.Level)
|
||||
}
|
||||
selected = append(selected, selection)
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
if len(selected) > int(^uint8(0)) {
|
||||
return nil, 0, fmt.Errorf("%w: creature_count=%d", errDungeonRandomizedObjectGroup, len(selected))
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeByte(byte(len(selected)))
|
||||
for _, selection := range selected {
|
||||
writer.writeUint16(uint16(selection.ObjectKey))
|
||||
writer.writeUint32(selection.DefinitionID)
|
||||
writer.writeUint16(uint16(selection.Level))
|
||||
writer.writeUint32(uint32(int32(selection.Position.X)))
|
||||
writer.writeUint32(uint32(int32(selection.Position.Y)))
|
||||
writer.writeUint32(0)
|
||||
writer.writeByte(0)
|
||||
writer.writeByte(currentDungeonExplicitPositionMode)
|
||||
}
|
||||
return writer.bytes(), len(selected), nil
|
||||
}
|
||||
Reference in new issue
Block a user