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