Files
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

352 lines
14 KiB
Go

package dnfbridge
import (
"encoding/binary"
"encoding/hex"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/worldmap"
)
func TestCurrentDungeonStartMapBuildMatchesCurrentEXEReader(t *testing.T) {
body, err := (currentDungeonStartMap{
X: 2, Y: 3, LayeredRoomFlag: 4,
Seed: 0x08070605, HellPartyMode: 9, UnknownAfterHellPartyMode: 10,
RoomStateValue: 0x0e0d0c0b, RoomStateFlag: 15, MapID: 0x13121110,
Actors: []currentDungeonStartMapActor{{
TemplateOrder: 0x2120, PacketIndex: 0x25242322, ObjectKey: 0x2726,
Code: 0x2b2a2928, Level: 44, Type: 45, Flag0: 46, Flag1: 47, ExtraState: 0x33323130,
Blocking: 52,
}},
ExtraEntries: []currentDungeonStartMapExtraEntry{{
PassiveObjectIndex: 53, GlobalSequence: 0x39383736, ItemID: 0x3d3c3b3a,
StackCount: 0x41403f3e, Endurance: 0x4342, AmplifyType: 68,
AmplifyValue: 0x4645, ExtendedValue: 0x4847, ExtendedFlag: 73,
}},
HellPartyFogFlag: 74,
RidableGroups: [][]currentDungeonStartMapRidableEntry{{{
// Wire order is (PosX, PosY, ObjectIndex, Faction, SpawnMode); the
// room pair and the runtime key are server-side only, so the two
// leading sentinels below are the position slots.
PosX: 0x4e4d4c4b, PosY: 0x5251504f, ObjectIndex: 0x56555453,
Faction: 0x5a595857, SpawnMode: 0x5e5d5c5b,
}}},
PartyMemberIndex: 0x5f,
}).Build()
if err != nil {
t.Fatal(err)
}
if len(body) != 86 {
t.Fatalf("body len=%d want=86 body=%x", len(body), body)
}
const wantHex = "02030405060708090a0b0c0d0e0f1011121301202122232425262728292a2b2c2d2e2f30313233340135363738393a3b3c3d3e3f404142434445464748494a01014b4c4d4e4f505152535455565758595a5b5c5d5e5f"
if got := hex.EncodeToString(body); got != wantHex {
t.Fatalf("body=%s want=%s", got, wantHex)
}
if got := binary.LittleEndian.Uint32(body[14:18]); got != 0x13121110 {
t.Fatalf("current EXE map id width/order got=%08x", got)
}
}
func TestCurrentDungeonStartMapBuildEmptyCollections(t *testing.T) {
body, err := (currentDungeonStartMap{MapID: 1, PartyMemberIndex: 0xff}).Build()
if err != nil {
t.Fatal(err)
}
if len(body) != 23 {
t.Fatalf("body len=%d want=23 body=%x", len(body), body)
}
if body[18] != 0 || body[19] != 0 || body[20] != 0 || body[21] != 0 || body[22] != 0xff {
t.Fatalf("empty collection tail=%x", body[18:])
}
}
func TestCurrentDungeonStartMapBuildRejectsCountOverflow(t *testing.T) {
tests := []struct {
name string
packet currentDungeonStartMap
want error
}{
{name: "actors", packet: currentDungeonStartMap{Actors: make([]currentDungeonStartMapActor, 256)}, want: errDungeonStartMapActorCount},
{name: "extras", packet: currentDungeonStartMap{ExtraEntries: make([]currentDungeonStartMapExtraEntry, 256)}, want: errDungeonStartMapExtraCount},
{name: "groups", packet: currentDungeonStartMap{RidableGroups: make([][]currentDungeonStartMapRidableEntry, 256)}, want: errDungeonStartMapGroupCount},
{name: "group entries", packet: currentDungeonStartMap{RidableGroups: [][]currentDungeonStartMapRidableEntry{make([]currentDungeonStartMapRidableEntry, 256)}}, want: errDungeonStartMapGroupCount},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := test.packet.Build()
if !errors.Is(err, test.want) {
t.Fatalf("error=%v want=%v", err, test.want)
}
})
}
}
func TestCurrentDungeonMonsterType(t *testing.T) {
tests := map[string]byte{
"[normal]": 0, "[dummy]": 0, "champion": 1, "[super champion]": 2, "[superchampion]": 2, "[boss]": 3,
}
for rank, want := range tests {
got, err := currentDungeonMonsterType(rank)
if err != nil || got != want {
t.Fatalf("rank=%q got=%d error=%v want=%d", rank, got, err, want)
}
}
if _, err := currentDungeonMonsterType("[new rank]"); !errors.Is(err, errDungeonStartMapMonsterRank) {
t.Fatalf("unknown rank error=%v", err)
}
}
func TestCurrentDungeonMonsterActorTypeAcceptsBossSuffixMarker(t *testing.T) {
got, err := currentDungeonMonsterActorType(worldmap.MonsterSpawn{Rank: "[dummy]", SuffixMarker: "[boss]"})
if err != nil || got != 3 {
t.Fatalf("boss suffix actor type=%d err=%v want=3", got, err)
}
got, err = currentDungeonMonsterActorType(worldmap.MonsterSpawn{Rank: "[dummy]"})
if err != nil || got != 0 {
t.Fatalf("dummy actor type=%d err=%v want=0", got, err)
}
got, err = currentDungeonMonsterActorType(worldmap.MonsterSpawn{Rank: "[NPC]"})
if err != nil || got != currentDungeonNPCActorType {
t.Fatalf("NPC actor type=%d err=%v want=%d", got, err, currentDungeonNPCActorType)
}
}
func TestCurrentDungeonStartMapNPCIsNonBlocking(t *testing.T) {
coordinate := worldmap.RoomCoordinate{X: 4, Y: 2}
spawn := worldmap.MonsterSpawn{MonsterID: 62531, AutoLevel: 62, Rank: "[NPC]"}
runtime := &runtimeDungeonState{
Room: &runtimeDungeonRoom{
coordinate: coordinate,
mapID: 91548,
monsters: []runtimeDungeonMonster{{
ObjectKey: 444,
Spawn: spawn,
}},
},
}
scene := worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 91548}},
Monsters: []worldmap.MonsterSpawn{spawn},
}
packet, err := currentDungeonStartMapFromRuntime(runtime, scene, currentDungeonStartMapState{})
if err != nil {
t.Fatal(err)
}
if len(packet.Actors) != 1 || packet.Actors[0].Type != currentDungeonNPCActorType || packet.Actors[0].Blocking != 1 {
t.Fatalf("NPC actors=%+v", packet.Actors)
}
if _, err := currentDungeonMonsterType(spawn.Rank); !errors.Is(err, errDungeonStartMapMonsterRank) {
t.Fatalf("NPC unexpectedly entered ordinary reward type mapping: %v", err)
}
}
func TestCurrentDungeonMonsterLevelUsesPVFSemantics(t *testing.T) {
tests := []struct {
name string
spawn worldmap.MonsterSpawn
basis worldmap.OptionalInt
want byte
}{
{name: "fixed", spawn: worldmap.MonsterSpawn{AutoLevel: 17}, want: 17},
{name: "relative", spawn: worldmap.MonsterSpawn{Level: 1, AutoLevel: -2}, basis: worldmap.OptionalInt{Value: 20, Set: true}, want: 18},
// The second column is a switch, not a level: any nonzero value picks
// the relative branch, so basis+auto must hold for 2 and 10 as well.
{name: "relative switch is not one", spawn: worldmap.MonsterSpawn{Level: 2, AutoLevel: 30}, basis: worldmap.OptionalInt{Value: 5, Set: true}, want: 35},
{name: "relative switch ten", spawn: worldmap.MonsterSpawn{Level: 10, AutoLevel: 0}, basis: worldmap.OptionalInt{Value: 5, Set: true}, want: 5},
// Out-of-range rows clamp instead of failing the whole room.
{name: "absolute at ceiling", spawn: worldmap.MonsterSpawn{AutoLevel: 200}, want: currentDungeonMonsterLevelMax},
{name: "absolute above ceiling", spawn: worldmap.MonsterSpawn{AutoLevel: 256}, want: currentDungeonMonsterLevelMax},
{name: "absolute zero", spawn: worldmap.MonsterSpawn{AutoLevel: 0}, want: 1},
{name: "absolute negative", spawn: worldmap.MonsterSpawn{AutoLevel: -5}, want: 1},
{name: "relative above ceiling", spawn: worldmap.MonsterSpawn{Level: 1, AutoLevel: 500}, basis: worldmap.OptionalInt{Value: 5, Set: true}, want: currentDungeonMonsterLevelMax},
{name: "relative below floor", spawn: worldmap.MonsterSpawn{Level: 1, AutoLevel: -90}, basis: worldmap.OptionalInt{Value: 5, Set: true}, want: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := currentDungeonMonsterLevel(test.spawn, abisTestRuntimeWithBasis(test.basis))
if err != nil || got != test.want {
t.Fatalf("level=%d error=%v want=%d", got, err, test.want)
}
})
}
// The relative branch keeps its only error path: a missing [basis level]
// is a missing dependency, not something to paper over with a default.
bad := []struct {
spawn worldmap.MonsterSpawn
basis worldmap.OptionalInt
}{
{spawn: worldmap.MonsterSpawn{Level: 1, AutoLevel: 1}},
{spawn: worldmap.MonsterSpawn{Level: 3, AutoLevel: 0}},
}
for _, test := range bad {
if _, err := currentDungeonMonsterLevel(test.spawn, abisTestRuntimeWithBasis(test.basis)); !errors.Is(err, errDungeonStartMapMonsterLevel) {
t.Fatalf("spawn=%+v basis=%+v error=%v", test.spawn, test.basis, err)
}
}
}
func TestCurrentDungeonStartMapFromRuntimeUsesPVFOwnedFields(t *testing.T) {
coordinate := worldmap.RoomCoordinate{X: 7, Y: 9}
spawn := worldmap.MonsterSpawn{MonsterID: 123456, Level: 1, AutoLevel: -2, Rank: "[boss]"}
runtime := &runtimeDungeonState{
Dungeon: worldmap.Dungeon{Metadata: worldmap.DungeonMetadata{
BasisLevel: worldmap.OptionalInt{Value: 30, Set: true},
}},
Room: &runtimeDungeonRoom{
coordinate: coordinate,
mapID: 70000,
monsters: []runtimeDungeonMonster{{
ObjectKey: 402,
Spawn: spawn,
}},
byObjectKey: make(map[uint32]int),
},
}
scene := worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 70000}},
Monsters: []worldmap.MonsterSpawn{spawn},
}
packet, err := currentDungeonStartMapFromRuntime(runtime, scene, currentDungeonStartMapState{
LayeredRoomFlag: 2, Seed: 0x12345678, HellPartyMode: 3,
UnknownAfterHellPartyMode: 4, RoomStateValue: 5, RoomStateFlag: 6,
HellPartyFogFlag: 7, PartyMemberIndex: 0xff,
})
if err != nil {
t.Fatal(err)
}
if packet.X != 7 || packet.Y != 9 || packet.MapID != 70000 || packet.Seed != 0x12345678 {
t.Fatalf("packet PVF/runtime fields=%+v", packet)
}
if len(packet.Actors) != 1 {
t.Fatalf("actors=%+v", packet.Actors)
}
actor := packet.Actors[0]
if actor.ObjectKey != 402 || actor.Code != 123456 || actor.Level != 28 || actor.Type != 3 || actor.Blocking != 0 {
t.Fatalf("actor=%+v", actor)
}
if body, buildErr := packet.Build(); buildErr != nil || len(body) != 44 {
t.Fatalf("build len=%d error=%v body=%x", len(body), buildErr, body)
}
}
func TestCurrentDungeonStartMapUsesScriptedRoomClearSkipByte(t *testing.T) {
coordinate := worldmap.RoomCoordinate{X: 1, Y: 0}
spawn := worldmap.MonsterSpawn{MonsterID: 70216, AutoLevel: 1, Rank: "[normal]"}
runtime := &runtimeDungeonState{
Room: &runtimeDungeonRoom{
coordinate: coordinate,
mapID: 70577,
monsters: []runtimeDungeonMonster{{
ObjectKey: 405,
Reference: worldmap.HostileReference{Kind: worldmap.HostileMonster, Index: 0},
Spawn: spawn,
RoomClearSkipByte: 1,
}},
},
}
scene := worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 70577}},
Monsters: []worldmap.MonsterSpawn{spawn},
}
packet, err := currentDungeonStartMapFromRuntime(runtime, scene, currentDungeonStartMapState{})
if err != nil {
t.Fatal(err)
}
if len(packet.Actors) != 1 || packet.Actors[0].Blocking != 1 {
t.Fatalf("actors=%+v", packet.Actors)
}
}
func TestCurrentDungeonStartMapFromRuntimeRejectsUnownedActors(t *testing.T) {
coordinate := worldmap.RoomCoordinate{X: 1, Y: 1}
spawn := worldmap.MonsterSpawn{MonsterID: 10, AutoLevel: 10, Rank: "[normal]"}
baseRoom := func() *runtimeDungeonRoom {
return &runtimeDungeonRoom{
coordinate: coordinate,
mapID: 20,
monsters: []runtimeDungeonMonster{{ObjectKey: 402, Spawn: spawn}},
}
}
baseScene := func() worldmap.DungeonRoomScene {
return worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 20}},
Monsters: []worldmap.MonsterSpawn{spawn},
}
}
tests := []struct {
name string
mutate func(*runtimeDungeonState, *worldmap.DungeonRoomScene)
want error
}{
{name: "opaque hostile", want: errDungeonStartMapOpaqueHostile, mutate: func(runtime *runtimeDungeonState, _ *worldmap.DungeonRoomScene) {
runtime.Room.opaqueHostiles = []worldmap.HostileReference{{Kind: worldmap.HostileAICharacter}}
}},
{name: "unplanned special passive", want: errDungeonStartMapSceneMismatch, mutate: func(_ *runtimeDungeonState, scene *worldmap.DungeonRoomScene) {
scene.SpecialPassiveObjects = []worldmap.SpecialPassiveObject{{}}
}},
{name: "scene mismatch", want: errDungeonStartMapSceneMismatch, mutate: func(_ *runtimeDungeonState, scene *worldmap.DungeonRoomScene) {
scene.Map.Map.ID++
}},
{name: "unknown rank", want: errDungeonStartMapMonsterRank, mutate: func(runtime *runtimeDungeonState, scene *worldmap.DungeonRoomScene) {
runtime.Room.monsters[0].Spawn.Rank = "[new rank]"
scene.Monsters[0].Rank = "[new rank]"
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime := &runtimeDungeonState{Room: baseRoom()}
scene := baseScene()
test.mutate(runtime, &scene)
_, err := currentDungeonStartMapFromRuntime(runtime, scene, currentDungeonStartMapState{})
if !errors.Is(err, test.want) {
t.Fatalf("error=%v want=%v", err, test.want)
}
})
}
}
func TestCurrentDungeonRidableGroupsForRoomScopesRowsToTheirRoom(t *testing.T) {
entry := func(roomX, roomY uint32, posX, posY int64) currentDungeonStartMapRidableEntry {
return currentDungeonStartMapRidableEntry{
RoomX: roomX, RoomY: roomY, PosX: posX, PosY: posY,
ObjectIndex: 18865, Faction: 1, SpawnMode: currentDungeonRandomizedPassiveFamily,
}
}
groups := [][]currentDungeonStartMapRidableEntry{
{entry(1, 0, 459, 256), entry(2, 3, 459, 256)},
{entry(1, 1, 459, 256)},
}
got := currentDungeonRidableGroupsForRoom(groups, worldmap.RoomCoordinate{X: 2, Y: 3})
if len(got) != 2 || len(got[0]) != 1 || len(got[1]) != 0 {
t.Fatalf("2:3 groups=%+v", got)
}
if got[0][0].RoomX != 2 || got[0][0].RoomY != 3 || got[0][0].PosX != 459 || got[0][0].PosY != 256 {
t.Fatalf("2:3 row=%+v", got[0][0])
}
// Group indexes survive: an empty group is still announced, so the client's
// group count never shifts.
if got[1] == nil {
t.Fatalf("2:3 group 1 must stay empty, not nil: %+v", got)
}
// The EnergyCut boss room (0:2) owns no randomized object at all: it must get
// the same zero group count 86JP writes for an empty table.
if boss := currentDungeonRidableGroupsForRoom(groups, worldmap.RoomCoordinate{X: 0, Y: 2}); boss != nil {
t.Fatalf("0:2 groups=%+v want nil", boss)
}
if empty := currentDungeonRidableGroupsForRoom(nil, worldmap.RoomCoordinate{X: 1, Y: 0}); empty != nil {
t.Fatalf("empty groups=%+v want nil", empty)
}
}
// abisTestRuntimeWithBasis 把旧的 (spawn, basis) 形状包成现在的
// (spawn, *runtimeDungeonState),保持这些用例仍在验证 basis 解析分支。
func abisTestRuntimeWithBasis(basis worldmap.OptionalInt) *runtimeDungeonState {
return &runtimeDungeonState{Dungeon: worldmap.Dungeon{Metadata: worldmap.DungeonMetadata{BasisLevel: basis}}}
}