Files
90CNServer/server/internal/services/dnfbridge/dungeon_entry_packets_test.go
T
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

3168 lines
133 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"errors"
"strconv"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/channelcatalog"
dnfcollectbox "longheng.io/server/internal/modules/dnf/collectbox"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfitemlock "longheng.io/server/internal/modules/dnf/itemlock"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
"longheng.io/server/internal/modules/dnf/worldmap"
)
func seedPartyOwnerActorProjectionForTest(
t *testing.T,
service *Service,
session *gameSession,
characterID uint16,
ownerChannel byte,
) {
t.Helper()
characterIDLoaded, characterName, character, found := service.characterForEnter(
context.Background(),
session,
characterID,
)
if !found || characterIDLoaded != characterID {
t.Fatalf("load owner projection character %d", characterID)
}
session.connectionTownActorOwnerChannel = ownerChannel
session.townActorOwnerChannel = ownerChannel
mode0, err := service.buildCurrentSceneObjectListBodyForSessionInContextStrict(
context.Background(),
session,
characterID,
characterName,
character,
true,
ownerChannel,
)
if err != nil {
t.Fatal(err)
}
adventure := service.currentAccountAdventureGroupSummaryForPacket(
context.Background(),
session,
character,
true,
)
mode1 := service.buildCurrentActorBindingMode1BodyForSelectedWithEquipmentInContext(
context.Background(),
session,
character,
true,
characterID,
uint32(adventure.ManageLevel),
ownerChannel,
true,
)
if err := captureCurrentPartyActorProjection(session, characterID, mode0, mode1); err != nil {
t.Fatal(err)
}
}
func TestRuntimePartyMemberIndexUsesStableNativeSlot(t *testing.T) {
state := alignedcmd.PartyState{
PartyID: 2,
UserID: 10,
Members: []alignedcmd.PartyMemberState{
{UserID: 10, Slot: 0, UserState: 100},
{UserID: 30, Slot: 2, UserState: 100},
},
}
if slot, ok := runtimePartyMemberIndex(30, state); !ok || slot != 2 {
t.Fatalf("stable party slot=%d ok=%t, want slot 2", slot, ok)
}
}
func TestPartyLeaderDungeonSelectionEntersEveryOnlineMemberWithStablePartyIndex(t *testing.T) {
source := bridgeDungeonPVF(false)
source["dungeon/test.dgn"] = strings.Replace(source["dungeon/test.dgn"], "[limit party count]\n1\n", "[limit party count]\n4\n", 1)
source["dungeon/test.dgn"] += "[required item]\n4726 1 0\n[/required item]\n"
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
accounts := map[string]string{"99": "account-leader", "100": "account-follower"}
for index, id := range []string{"99", "100"} {
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: accounts[id],
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
itemID := int64(116010135 + index)
serial := 20 + index
raw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint16(raw[0:2], 17)
binary.LittleEndian.PutUint32(raw[2:6], uint32(itemID))
raw[0x27] = 1
binary.LittleEndian.PutUint32(raw[0x28:0x2c], currentChronicleCanonicalAuraBase+2)
raw[0x30] = 15
raw[0x32] = 2
raw[0x34] = 12
raw[0x36] = 11
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: id,
Slots: map[string]dnfrepo.ItemStack{
"0:17": {
ItemID: itemID,
Count: 1,
RawEntry: raw,
Extra: map[string]string{"item_serial": strconv.Itoa(serial)},
},
"0:180": {ItemID: 4726, Count: 1},
},
}); err != nil {
t.Fatal(err)
}
if err := saveSkillSwitchInventoryState(context.Background(), repositories.Settings, id, skillSwitchInventoryState{
SkillID: 46,
Items: []skillSwitchBinding{{
EquipmentPart: 12,
ItemSerial: uint32(serial),
ItemID: uint32(itemID),
}},
}); err != nil {
t.Fatal(err)
}
}
service := &Service{
options: options{accountID: "fallback-account", gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameplayTimers: newFakeCurrentDungeonDeathTimerQueue(),
}
enableTownMoveSkillProjection(t, service, repositories, "99")
enableTownMoveSkillProjection(t, service, repositories, "100")
// Exercise the actor-construction passive route on both receivers. Fear
// Aura remains a paid learned skill: it is present only in each durable
// active tree and is deliberately absent from initialSkillsByJob.
fearAuraCatalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "0 `swordman.lst`\n",
"skill/swordman.lst": "46 `swordman/active.skl`\n89 `swordman/darkaura.skl`\n",
"skill/swordman/active.skl": "[type]\n`active`\n",
"skill/swordman/darkaura.skl": "[type]\n`passive`\n[skill class]\n3\n[skill fitness second growtype]\n1 1\n[purchase cost]\n45\n",
})
if err != nil {
t.Fatal(err)
}
service.skillCatalog = fearAuraCatalog
for _, characterID := range []string{"99", "100"} {
character, found, err := repositories.Character.Load(context.Background(), characterID)
if err != nil || !found {
t.Fatalf("load party Soul Bender %s: found=%t err=%v", characterID, found, err)
}
character.Job = "0"
character.Stats["grow_type"] = 0x12
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
record, found, err := repositories.Skill.Load(context.Background(), characterID)
if err != nil || !found {
t.Fatalf("load party Soul Bender skill %s: found=%t err=%v", characterID, found, err)
}
record.Skills[currentSoulBenderFearAuraSkillID] = dnfrepo.SkillState{Level: 1, Enabled: true}
activeTree, found := dnfrepo.SkillTree(record, 1)
if !found {
t.Fatalf("party Soul Bender %s has no active skill tree", characterID)
}
activeTree.Skills[currentSoulBenderFearAuraSkillID] = dnfrepo.SkillState{Level: 1, Enabled: true}
dnfrepo.SetSkillTree(&record, 1, activeTree)
if err := repositories.Skill.Save(context.Background(), record); err != nil {
t.Fatal(err)
}
}
channel := channelcatalog.Channel{ServerID: 1, ID: 42, Type: 11, Name: "ch.42", Port: 10042}
writeLog := &orderedPartyWriteLog{}
leaderConn, followerConn, observerConn := &bufferConn{}, &bufferConn{}, &bufferConn{}
leaderWireConn := &orderedPartyConn{bufferConn: leaderConn, name: "leader", log: writeLog}
followerWireConn := &orderedPartyConn{bufferConn: followerConn, name: "follower", log: writeLog}
leader := &gameSession{conn: leaderWireConn, accountID: accounts["99"], channel: channel, residentChannel: channel, selectedCharacterID: 99}
follower := &gameSession{conn: followerWireConn, accountID: accounts["100"], channel: channel, residentChannel: channel, selectedCharacterID: 100}
observer := &gameSession{conn: observerConn, channel: channel, residentChannel: channel, selectedCharacterID: 101}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(follower, 100)
seedPartyOwnerActorProjectionForTest(t, service, leader, 99, 42)
seedPartyOwnerActorProjectionForTest(t, service, follower, 100, 42)
registerPartyPeerEndpointForTest(leader, 3, 5063)
registerPartyPeerEndpointForTest(follower, 3, 60359)
state := alignedcmd.PartyState{
PartyID: 99,
UserID: 99,
Members: []alignedcmd.PartyMemberState{
{UserID: 99, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 100, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
storeRuntimePartyState(leader, state)
storeRuntimePartyState(follower, state)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 99, TownID: 38, AreaID: 1, Session: leader})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 100, TownID: 38, AreaID: 1, Session: follower})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 101, TownID: 38, AreaID: 1, Session: observer})
bindDungeonSelectorOriginForTestAt(t, service, leader, 38, 1, 450, 234)
bindDungeonSelectorOriginForTestAt(t, service, follower, 38, 1, 450, 234)
if got := runtimePartyStateSnapshot(leader); got.PartyID != 99 || len(got.Members) != 2 {
t.Fatalf("leader party before dungeon entry=%+v", got)
}
// 这个 fixture 表示的是「队长从 op15 选图页选图进来」那条路 ——
// bindDungeonSelectorOriginForTestAt 做的是 bindCurrentTownSelectorOrigin,
// 也就是选图页打开时做的那次原点绑定。真实的选图页还会**发 op27 并把
// enterSelectDungeonContextSent 置真**(sendCurrentTownEnterSelectContext,
// town_enter_select.go:318/332);helper 没做那一半,所以这里补上。
//
// 不补的话 fixture 表示的其实是另一条路:「服务端驱动的进图」——
// 超时空遭遇战就是那种(op36 走进区域触发,玩家从来没有打开过选图页,
// latch 一直是假)。两条路对 op27 的期望正好相反,见下面 wantPrelude 之后那段。
leader.enterSelectDungeonContextSent = true
if index, ok := runtimePartyMemberIndexForSession(leader); !ok || index != 0 {
t.Fatalf("leader party index before dungeon entry=%d ok=%t", index, ok)
}
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(body[:4], 700)
if err := service.handleDungeonSelectUpper(leader, body); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
characterID string
connection *bufferConn
admissionSlot uint16
}{
{name: "leader", characterID: "99", connection: leaderConn, admissionSlot: 180},
{name: "follower", characterID: "100", connection: followerConn, admissionSlot: 180},
} {
inventory, found, err := repositories.Inventory.Load(context.Background(), test.characterID)
if err != nil || !found {
t.Fatalf("%s admission inventory found=%t err=%v", test.name, found, err)
}
if _, remains := inventory.Slots["0:180"]; remains {
t.Fatalf("%s retained its consumed dungeon admission item", test.name)
}
if !containsAdmissionDeleteUpdateForSlot(t, test.connection.write.Bytes(), test.admissionSlot) {
t.Fatalf("%s did not receive its own final-stack op14 deletion", test.name)
}
}
writeLog.mu.Lock()
orderedWrites := append([]orderedPartyWrite(nil), writeLog.writes...)
writeLog.mu.Unlock()
preparedBeforeRoom := make(map[string]bool, 2)
roomPhaseSeen := false
for _, write := range orderedWrites {
packet, trailing := splitGameServerUpperPacket(t, write.data)
if len(trailing) != 0 || packet.Header.Classification != 0 {
continue
}
switch packet.Header.MsgID {
case currentDungeonInfoNotification:
if roomPhaseSeen {
t.Fatalf("%s op28 arrived after initial room release", write.connection)
}
preparedBeforeRoom[write.connection] = true
case currentTownActorSceneSnapshotMsgID, currentDungeonStartNotification:
roomPhaseSeen = true
if !preparedBeforeRoom["leader"] || !preparedBeforeRoom["follower"] {
t.Fatalf("first op%d arrived before both members completed op28: %+v",
packet.Header.MsgID, preparedBeforeRoom)
}
}
}
if !roomPhaseSeen {
t.Fatal("party dungeon entry never released op800/op29")
}
actorProjectionBodies := func(stream []byte) [][]byte {
packets := splitAllCurrentGameServerUpperPackets(t, stream)
bodies := make([][]byte, 0, 2)
for _, packet := range packets {
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) &&
len(packet.Body) > 0 && (packet.Body[0] == 0 || packet.Body[0] == 1) {
bodies = append(bodies, append([]byte(nil), packet.Body...))
}
}
return bodies
}
leaderActorProjection := actorProjectionBodies(leaderConn.write.Bytes())
followerActorProjection := actorProjectionBodies(followerConn.write.Bytes())
if len(leaderActorProjection) != 2 || len(followerActorProjection) != 2 ||
!bytes.Equal(leaderActorProjection[0], followerActorProjection[0]) ||
!bytes.Equal(leaderActorProjection[1], followerActorProjection[1]) {
t.Fatalf("party actor projection differs by receiver: leader=%d follower=%d", len(leaderActorProjection), len(followerActorProjection))
}
leaderOwnerProjection, leaderProjectionFound := currentPartyActorProjectionSnapshot(
leader,
99,
leader.characterGeneration,
)
followerOwnerProjection, followerProjectionFound := currentPartyActorProjectionSnapshot(
follower,
100,
follower.characterGeneration,
)
if !leaderProjectionFound || !followerProjectionFound {
t.Fatal("party entry lost an owner-produced actor projection")
}
wantMode0 := append([]byte{0, 2, 0}, leaderOwnerProjection.mode0[3:]...)
wantMode0 = append(wantMode0, followerOwnerProjection.mode0[3:]...)
wantMode1 := append([]byte{1, 2, 0}, leaderOwnerProjection.mode1[3:]...)
wantMode1 = append(wantMode1, followerOwnerProjection.mode1[3:]...)
if !bytes.Equal(leaderActorProjection[0], wantMode0) ||
!bytes.Equal(leaderActorProjection[1], wantMode1) {
t.Fatal("party entry rebuilt owner-produced actor bytes instead of composing the frozen rows")
}
entryOwner := leader.dungeon.runtime.partyDungeonOwner
if entryOwner == nil {
t.Fatal("party dungeon entry has no shared owner")
}
entryOwner.mu.Lock()
retainedEntryContinuations := len(entryOwner.entryContinuations)
entryOwner.mu.Unlock()
if retainedEntryContinuations != 0 {
t.Fatalf("released party entry barrier retained %d old session/runtime continuations", retainedEntryContinuations)
}
firstProjection, err := service.runtimePartyDungeonActorProjectionForOwner(context.Background(), leader, entryOwner)
if err != nil {
t.Fatal(err)
}
firstProjection.mode0[0] ^= 0xff
secondProjection, err := service.runtimePartyDungeonActorProjectionForOwner(context.Background(), follower, entryOwner)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(secondProjection.mode0, leaderActorProjection[0]) ||
!bytes.Equal(secondProjection.mode1, leaderActorProjection[1]) {
t.Fatal("party actor projection cache leaked a receiver mutation")
}
for _, test := range []struct {
name string
session *gameSession
connection *bufferConn
index byte
}{
{name: "leader", session: leader, connection: leaderConn, index: 0},
{name: "follower", session: follower, connection: followerConn, index: 1},
} {
if test.session.dungeon.runtime == nil || !test.session.dungeon.runtime.partyMemberIndexed ||
test.session.dungeon.runtime.partyMemberIndex != test.index {
t.Fatalf("%s runtime party index=%d indexed=%t", test.name,
currentDungeonRuntimePartyMemberIndex(test.session.dungeon.runtime),
test.session.dungeon.runtime != nil && test.session.dungeon.runtime.partyMemberIndexed)
}
if test.session.dungeon.runtime.Seed != 0x12345678 {
t.Fatalf("%s seed=%08x", test.name, test.session.dungeon.runtime.Seed)
}
assertDungeonStartMapPartyIndex(t, test.connection.write.Bytes(), 0xff)
packets := splitAllCurrentGameServerUpperPackets(t, test.connection.write.Bytes())
op9Count := 0
op9AfterOp29 := false
op800BeforeOp29 := false
seenOp800 := false
seenOp29 := false
var roomSlotTable []byte
messages := make([]uint16, 0, len(packets))
for _, packet := range packets {
if packet.Header.Classification == 0 && packet.Header.MsgID == currentTownUserLeaveMsgID {
t.Fatalf("%s received party peer town op6 during shared dungeon admission body=%x", test.name, packet.Body)
}
messages = append(messages, packet.Header.MsgID)
switch packet.Header.MsgID {
case uint16(dnfenum.CmdPacketRecoverStamina):
op9Count++
op9AfterOp29 = seenOp29
roomSlotTable = packet.Body
case currentTownActorSceneSnapshotMsgID:
seenOp800 = true
case currentDungeonStartNotification:
op800BeforeOp29 = seenOp800
seenOp29 = true
}
}
// The prelude itself carries no op9 (the captured party flow has none
// there); the single op9 is the room slot table, sent after op29 once
// every party actor exists in the receiver's room. It is what arms the
// client's host arbitration (sceneActorManager+0x48/+0x68).
if op9Count != 1 || !op9AfterOp29 || !op800BeforeOp29 {
t.Fatalf("%s party entry op9=%d op9_after_op29=%t op800_before_op29=%t", test.name, op9Count, op9AfterOp29, op800BeforeOp29)
}
assertPartyDungeonRoomSlotTable(t, test.name, roomSlotTable, currentSceneActorObjectKey(test.session.selectedCharacterID),
map[byte]uint16{0: 99, 1: 100}, 0)
wantPrelude := []uint16{
currentTownUserAreaNotificationMsgID,
uint16(dnfenum.CmdPacketSetUDPIPPort),
currentSkillInfoMsgID,
uint16(dnfenum.CmdPacketSetUDPIPPort),
11,
uint16(dnfenum.CmdPacketNotifyUserState),
26,
currentDungeonInfoNotification,
currentTownActorSceneSnapshotMsgID,
currentDungeonStartNotification,
currentFatigueMsgID,
}
if !containsUint16Sequence(messages, wantPrelude) {
t.Fatalf("%s party entry sequence=%v missing=%v", test.name, messages, wantPrelude)
}
// Pin the other half of the settlement-retry gate: a party entry by a member
// that ALREADY has the dungeon context must not carry op27 again inside the
// shared-dungeon prelude. A follower's own follower-selector context
// (op36 + op27, before op5) is legitimate and is not what this guards; the
// packet this guards is the one emitRuntimePartyDungeonStart inserts after
// the op26 leader-slot notification. MsgID 26 is that notification (sent as
// a literal 26).
//
// The condition is the LATCH, not "ordinary vs 再次挑战":
// * member already has the context (this fixture: the leader came through
// the op15 selector page, latch true) -> nothing to insert;
// * 再次挑战 (retryFromSettlement) -> insert every time, latch or not;
// * member has NEVER had the context (latch false) -> insert. That is the
// server-driven entry: Fiend War's encounter is triggered by op36 walking
// into an area, so the driver never opens a selector page. Live
// 2026-10-01 23:07, without it the driver's client never switched dungeon
// context and never sent op37, i.e. it hung on the loading screen while
// the follower loaded fine. Covered by
// TestRealPVFFiendWarDifferentSquadsSharingOneCellEnterTogether.
seenLeaderSlotNotification := false
for _, message := range messages {
if message == 26 {
seenLeaderSlotNotification = true
continue
}
if seenLeaderSlotNotification && message == currentDungeonContextMsgID {
t.Fatalf("%s ordinary party entry emitted op27 inside the dungeon-start prelude sequence=%v",
test.name, messages)
}
}
prebindIndex, mode1Index := -1, -1
for packetIndex, packet := range packets {
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSkillInfoMsgID {
prebindIndex = packetIndex
slots := currentSkillInfoFirstTreeSlots(t, packet.Body)
foundFearAura := false
for _, skillID := range slots {
foundFearAura = foundFearAura || int64(skillID) == currentSoulBenderFearAuraSkillID
}
if !foundFearAura {
t.Fatalf("%s party Fear Aura prebind slots=%v", test.name, slots)
}
}
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) &&
len(packet.Body) > 0 && packet.Body[0] == 1 {
mode1Index = packetIndex
}
}
if prebindIndex < 0 || mode1Index < 0 || prebindIndex >= mode1Index {
t.Fatalf("%s party Fear Aura prebind=%d mode1=%d", test.name, prebindIndex, mode1Index)
}
if test.name == "follower" {
assertDungeonPartyFollowerHasNoUnsolicitedSelectAck(t, test.connection.write.Bytes())
assertDungeonPartyFollowerPrerequisitePrecedesRuntime(t, test.connection.write.Bytes())
}
if resident := service.onlinePlayers.SessionForCharacter(test.session.selectedCharacterID); resident != nil {
t.Fatalf("%s retained town presence after dungeon entry: %p", test.name, resident)
}
}
seenRetirements := map[uint16]bool{}
observerStream := observerConn.write.Bytes()
observerPackets := 0
for len(observerStream) > 0 {
packet, rest := splitLongHengGameServerUpperPacket(t, observerStream)
observerPackets++
if packet.Header.Classification != 0 || packet.Header.MsgID != currentTownUserLeaveMsgID || len(packet.Body) < 2 {
t.Fatalf("non-party observer transition packet=%+v body=%x", packet.Header, packet.Body)
}
seenRetirements[binary.LittleEndian.Uint16(packet.Body[:2])] = true
observerStream = rest
}
if observerPackets != 2 {
t.Fatalf("non-party observer dungeon-transition packets=%d want=2 stream=%x", observerPackets, observerConn.write.Bytes())
}
if !seenRetirements[99] || !seenRetirements[100] {
t.Fatalf("non-party observer retirements=%v want leader and follower", seenRetirements)
}
leaderConn.write.Reset()
followerConn.write.Reset()
writeLog.mu.Lock()
writeLog.writes = nil
writeLog.mu.Unlock()
if err := service.sendFinishLoadingStatus(leader, make([]byte, currentFinishLoadingLegacyRequestBodySize)); err != nil {
t.Fatal(err)
}
if got := splitAllCurrentGameServerUpperPackets(t, leaderConn.write.Bytes()); len(got) != 1 ||
got[0].Header.Classification != dnfproto.DefaultChannelClassification ||
got[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) {
t.Fatalf("first ready must receive ACK only: %+v", got)
}
if followerConn.write.Len() != 0 {
t.Fatalf("unready follower received premature release=%x", followerConn.write.Bytes())
}
if err := service.sendFinishLoadingStatus(follower, make([]byte, currentFinishLoadingLegacyRequestBodySize)); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
connection *bufferConn
}{
{name: "leader", connection: leaderConn},
{name: "follower", connection: followerConn},
} {
packets := splitAllCurrentGameServerUpperPackets(t, test.connection.write.Bytes())
messages := make([]uint16, 0, len(packets))
for _, packet := range packets {
messages = append(messages, packet.Header.MsgID)
}
want := []uint16{
uint16(dnfenum.CmdPacketFinishLoading),
uint16(dnfenum.CmdPacketRequestBlacklist),
uint16(dnfenum.CmdPacketRequestBlacklist),
uint16(dnfenum.CmdPacketNotifyUserState),
uint16(dnfenum.CmdPacketFinishLoading),
currentIncreaseStatusResultMsgID,
currentSkillInfoMsgID,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
skillSwitchInventoryAllDataWire,
currentDungeonConsumableUseGateResetWireOpcode,
}
if !equalUint16Values(messages, want) {
t.Fatalf("%s shared loading sequence=%v want=%v", test.name, messages, want)
}
finalSkills := packets[6]
if finalSkills.Header.MsgID != currentSkillInfoMsgID {
t.Fatalf("%s first-room final skill packet=op%d", test.name, finalSkills.Header.MsgID)
}
foundFearAura := false
for _, skillID := range currentSkillInfoFirstTreeSlots(t, finalSkills.Body) {
foundFearAura = foundFearAura || int64(skillID) == currentSoulBenderFearAuraSkillID
}
if !foundFearAura {
t.Fatalf("%s first-room final op19 omitted Fear Aura", test.name)
}
if !bytes.Equal(packets[1].Body, []byte{0, 0}) || !bytes.Equal(packets[2].Body, []byte{1, 0}) {
t.Fatalf("%s stable placements=%x/%x", test.name, packets[1].Body, packets[2].Body)
}
chronicleRefresh := packets[len(packets)-3]
if len(chronicleRefresh.Body) != 3+currentItemListEntryWireSize ||
chronicleRefresh.Body[0] != 0 ||
binary.LittleEndian.Uint16(chronicleRefresh.Body[1:3]) != 1 ||
chronicleRefresh.Body[3+0x27] != 1 ||
binary.LittleEndian.Uint32(chronicleRefresh.Body[3+0x28:3+0x2c]) != currentChronicleCanonicalAuraBase+2 {
t.Fatalf("%s registered Chronicle refresh body=%x", test.name, chronicleRefresh.Body)
}
if wantBody := []byte{46, 0, 1, 1, 12, 0, 17, 0}; !bytes.Equal(packets[len(packets)-2].Body, wantBody) {
t.Fatalf("%s party BUFF registration body=%x want=%x", test.name, packets[len(packets)-2].Body, wantBody)
}
if reset := packets[len(packets)-1]; reset.Header.MsgID != currentDungeonConsumableUseGateResetWireOpcode ||
reset.Header.Classification != 0 || len(reset.Body) != 0 {
t.Fatalf("%s final consumable gate reset=%+v body=%x", test.name, reset.Header, reset.Body)
}
}
writeLog.mu.Lock()
loadingWrites := append([]orderedPartyWrite(nil), writeLog.writes...)
writeLog.mu.Unlock()
followerFinalIndex, leaderReleaseIndex := -1, -1
for index, write := range loadingWrites {
packet, trailing := splitGameServerUpperPacket(t, write.data)
if len(trailing) != 0 || packet.Header.Classification != 0 {
continue
}
if write.connection == "follower" && packet.Header.MsgID == currentDungeonConsumableUseGateResetWireOpcode {
followerFinalIndex = index
}
if write.connection == "leader" && packet.Header.MsgID == uint16(dnfenum.CmdPacketRequestBlacklist) && leaderReleaseIndex < 0 {
leaderReleaseIndex = index
}
}
if followerFinalIndex < 0 || leaderReleaseIndex < 0 || followerFinalIndex >= leaderReleaseIndex {
t.Fatalf("party loading activated leader before follower completed: follower_final=%d leader_release=%d writes=%d",
followerFinalIndex, leaderReleaseIndex, len(loadingWrites))
}
// A room transition must not replay op19. The current client rebuilds its
// complete skill manager when op19 arrives, which clears live P2P skill
// effects (for example a teammate's Courage Blessing). The second and later
// party loading cycles preserve that manager just like ordinary solo room
// moves, while still publishing every room-ready state packet.
leaderConn.write.Reset()
followerConn.write.Reset()
resetDungeonEntrySceneGates(leader)
resetDungeonEntrySceneGates(follower)
if cycle := entryOwner.beginRoomLoading(); cycle != 2 {
t.Fatalf("second party loading cycle=%d want=2", cycle)
}
if err := service.sendFinishLoadingStatus(leader, make([]byte, currentFinishLoadingLegacyRequestBodySize)); err != nil {
t.Fatal(err)
}
if err := service.sendFinishLoadingStatus(follower, make([]byte, currentFinishLoadingLegacyRequestBodySize)); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
session *gameSession
connection *bufferConn
}{
{name: "leader", session: leader, connection: leaderConn},
{name: "follower", session: follower, connection: followerConn},
} {
packets := splitAllCurrentGameServerUpperPackets(t, test.connection.write.Bytes())
messages := make([]uint16, 0, len(packets))
for _, packet := range packets {
messages = append(messages, packet.Header.MsgID)
if packet.Header.Classification == 0 && (packet.Header.MsgID == currentSkillInfoMsgID ||
packet.Header.MsgID == uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
packet.Header.MsgID == skillSwitchInventoryAllDataWire) {
t.Fatalf("%s room-transition loading rebuilt skill/Buff manager with op%d body=%x",
test.name, packet.Header.MsgID, packet.Body)
}
}
want := []uint16{
uint16(dnfenum.CmdPacketFinishLoading),
uint16(dnfenum.CmdPacketRequestBlacklist),
uint16(dnfenum.CmdPacketRequestBlacklist),
uint16(dnfenum.CmdPacketNotifyUserState),
uint16(dnfenum.CmdPacketFinishLoading),
currentIncreaseStatusResultMsgID,
}
if !equalUint16Values(messages, want) {
t.Fatalf("%s room-transition loading sequence=%v want=%v", test.name, messages, want)
}
if !test.session.dungeonSceneSkillInfoSent || !test.session.postFinishLoadingPlayerStateSent {
t.Fatalf("%s room-transition lifecycle flags skill=%t post=%t",
test.name, test.session.dungeonSceneSkillInfoSent, test.session.postFinishLoadingPlayerStateSent)
}
}
// Member death is one shared room fact. A single death must not start the
// party wipe transition, self-revive clears the shared slot, and only the
// final dead slot aligns every member to the reference server's 10s edge.
leaderConn.write.Reset()
followerConn.write.Reset()
if err := service.handleDungeonDieCharacter(leader, make([]byte, currentDungeonDieCharacterBodySize)); err != nil {
t.Fatal(err)
}
owner := leader.dungeon.runtime.partyDungeonOwner
if owner == nil || owner.allMembersDead() || owner.deadSlots != 1 {
t.Fatalf("single party death owner=%p dead_slots=%08b all_dead=%t", owner, owner.deadSlots, owner.allMembersDead())
}
for _, test := range []struct {
name string
connection *bufferConn
}{
{name: "leader", connection: leaderConn},
{name: "follower", connection: followerConn},
} {
packets := splitAllCurrentGameServerUpperPackets(t, test.connection.write.Bytes())
if len(packets) != 1 || packets[0].Header.MsgID != currentDungeonDeathStateMsgID ||
!bytes.Equal(packets[0].Body, buildCurrentDungeonDeathStateBody(99, 0, 0)) {
t.Fatalf("%s shared death projection=%x", test.name, test.connection.write.Bytes())
}
}
revive := make([]byte, currentDungeonUseCoinBodySize)
binary.LittleEndian.PutUint16(revive, 99)
leaderConn.write.Reset()
followerConn.write.Reset()
if err := service.handleDungeonUseCoin(leader, revive); err != nil {
t.Fatal(err)
}
if owner.deadSlots != 0 || owner.allMembersDead() {
t.Fatalf("party revive dead_slots=%08b all_dead=%t", owner.deadSlots, owner.allMembersDead())
}
followerPackets := splitAllCurrentGameServerUpperPackets(t, followerConn.write.Bytes())
if len(followerPackets) != 1 || followerPackets[0].Header.MsgID != currentDungeonDeathStateMsgID ||
!bytes.Equal(followerPackets[0].Body, buildCurrentDungeonDeathStateBody(99, 1, 0)) {
t.Fatalf("follower revive projection=%x", followerConn.write.Bytes())
}
leaderConn.write.Reset()
followerConn.write.Reset()
if err := service.handleDungeonDieCharacter(leader, make([]byte, currentDungeonDieCharacterBodySize)); err != nil {
t.Fatal(err)
}
if err := service.handleDungeonDieCharacter(follower, make([]byte, currentDungeonDieCharacterBodySize)); err != nil {
t.Fatal(err)
}
if !owner.allMembersDead() || owner.deadSlots != 3 {
t.Fatalf("party wipe dead_slots=%08b all_dead=%t", owner.deadSlots, owner.allMembersDead())
}
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
queue.mu.Lock()
activeDelays := make([]time.Duration, 0, len(queue.active))
for _, task := range queue.active {
activeDelays = append(activeDelays, task.delay)
}
queue.mu.Unlock()
if len(activeDelays) != 2 {
t.Fatalf("party wipe active timers=%v", activeDelays)
}
for _, delay := range activeDelays {
if delay != currentPartyDungeonAllDeadReturnDelay {
t.Fatalf("party wipe timer delay=%s want=%s", delay, currentPartyDungeonAllDeadReturnDelay)
}
}
for _, test := range []struct {
session *gameSession
target uint16
}{
{session: leader, target: 99},
{session: follower, target: 100},
} {
body := make([]byte, currentDungeonUseCoinBodySize)
binary.LittleEndian.PutUint16(body, test.target)
if err := service.handleDungeonUseCoin(test.session, body); err != nil {
t.Fatal(err)
}
}
if owner.deadSlots != 0 {
t.Fatalf("party wipe revive dead_slots=%08b", owner.deadSlots)
}
leaderConn.write.Reset()
followerConn.write.Reset()
roomMonsters := leader.dungeon.runtime.Room.Snapshot().Monsters
if len(roomMonsters) != 0 {
if err := service.handleDungeonMonsterDeath(
leader,
ordinaryTutorialDeathBody(roomMonsters[0].ObjectKey, leader.selectedCharacterID),
); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
session *gameSession
connection *bufferConn
}{
{name: "leader", session: leader, connection: leaderConn},
{name: "follower", session: follower, connection: followerConn},
} {
if got := test.session.dungeon.runtime.Room.Snapshot().Monsters[0].State; got != runtimeDungeonMonsterDefeated {
t.Fatalf("%s shared monster state=%s", test.name, got)
}
packets := splitAllCurrentGameServerUpperPackets(t, test.connection.write.Bytes())
foundDeath := false
for _, packet := range packets {
if packet.Header.MsgID == uint16(dnfenum.CmdPacketNotifyDieMonster) {
foundDeath = true
break
}
}
if !foundDeath {
t.Fatalf("%s received no shared op38 death projection: %x", test.name, test.connection.write.Bytes())
}
}
if err := service.handleDungeonBossDieCheck(
leader,
bossDieCheckRequestBody(leader.selectedCharacterID, uint16(roomMonsters[0].ObjectKey)),
); err != nil {
t.Fatal(err)
}
for _, runtime := range []*runtimeDungeonState{leader.dungeon.runtime, follower.dungeon.runtime} {
runtime.settlementPlayResultReceived = true
runtime.settlementStatisticReceived = true
markCurrentDungeonCardRewardCommittedForTest(runtime)
}
leaderConn.write.Reset()
followerConn.write.Reset()
if err := service.handleDungeonEplpCommand(leader, []byte{1, 1}); err != nil {
t.Fatal(err)
}
if leader.dungeon.runtime != nil || follower.dungeon.runtime != nil {
t.Fatalf("party select-other retained runtimes leader=%p follower=%p", leader.dungeon.runtime, follower.dungeon.runtime)
}
leaderPackets := splitAllCurrentGameServerUpperPackets(t, leaderConn.write.Bytes())
followerPackets := splitAllCurrentGameServerUpperPackets(t, followerConn.write.Bytes())
if len(leaderPackets) == 0 || leaderPackets[0].Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) {
t.Fatalf("leader party settlement selector stream=%x", leaderConn.write.Bytes())
}
if len(followerPackets) == 0 || followerPackets[0].Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) {
t.Fatalf("follower party settlement selector stream=%x", followerConn.write.Bytes())
}
followerSwitchedToSelector := false
for _, packet := range followerPackets {
if packet.Header.MsgID == currentFatigueMsgID {
followerSwitchedToSelector = true
}
}
if !followerSwitchedToSelector {
// 选择其他地下城 takes the whole party to the selector page. Retiring the
// member's run without that follower sequence left it holding the
// settlement page inside the instance -- live 2026-09-19 in an ordinary
// party and again in an Anton gate, where its own op15 answered
// raid_member_not_in_private_waiting_instance, 返回城镇 answered "Anton
// selector origin is not bound", and only a client restart got it out.
t.Fatalf("follower was never moved to the selector page: %x", followerConn.write.Bytes())
}
for _, packet := range followerPackets {
if packet.Header.MsgID == uint16(dnfenum.CmdPacketEnterSelectDungeon) {
t.Fatalf("follower received fabricated op15 ACK: %x", followerConn.write.Bytes())
}
}
}
}
// assertPartyDungeonRoomSlotTable parses a dungeon-room op9 kind-0 record the
// way buildCurrentSceneOp9DungeonRoomSlotTableBodyInContext writes it - fixed
// prefix, the receiver's name DSTR, the remaining header, then the rows - and
// pins the fields the client's host arbitration keys off: the record is keyed
// by the receiver's own actor in owner context 0, every member row is the
// extended form at its real slot with the present marker set, and the tail
// carries the shared leader slot.
func assertPartyDungeonRoomSlotTable(
t *testing.T,
name string,
body []byte,
receiverKey uint16,
wantRows map[byte]uint16,
leaderSlot byte,
) {
t.Helper()
reader := body
need := func(n int) []byte {
if len(reader) < n {
t.Fatalf("%s room slot table truncated at offset %d: %x", name, len(body)-len(reader), body)
}
chunk := reader[:n]
reader = reader[n:]
return chunk
}
u8 := func() byte { return need(1)[0] }
u16 := func() uint16 { return binary.LittleEndian.Uint16(need(2)) }
u32 := func() uint32 { return binary.LittleEndian.Uint32(need(4)) }
dstr := func() {
length := int(int32(u32()))
if length < 0 || length > 64 {
t.Fatalf("%s room slot table DSTR length=%d body=%x", name, length, body)
}
need(length)
}
if count := u16(); count != 1 {
t.Fatalf("%s room slot table record count=%d", name, count)
}
if scene := u16(); scene != currentSceneOp9StableSceneValue {
t.Fatalf("%s room slot table scene=%d", name, scene)
}
if key := u16(); key != receiverKey {
t.Fatalf("%s room slot table record key=%d want receiver actor %d", name, key, receiverKey)
}
u8()
if owner := u8(); owner != currentSceneObjectContext {
t.Fatalf("%s room slot table owner context=%d want dungeon context %d", name, owner, currentSceneObjectContext)
}
u8()
if kind := u8(); kind != currentSceneOp9ActorDisplayKind {
t.Fatalf("%s room slot table record kind=%d", name, kind)
}
u8() // route
if context := u8(); context != currentSceneObjectContext {
t.Fatalf("%s room slot table inline identity discriminator=%d", name, context)
}
dstr() // receiver name
u8() // job
u8() // member cap
u32()
u8() // grow
u16()
u8()
u8() // party type
u16()
rowCount := int(u8())
if rowCount != len(wantRows) {
t.Fatalf("%s room slot table rows=%d want=%d body=%x", name, rowCount, len(wantRows), body)
}
seen := map[byte]uint16{}
for index := 0; index < rowCount; index++ {
slot := u8()
uid := u16()
u8() // job
if level := u8(); level == 0 {
t.Fatalf("%s room slot table row slot=%d carries level 0", name, slot)
}
u8() // grow
u8() // flag
dstr()
u8()
if state := u8(); state != 100 {
t.Fatalf("%s room slot table row slot=%d state=%d want 100", name, slot, state)
}
if present := u8(); present != currentSceneOp9PartyMemberSlotPresent {
t.Fatalf("%s room slot table row slot=%d present marker=%d", name, slot, present)
}
if _, duplicate := seen[slot]; duplicate {
t.Fatalf("%s room slot table repeats slot %d", name, slot)
}
seen[slot] = uid
}
for slot, uid := range wantRows {
if seen[slot] != uid {
t.Fatalf("%s room slot table slot %d uid=%d want=%d rows=%v", name, slot, seen[slot], uid, seen)
}
}
u8()
if got := u8(); got != leaderSlot {
t.Fatalf("%s room slot table host slot=%d want=%d", name, got, leaderSlot)
}
u8()
u8()
if len(reader) != 0 {
t.Fatalf("%s room slot table has %d trailing bytes: %x", name, len(reader), reader)
}
}
func equalUint16Values(left, right []uint16) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func containsUint16Sequence(values, sequence []uint16) bool {
if len(sequence) == 0 {
return true
}
for start := 0; start+len(sequence) <= len(values); start++ {
if equalUint16Values(values[start:start+len(sequence)], sequence) {
return true
}
}
return false
}
func TestPartyLeaderDungeonRoomMoveSynchronizesEveryMemberAndLeaderSeed(t *testing.T) {
source := bridgeDungeonMovePVF(false)
source[bridgeDungeonMoveDungeonPath] = strings.Replace(
source[bridgeDungeonMoveDungeonPath],
"[limit party count]\n1\n",
"[limit party count]\n4\n",
1,
)
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
for _, id := range []string{"99", "100"} {
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: "account-1",
Job: atSwordmanTutorialJob,
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: id, Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
}
seedCalls := uint32(0)
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) {
seedCalls++
return 0x1000 + seedCalls, nil
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
enableTownMoveSkillProjection(t, service, repositories, "99")
enableTownMoveSkillProjection(t, service, repositories, "100")
channel := channelcatalog.Channel{ServerID: 1, ID: 42, Type: 11, Name: "ch.42", Port: 10042}
leaderConn, followerConn := &bufferConn{}, &bufferConn{}
leader := &gameSession{conn: leaderConn, channel: channel, residentChannel: channel, selectedCharacterID: 99}
follower := &gameSession{conn: followerConn, channel: channel, residentChannel: channel, selectedCharacterID: 100}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(follower, 100)
seedPartyOwnerActorProjectionForTest(t, service, leader, 99, 42)
seedPartyOwnerActorProjectionForTest(t, service, follower, 100, 42)
registerPartyPeerEndpointForTest(leader, 3, 5063)
registerPartyPeerEndpointForTest(follower, 3, 60359)
state := alignedcmd.PartyState{
PartyID: 99, UserID: 99,
Members: []alignedcmd.PartyMemberState{
{UserID: 99, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 100, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
storeRuntimePartyState(leader, state)
storeRuntimePartyState(follower, state)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 99, TownID: 38, AreaID: 1, Session: leader})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 100, TownID: 38, AreaID: 1, Session: follower})
bindDungeonSelectorOriginForTestAt(t, service, leader, 38, 1, 450, 234)
bindDungeonSelectorOriginForTestAt(t, service, follower, 38, 1, 450, 234)
selectBody := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(selectBody[:4], 700)
if err := service.handleDungeonSelectUpper(leader, selectBody); err != nil {
t.Fatal(err)
}
if seedCalls != 1 {
t.Fatalf("party entry independently rolled follower seed calls=%d", seedCalls)
}
leaderConn.write.Reset()
followerConn.write.Reset()
moveBody := make([]byte, dungeoncmd.MoveMapRequestSize)
moveBody[0] = 1
if err := service.handleDungeonMoveMap(leader, moveBody); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
session *gameSession
connection *bufferConn
}{
{name: "leader", session: leader, connection: leaderConn},
{name: "follower", session: follower, connection: followerConn},
} {
snapshot := test.session.dungeon.runtime.Session.Snapshot()
if snapshot.Scene.Coordinate != (worldmap.RoomCoordinate{X: 1, Y: 0}) ||
snapshot.Scene.Map.Map.ID != 101 || test.session.dungeon.runtime.Seed != 0x1002 {
t.Fatalf("%s move snapshot=%+v seed=%x", test.name, snapshot, test.session.dungeon.runtime.Seed)
}
packet, trailing := splitGameServerUpperPacket(t, test.connection.write.Bytes())
trailing = consumeOptionalCurrentDungeonFatigueRefreshForTest(t, trailing)
if packet.Header.Classification != 0 || packet.Header.MsgID != currentDungeonStartNotification || len(trailing) != 0 {
t.Fatalf("%s move packet=%+v trailing=%x", test.name, packet.Header, trailing)
}
}
if seedCalls != 3 {
t.Fatalf("room move seed chooser calls=%d want leader+discarded follower=3", seedCalls)
}
}
func TestPartyDungeonEntryRejectsEveryMemberBeforeCommitWhenFollowerLevelIsTooLow(t *testing.T) {
source := bridgeDungeonPVF(false)
source["dungeon/test.dgn"] = strings.Replace(source["dungeon/test.dgn"], "[limit party count]\n1\n", "[limit party count]\n4\n", 1)
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
for id, level := range map[string]int{"99": 20, "100": 5} {
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: "account-1",
Level: level,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: id, Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
channel := channelcatalog.Channel{ServerID: 1, ID: 42, Type: 11, Name: "ch.42", Port: 10042}
leaderConn, followerConn := &bufferConn{}, &bufferConn{}
leader := &gameSession{conn: leaderConn, channel: channel, residentChannel: channel, selectedCharacterID: 99}
follower := &gameSession{conn: followerConn, channel: channel, residentChannel: channel, selectedCharacterID: 100}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(follower, 100)
state := alignedcmd.PartyState{
PartyID: 99,
UserID: 99,
Members: []alignedcmd.PartyMemberState{
{UserID: 99, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 100, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
storeRuntimePartyState(leader, state)
storeRuntimePartyState(follower, state)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 99, TownID: 38, AreaID: 1, Session: leader})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 100, TownID: 38, AreaID: 1, Session: follower})
bindDungeonSelectorOriginForTestAt(t, service, leader, 38, 1, 450, 234)
bindDungeonSelectorOriginForTestAt(t, service, follower, 38, 1, 450, 234)
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(body[:4], 700)
if err := service.handleDungeonSelectUpper(leader, body); err != nil {
t.Fatal(err)
}
if leader.dungeon.runtime != nil || follower.dungeon.runtime != nil {
t.Fatalf("party entry partially committed leader_runtime=%p follower_runtime=%p", leader.dungeon.runtime, follower.dungeon.runtime)
}
packet, trailing := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) ||
!bytes.Equal(packet.Body, buildSelectDungeonFailureBody(currentSelectDungeonPreparationRejectedReason)) ||
len(trailing) != 0 {
t.Fatalf("blocked party entry rejection packet=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
}
if followerConn.write.Len() != 0 {
t.Fatalf("blocked party entry wrote unsolicited follower packets=%x", followerConn.write.Bytes())
}
if service.onlinePlayers.SessionForCharacter(99) != leader || service.onlinePlayers.SessionForCharacter(100) != follower {
t.Fatal("blocked party entry removed a town presence")
}
}
func TestPartyDungeonEntryRejectsBeforeCommitWhenNativePeerEndpointIsIncomplete(t *testing.T) {
source := bridgeDungeonPVF(false)
source["dungeon/test.dgn"] = strings.Replace(source["dungeon/test.dgn"], "[limit party count]\n1\n", "[limit party count]\n4\n", 1)
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
accounts := map[string]string{"99": "account-leader", "100": "account-follower"}
for _, id := range []string{"99", "100"} {
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: accounts[id],
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: id, Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
}
service := &Service{
options: options{accountID: "fallback-account", gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
channel := channelcatalog.Channel{ServerID: 1, ID: 42, Type: 11, Name: "ch.42", Port: 10042}
leaderConn, followerConn := &bufferConn{}, &bufferConn{}
leader := &gameSession{conn: leaderConn, accountID: accounts["99"], channel: channel, residentChannel: channel, selectedCharacterID: 99}
follower := &gameSession{conn: followerConn, accountID: accounts["100"], channel: channel, residentChannel: channel, selectedCharacterID: 100}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(follower, 100)
seedPartyOwnerActorProjectionForTest(t, service, leader, 99, 42)
seedPartyOwnerActorProjectionForTest(t, service, follower, 100, 42)
registerPartyPeerEndpointForTest(leader, 3, 5063)
state := alignedcmd.PartyState{
PartyID: 99,
UserID: 99,
Members: []alignedcmd.PartyMemberState{
{UserID: 99, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 100, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
storeRuntimePartyState(leader, state)
storeRuntimePartyState(follower, state)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 99, TownID: 38, AreaID: 1, Session: leader})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 100, TownID: 38, AreaID: 1, Session: follower})
bindDungeonSelectorOriginForTestAt(t, service, leader, 38, 1, 450, 234)
bindDungeonSelectorOriginForTestAt(t, service, follower, 38, 1, 450, 234)
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(body[:4], 700)
if err := service.handleDungeonSelectUpper(leader, body); err != nil {
t.Fatal(err)
}
if leader.dungeon.runtime != nil || follower.dungeon.runtime != nil {
t.Fatalf("endpoint rejection partially committed leader_runtime=%p follower_runtime=%p", leader.dungeon.runtime, follower.dungeon.runtime)
}
packet, trailing := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) ||
!bytes.Equal(packet.Body, buildSelectDungeonFailureBody(currentSelectDungeonPreparationRejectedReason)) ||
len(trailing) != 0 {
t.Fatalf("endpoint rejection packet=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
}
for stream := followerConn.write.Bytes(); len(stream) != 0; {
followerPacket, rest := splitGameServerUpperPacket(t, stream)
if followerPacket.Header.MsgID == uint16(dnfenum.CmdPacketSelectDungeon) ||
followerPacket.Header.MsgID == currentDungeonInfoNotification ||
followerPacket.Header.MsgID == currentDungeonStartNotification {
t.Fatalf("endpoint rejection advanced follower into dungeon packet=%+v body=%x", followerPacket.Header, followerPacket.Body)
}
stream = rest
}
}
func assertDungeonPartyFollowerPrerequisitePrecedesRuntime(t *testing.T, stream []byte) {
t.Helper()
fatigueIndex, contextIndex, infoIndex, startIndex := -1, -1, -1, -1
for index := 0; len(stream) > 0; index++ {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification == 0 {
switch packet.Header.MsgID {
case currentFatigueMsgID:
if fatigueIndex < 0 {
fatigueIndex = index
}
case currentDungeonContextMsgID:
if contextIndex < 0 {
contextIndex = index
}
case currentDungeonInfoNotification:
if infoIndex < 0 {
infoIndex = index
}
case currentDungeonStartNotification:
if startIndex < 0 {
startIndex = index
}
}
}
stream = rest
}
if fatigueIndex < 0 || contextIndex < 0 || infoIndex < 0 || startIndex < 0 ||
!(fatigueIndex < contextIndex && contextIndex < infoIndex && infoIndex < startIndex) {
t.Fatalf("party follower sequence fatigue=%d context=%d dungeon_info=%d start=%d", fatigueIndex, contextIndex, infoIndex, startIndex)
}
}
func assertDungeonPartyFollowerHasNoUnsolicitedSelectAck(t *testing.T, stream []byte) {
t.Helper()
for len(stream) > 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification == dnfproto.DefaultChannelClassification &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketSelectDungeon) {
t.Fatalf("party follower received unsolicited class1 op16 ack body=%x", packet.Body)
}
stream = rest
}
}
func containsAdmissionDeleteUpdateForSlot(t *testing.T, stream []byte, slot uint16) bool {
t.Helper()
for len(stream) > 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
stream = rest
if packet.Header.Classification != 0 ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(packet.Body) < 3+currentItemListEntryWireSize ||
packet.Body[0] != dnfrepo.MainInventoryListType {
continue
}
rowCount := int(binary.LittleEndian.Uint16(packet.Body[1:3]))
for index := 0; index < rowCount; index++ {
offset := 3 + index*currentItemListEntryWireSize
if offset+currentItemListEntryWireSize > len(packet.Body) {
break
}
row := packet.Body[offset : offset+currentItemListEntryWireSize]
if binary.LittleEndian.Uint16(row[0:2]) == slot &&
binary.LittleEndian.Uint32(row[2:6]) == uint32(^uint32(0)) &&
binary.LittleEndian.Uint32(row[6:10]) == 0 {
return true
}
}
}
return false
}
func assertDungeonPartyRealtimeSnapshot(t *testing.T, stream []byte, wantMembers byte) {
t.Helper()
foundRealtime := false
for len(stream) > 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification == 0 && packet.Header.MsgID == 0x0099 && len(packet.Body) > 0 && packet.Body[0] == wantMembers {
if len(packet.Body) != 1+int(wantMembers)*5 {
t.Fatalf("dungeon party realtime body_len=%d want=%d body=%x", len(packet.Body), 1+int(wantMembers)*5, packet.Body)
}
for slot := byte(0); slot < wantMembers; slot++ {
offset := 1 + int(slot)*5
if packet.Body[offset+2] == 0 || packet.Body[offset+4] != slot {
t.Fatalf("dungeon party realtime slot=%d row=%x", slot, packet.Body[offset:offset+5])
}
}
foundRealtime = true
}
stream = rest
}
if !foundRealtime {
t.Fatalf("dungeon party snapshot realtime=%t want_members=%d", foundRealtime, wantMembers)
}
}
func assertDungeonPartyFrameCount(t *testing.T, stream []byte, characterID uint16, wantCount int) {
t.Helper()
wantObjectKey := currentSceneActorObjectKey(characterID)
count := 0
for len(stream) > 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketRecoverStamina) &&
len(packet.Body) >= 10 && packet.Body[9] == currentSceneOp9ActorDisplayKind &&
binary.LittleEndian.Uint16(packet.Body[4:6]) == wantObjectKey {
count++
}
stream = rest
}
if count != wantCount {
t.Fatalf("dungeon local party frame count=%d want=%d object_key=%d", count, wantCount, wantObjectKey)
}
}
func assertDungeonStartMapPartyIndex(t *testing.T, stream []byte, want byte) {
t.Helper()
for len(stream) > 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification == 0 && packet.Header.MsgID == currentDungeonStartNotification {
if len(packet.Body) == 0 || packet.Body[len(packet.Body)-1] != want {
t.Fatalf("op29 party index body_tail=%x want=%d", packet.Body, want)
}
return
}
stream = rest
}
t.Fatal("op29 start-map packet not found")
}
func TestBuildCurrentDungeonUserStateBodyUsesDynamicObjectKey(t *testing.T) {
body, err := buildCurrentDungeonUserStateBody(19)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(body, []byte{1, 0x13, 0x00, currentDungeonPlayerUserState}) {
t.Fatalf("user-state body=%x", body)
}
if _, err := buildCurrentDungeonUserStateBody(0); err == nil {
t.Fatal("zero object key accepted")
}
}
func TestBuildCurrentDungeonEntryPacketsUsesRuntimeAndPVFOwners(t *testing.T) {
coordinate := worldmap.RoomCoordinate{X: 2, Y: 3}
spawn := worldmap.MonsterSpawn{MonsterID: 3001, AutoLevel: 20, Rank: "[normal]"}
room := &runtimeDungeonRoom{
coordinate: coordinate,
mapID: 70000,
monsters: []runtimeDungeonMonster{{
ObjectKey: 402,
Reference: worldmap.HostileReference{Kind: worldmap.HostileMonster, Index: 0},
Spawn: spawn,
State: runtimeDungeonMonsterPlanned,
}},
extendedActors: []runtimeDungeonExtendedActor{{
Kind: runtimeDungeonActorAICharacter,
ObjectKey: 403,
Packet: currentDungeonStartMapActor{
ObjectKey: 403, Code: 4001, Level: 21, Type: 5, Blocking: 0,
},
State: runtimeDungeonMonsterPlanned,
}},
aiCharacterCount: 1,
}
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 900, Difficulty: 2},
Dungeon: worldmap.Dungeon{ID: 900, Mazes: []worldmap.Maze{{}}},
MazeIndex: 0,
Session: &worldmap.DungeonSession{},
Room: room,
BossCoordinate: worldmap.RoomCoordinate{X: 8, Y: 9},
BossSet: true,
Seed: 0x12345678,
}
scene := worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 70000}},
Monsters: []worldmap.MonsterSpawn{spawn},
AICharacters: []worldmap.AICharacter{{Code: 4001, Faction: "[character]", AIType: "[normal]"}},
}
packets, err := buildCurrentDungeonEntryPackets(runtime, scene)
if err != nil {
t.Fatal(err)
}
if len(packets.DungeonInfo) != 36 {
t.Fatalf("dungeon-info len=%d body=%x", len(packets.DungeonInfo), packets.DungeonInfo)
}
if got := binary.LittleEndian.Uint32(packets.DungeonInfo[0:4]); got != 900 {
t.Fatalf("dungeon id=%d", got)
}
if packets.DungeonInfo[4] != 2 || packets.DungeonInfo[7] != 0 || packets.DungeonInfo[8] != 8 || packets.DungeonInfo[9] != 9 {
t.Fatalf("dungeon-info runtime fields=%x", packets.DungeonInfo[:10])
}
if packets.DungeonInfo[10] != 0xff || packets.DungeonInfo[11] != 0xff || binary.LittleEndian.Uint32(packets.DungeonInfo[20:24]) != ^uint32(0) {
t.Fatalf("dungeon-info ordinary sentinels=%x", packets.DungeonInfo)
}
if len(packets.StartMap) != 65 {
t.Fatalf("start-map len=%d body=%x", len(packets.StartMap), packets.StartMap)
}
if packets.StartMap[0] != 2 || packets.StartMap[1] != 3 || binary.LittleEndian.Uint32(packets.StartMap[3:7]) != 0x12345678 {
t.Fatalf("start-map room/seed=%x", packets.StartMap[:7])
}
if got := binary.LittleEndian.Uint32(packets.StartMap[14:18]); got != 70000 || packets.StartMap[18] != 2 {
t.Fatalf("start-map map/count map=%d count=%d", got, packets.StartMap[18])
}
if binary.LittleEndian.Uint16(packets.StartMap[25:27]) != 402 || binary.LittleEndian.Uint32(packets.StartMap[27:31]) != 3001 || packets.StartMap[39] != 0 {
t.Fatalf("normal actor row=%x", packets.StartMap[19:40])
}
if binary.LittleEndian.Uint16(packets.StartMap[46:48]) != 403 || binary.LittleEndian.Uint32(packets.StartMap[48:52]) != 4001 || packets.StartMap[53] != 5 || packets.StartMap[60] != 0 {
t.Fatalf("APC actor row=%x", packets.StartMap[40:61])
}
}
func TestBuildCurrentDungeonEntryPacketsKeepsSingleAntonPVFIdentityInsideDungeon(t *testing.T) {
coordinate := worldmap.RoomCoordinate{X: 0, Y: 0}
const pvfMapID = int64(292101788)
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 5314},
Dungeon: worldmap.Dungeon{ID: 5314, Mazes: []worldmap.Maze{{}}},
clientDungeonID: 210,
MazeIndex: 0,
Session: &worldmap.DungeonSession{},
Room: &runtimeDungeonRoom{coordinate: coordinate, mapID: pvfMapID},
BossCoordinate: coordinate,
BossSet: true,
Seed: 0x12345678,
}
scene := worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: pvfMapID}},
}
packets, err := buildCurrentDungeonEntryPackets(runtime, scene)
if err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint32(packets.DungeonInfo[:4]); got != 5314 {
t.Fatalf("single Anton op28 dungeon id=%d want dedicated PVF dungeon 5314", got)
}
if got := binary.LittleEndian.Uint32(packets.StartMap[14:18]); got != uint32(pvfMapID) {
t.Fatalf("single Anton op29 map id=%d want PVF room %d", got, pvfMapID)
}
if runtime.Request.DungeonID != 5314 || runtime.Dungeon.ID != 5314 {
t.Fatalf("single Anton server identity request=%d dungeon=%d", runtime.Request.DungeonID, runtime.Dungeon.ID)
}
}
func TestBuildCurrentDungeonEntryPacketsRejectsUnownedState(t *testing.T) {
base := func() (*runtimeDungeonState, worldmap.DungeonRoomScene) {
coordinate := worldmap.RoomCoordinate{X: 0, Y: 0}
room := &runtimeDungeonRoom{coordinate: coordinate, mapID: 100}
return &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 700},
Dungeon: worldmap.Dungeon{ID: 700, Mazes: []worldmap.Maze{{}}},
Session: &worldmap.DungeonSession{},
Room: room,
BossCoordinate: coordinate,
BossSet: true,
}, worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 100}},
}
}
tests := []struct {
name string
mutate func(*runtimeDungeonState, *worldmap.DungeonRoomScene)
want error
}{
{name: "request mode", want: errDungeonEntryModeUnsupported, mutate: func(runtime *runtimeDungeonState, _ *worldmap.DungeonRoomScene) { runtime.Request.SpecialMode = 1 }},
{name: "boss missing", want: errDungeonBossCoordinateRequired, mutate: func(runtime *runtimeDungeonState, _ *worldmap.DungeonRoomScene) { runtime.BossSet = false }},
{name: "boss range", want: errDungeonBossCoordinateRange, mutate: func(runtime *runtimeDungeonState, _ *worldmap.DungeonRoomScene) { runtime.BossCoordinate.X = 256 }},
{name: "randomized object", want: errDungeonRandomizedObjectOwner, mutate: func(runtime *runtimeDungeonState, _ *worldmap.DungeonRoomScene) {
runtime.Dungeon.Mazes[0].RandomizedObjects = []worldmap.RandomizedObjectScript{{}}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime, scene := base()
test.mutate(runtime, &scene)
_, err := buildCurrentDungeonEntryPackets(runtime, scene)
if !errors.Is(err, test.want) {
t.Fatalf("error=%v want=%v", err, test.want)
}
})
}
}
// The raw pair mapping alone stays conservative: state=1 + token=UINT16_MAX is
// the 练习模式 switch for an ordinary room but the guided rooms' own button shape
// (修炼场 5000, 转职体验 7142), so only the room-aware
// currentDungeonProtocolModeForRoom may promote it to practice.
func TestCurrentDungeonProtocolModeForWireMapsEveryProvenPair(t *testing.T) {
tests := []struct {
name string
runtimeState byte
runtimeToken uint16
wantMode currentDungeonProtocolMode
wantOK bool
}{
{name: "normal zero token", runtimeState: 0, runtimeToken: 0, wantMode: currentDungeonProtocolModeNormal, wantOK: true},
{name: "normal current client token", runtimeState: 0, runtimeToken: 0xffff, wantMode: currentDungeonProtocolModeNormal, wantOK: true},
{name: "practice or guided shape without the room", runtimeState: 1, runtimeToken: 0xffff, wantMode: currentDungeonProtocolModeNormal, wantOK: true},
{name: "explicit practice", runtimeState: 1, runtimeToken: 0, wantMode: currentDungeonProtocolModePractice, wantOK: true},
{name: "unproven token", runtimeState: 1, runtimeToken: 1, wantMode: currentDungeonProtocolModeNormal, wantOK: false},
{name: "unproven state", runtimeState: 2, runtimeToken: 0xffff, wantMode: currentDungeonProtocolModeNormal, wantOK: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotMode, gotOK := currentDungeonProtocolModeForWire(test.runtimeState, test.runtimeToken)
if gotMode != test.wantMode || gotOK != test.wantOK {
t.Fatalf("currentDungeonProtocolModeForWire(%d,%d)=(%s,%t), want=(%s,%t)", test.runtimeState, test.runtimeToken, gotMode, gotOK, test.wantMode, test.wantOK)
}
})
}
}
func TestValidateCurrentDungeonEntryRequestAcceptsDefaultAndExplicitPracticeModes(t *testing.T) {
request := dungeoncmd.SelectDungeonRequest{DungeonID: 4999, RuntimeState: 1, RuntimeToken: 0xffff}
if err := validateCurrentDungeonEntryRequest(request); err != nil {
t.Fatalf("ordinary current-client mode rejected: %v", err)
}
request.RuntimeToken = 0
if err := validateCurrentDungeonEntryRequest(request); err != nil {
t.Fatalf("explicit practice mode rejected: %v", err)
}
request.RuntimeToken = 1
if err := validateCurrentDungeonEntryRequest(request); !errors.Is(err, errDungeonEntryModeUnsupported) {
t.Fatalf("ordinary dungeon accepted unproven runtime token error=%v", err)
}
}
func TestBuildCurrentDungeonEntryPacketsKeepsSpecialSpawnMetadataOutOfActorAndDropTables(t *testing.T) {
coordinate := worldmap.RoomCoordinate{X: 0, Y: 0}
specialObject := worldmap.SpecialPassiveObject{
PassiveObject: worldmap.PassiveObject{ObjectID: 109006908},
Spawns: []worldmap.SpecialObjectSpawn{
{Kind: "[item]", Code: 1001},
{Kind: "[trap]", Code: 1002},
},
}
room := &runtimeDungeonRoom{
coordinate: coordinate,
mapID: 76191,
extendedActors: []runtimeDungeonExtendedActor{{
Kind: runtimeDungeonActorSpecialObject,
ObjectKey: 402,
Packet: currentDungeonStartMapActor{
PacketIndex: 0,
ObjectKey: 402,
Code: uint32(specialObject.ObjectID),
Type: 9,
},
State: runtimeDungeonMonsterPlanned,
}},
retainedSpecialSpawns: []runtimeDungeonRetainedSpecialSpawn{
{ObjectIndex: 0, SpawnIndex: 0, Spawn: specialObject.Spawns[0]},
{ObjectIndex: 0, SpawnIndex: 1, Spawn: specialObject.Spawns[1]},
},
specialObjectCount: 1,
}
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 1000},
Dungeon: worldmap.Dungeon{ID: 1000, Mazes: []worldmap.Maze{{}}},
MazeIndex: 0,
Session: &worldmap.DungeonSession{},
Room: room,
BossCoordinate: coordinate,
BossSet: true,
Seed: 1,
}
scene := worldmap.DungeonRoomScene{
Coordinate: coordinate,
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 76191}},
SpecialPassiveObjects: []worldmap.SpecialPassiveObject{specialObject},
}
packets, err := buildCurrentDungeonEntryPackets(runtime, scene)
if err != nil {
t.Fatal(err)
}
if len(packets.DungeonInfo) != 36 {
t.Fatalf("dungeon-info len=%d body=%x", len(packets.DungeonInfo), packets.DungeonInfo)
}
if len(packets.StartMap) != 44 {
t.Fatalf("start-map len=%d body=%x", len(packets.StartMap), packets.StartMap)
}
if packets.StartMap[18] != 1 {
t.Fatalf("actor count=%d body=%x", packets.StartMap[18], packets.StartMap)
}
if got := binary.LittleEndian.Uint32(packets.StartMap[27:31]); got != uint32(specialObject.ObjectID) || packets.StartMap[32] != 9 {
t.Fatalf("special object code=%d type=%d body=%x", got, packets.StartMap[32], packets.StartMap)
}
if packets.StartMap[40] != 0 {
t.Fatalf("metadata child rows were invented as materialized drops: count=%d body=%x", packets.StartMap[40], packets.StartMap)
}
}
func TestRuntimeDungeonRoomAnnounceAllActorsCommitsAfterPreflight(t *testing.T) {
runtime := prepareSyntheticDungeonRuntimeForEntryTest(t, bridgeDungeonPVF(false), nil)
count, err := runtime.Room.AnnounceAllActors(runtime.Session)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("announced count=%d", count)
}
snapshot := runtime.Room.Snapshot()
if snapshot.Monsters[0].State != runtimeDungeonMonsterAnnounced {
t.Fatalf("monster state=%s", snapshot.Monsters[0].State)
}
scene, _ := runtime.Session.Scene()
if got := scene.RuntimeObjects[402]; got != (worldmap.HostileReference{Kind: worldmap.HostileMonster, Index: 0}) {
t.Fatalf("runtime binding=%+v", got)
}
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); !errors.Is(err, errDungeonActorAlreadyAnnounced) {
t.Fatalf("duplicate announce error=%v", err)
}
}
func TestRuntimeDungeonRoomAnnounceAllActorsIncludesHostileAPC(t *testing.T) {
source := bridgeDungeonPVF(false)
source["map/dungeon/test/start.map"] += "[ai character]\n4001 10 20 0 `[monster]` `[boss]` 0 0\n"
source[defaultDungeonAICharacterList] = "4001 `Test/Enemy.aic`\n"
source["AICharacter/Test/Enemy.aic"] = "[minimum info]\n`Enemy APC` 1 2 3 4 25\n"
aiCatalog, err := newPVFDungeonAICharacterCatalog(source)
if err != nil {
t.Fatal(err)
}
runtime := prepareSyntheticDungeonRuntimeForEntryTest(t, source, aiCatalog)
if runtime.NextObjectKey != 404 {
t.Fatalf("next object key=%d", runtime.NextObjectKey)
}
count, err := runtime.Room.AnnounceAllActors(runtime.Session)
if err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("announced count=%d", count)
}
snapshot := runtime.Room.Snapshot()
if len(snapshot.ExtendedActors) != 1 || snapshot.ExtendedActors[0].State != runtimeDungeonMonsterAnnounced || snapshot.ExtendedActors[0].ObjectKey != 403 {
t.Fatalf("extended actors=%+v", snapshot.ExtendedActors)
}
scene, _ := runtime.Session.Scene()
if got := scene.RuntimeObjects[403]; got != (worldmap.HostileReference{Kind: worldmap.HostileAICharacter, Index: 0}) {
t.Fatalf("APC runtime binding=%+v", got)
}
}
func TestHandleDungeonMonsterDeathSupportsHostileAPCAndClearsLastHostile(t *testing.T) {
source := bridgeDungeonPVF(false)
source["map/dungeon/test/start.map"] += "[ai character]\n4001 10 20 0 `[monster]` `[boss]` 0 0\n4002 30 20 0 `[character]` `[normal]` 0 0\n"
source[defaultDungeonAICharacterList] = "4001 `Test/Enemy.aic`\n4002 `Test/Friend.aic`\n"
source["AICharacter/Test/Enemy.aic"] = "[minimum info]\n`Enemy APC` 1 2 3 4 25\n"
source["AICharacter/Test/Friend.aic"] = "[minimum info]\n`Friend APC` 1 2 3 4 25\n"
aiCatalog, err := newPVFDungeonAICharacterCatalog(source)
if err != nil {
t.Fatal(err)
}
runtime := prepareSyntheticDungeonRuntimeForEntryTest(t, source, aiCatalog)
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
conn := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: conn, connID: "dungeon-death-ai-test", dungeon: dungeonSessionState{runtime: runtime}}
aiObjectKey := uint32(403)
aiDeathBody, err := hex.DecodeString("9301000094010000000000000000910d0000130000000211019401bf0b00000d001101130094010000060064024d01000000001e3c0001000000430d00000000000064024d0164024d0112002f0003000000300280d6")
if err != nil {
t.Fatal(err)
}
if err := service.handleDungeonMonsterDeath(session, aiDeathBody); err != nil {
t.Fatal(err)
}
aiPacket, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
wantAI, err := buildCurrentDungeonDeathNotificationBody(
aiObjectKey,
currentDungeonDeathResponseAICharacter,
)
if err != nil {
t.Fatal(err)
}
if aiPacket.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
aiPacket.Header.Classification != 0 ||
!bytes.Equal(aiPacket.Body, wantAI) {
t.Fatalf("AI death notification = header=%+v body=%x rest=%x", aiPacket.Header, aiPacket.Body, rest)
}
forcedPacket, rest := splitGameServerUpperPacket(t, rest)
wantForced, err := buildCurrentDungeonDeathNotificationBody(
402,
currentDungeonDeathResponseMonster,
)
if err != nil {
t.Fatal(err)
}
if forcedPacket.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
forcedPacket.Header.Classification != 0 ||
!bytes.Equal(forcedPacket.Body, wantForced) ||
len(rest) != 0 {
t.Fatalf("AI boss forced-clear packet = header=%+v body=%x rest=%x", forcedPacket.Header, forcedPacket.Body, rest)
}
snapshot := runtime.Room.Snapshot()
scene, _ := runtime.Session.Scene()
if snapshot.ExtendedActors[0].State != runtimeDungeonMonsterDefeated ||
snapshot.Monsters[0].State != runtimeDungeonMonsterDefeated ||
!scene.Cleared {
t.Fatalf("APC death state = room=%+v scene=%+v", snapshot, scene)
}
conn.write.Reset()
fixedSentinelBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(fixedSentinelBody[0:4], 404)
binary.LittleEndian.PutUint16(fixedSentinelBody[4:6], ^uint16(0))
if err := service.handleDungeonMonsterDeath(session, fixedSentinelBody); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || runtime.Room.Snapshot().ExtendedActors[1].State != runtimeDungeonMonsterAnnounced {
t.Fatalf("fixed62 owner=ffff was not rejected: writes=%d room=%+v", conn.write.Len(), runtime.Room.Snapshot())
}
variableCombatSentinelBody := make([]byte, dungeoncmd.DieMonsterVariableBaseSize+dungeoncmd.DieMonsterVariableCombatEntrySize)
binary.LittleEndian.PutUint32(variableCombatSentinelBody[0:4], 404)
binary.LittleEndian.PutUint16(variableCombatSentinelBody[4:6], ^uint16(0))
variableCombatSentinelBody[22] = 1
if err := service.handleDungeonMonsterDeath(session, variableCombatSentinelBody); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || runtime.Room.Snapshot().ExtendedActors[1].State != runtimeDungeonMonsterAnnounced {
t.Fatalf("variable count=1 owner=ffff was not rejected: writes=%d room=%+v", conn.write.Len(), runtime.Room.Snapshot())
}
conn.write.Reset()
nonHostileDeathBody := make([]byte, dungeoncmd.DieMonsterVariableBaseSize)
binary.LittleEndian.PutUint32(nonHostileDeathBody[0:4], 404)
binary.LittleEndian.PutUint16(nonHostileDeathBody[4:6], ^uint16(0))
if err := service.handleDungeonMonsterDeath(session, nonHostileDeathBody); err != nil {
t.Fatal(err)
}
nonHostilePacket, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
wantNonHostile, err := buildCurrentDungeonDeathNotificationBody(
404,
currentDungeonDeathResponseAICharacter,
)
if err != nil {
t.Fatal(err)
}
if nonHostilePacket.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
nonHostilePacket.Header.Classification != 0 ||
!bytes.Equal(nonHostilePacket.Body, wantNonHostile) ||
len(rest) != 0 {
t.Fatalf("non-hostile AI retirement notification = header=%+v body=%x rest=%x", nonHostilePacket.Header, nonHostilePacket.Body, rest)
}
snapshot = runtime.Room.Snapshot()
scene, _ = runtime.Session.Scene()
if snapshot.ExtendedActors[1].State != runtimeDungeonMonsterDefeated || !scene.Cleared {
t.Fatalf("non-hostile AI retirement changed completed clear state = room=%+v scene=%+v", snapshot, scene)
}
conn.write.Reset()
if err := service.handleDungeonMonsterDeath(session, nonHostileDeathBody); err != nil {
t.Fatal(err)
}
duplicateNonHostilePacket, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if duplicateNonHostilePacket.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
duplicateNonHostilePacket.Header.Classification != 0 ||
!bytes.Equal(duplicateNonHostilePacket.Body, wantNonHostile) ||
len(rest) != 0 {
t.Fatalf("duplicate non-hostile AI retirement notification = header=%+v body=%x rest=%x", duplicateNonHostilePacket.Header, duplicateNonHostilePacket.Body, rest)
}
snapshot = runtime.Room.Snapshot()
scene, _ = runtime.Session.Scene()
if snapshot.ExtendedActors[1].State != runtimeDungeonMonsterDefeated || !scene.Cleared {
t.Fatalf("duplicate non-hostile AI retirement changed completed clear state = room=%+v scene=%+v", snapshot, scene)
}
conn.write.Reset()
monsterObjectKey := uint32(firstDungeonMonsterObjectKey)
monsterDeathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(monsterDeathBody[0:4], monsterObjectKey)
binary.LittleEndian.PutUint16(monsterDeathBody[4:6], currentSceneBootstrapObjectKey)
if err := service.handleDungeonMonsterDeath(session, monsterDeathBody); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 {
t.Fatalf("forced-cleared monster death replay wrote=%x", conn.write.Bytes())
}
snapshot = runtime.Room.Snapshot()
scene, _ = runtime.Session.Scene()
if snapshot.Monsters[0].State != runtimeDungeonMonsterDefeated || !scene.Cleared {
t.Fatalf("last hostile death state = room=%+v scene=%+v", snapshot, scene)
}
}
func TestHandleDungeonMonsterDeathRetiresSpecialMonsterVisualActor(t *testing.T) {
source := bridgeDungeonPVF(false)
source["map/dungeon/test/start.map"] += "[special passive object]\n7001 30 20 0 1 `[monster]` 3001 20 0 0 0\n"
// 特殊被动对象要经 passiveobject.lst 反查安装路径才认得出是怪物视觉体
// (dungeon_monster_catalog.go 的 ensurePassiveObjectIndexLocked)。共享桩里没有
// 这张表,缺了它解析直接报错,而不是走到本测试要验的"退场"分支。只列 7001,
// 不动 bridgeDungeonPVF,避免影响其他用同一个桩的测试。
source[currentDungeonPassiveObjectList] = "7001 `test.obj`\n"
runtime := prepareSyntheticDungeonRuntimeForEntryTest(t, source, nil)
count, err := runtime.Room.AnnounceAllActors(runtime.Session)
if err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("announced count=%d", count)
}
snapshot := runtime.Room.Snapshot()
if len(snapshot.ExtendedActors) != 2 ||
snapshot.ExtendedActors[0].Kind != runtimeDungeonActorSpecialObject ||
snapshot.ExtendedActors[0].ObjectKey != 403 ||
snapshot.ExtendedActors[1].Kind != runtimeDungeonActorSpecialMonster ||
snapshot.ExtendedActors[1].ObjectKey != 404 ||
snapshot.ExtendedActors[1].HostileReference != nil ||
snapshot.ExtendedActors[1].State != runtimeDungeonMonsterAnnounced {
t.Fatalf("special actors=%+v", snapshot.ExtendedActors)
}
scene, _ := runtime.Session.Scene()
if got := scene.RuntimeObjects[402]; got != (worldmap.HostileReference{Kind: worldmap.HostileMonster, Index: 0}) {
t.Fatalf("ordinary monster binding=%+v", got)
}
if _, bound := scene.RuntimeObjects[404]; bound {
t.Fatalf("special monster was bound as a room owner: %+v", scene.RuntimeObjects)
}
conn := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: conn, connID: "dungeon-special-monster-test", dungeon: dungeonSessionState{runtime: runtime}}
specialDeathBody := make([]byte, dungeoncmd.DieMonsterVariableBaseSize+dungeoncmd.DieMonsterVariableCombatEntrySize)
binary.LittleEndian.PutUint32(specialDeathBody[0:4], 404)
binary.LittleEndian.PutUint16(specialDeathBody[4:6], currentSceneBootstrapObjectKey)
specialDeathBody[22] = 1
if err := service.handleDungeonMonsterDeath(session, specialDeathBody); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
want, err := buildCurrentDungeonDeathNotificationBody(
404,
currentDungeonDeathResponseMonster,
)
if err != nil {
t.Fatal(err)
}
if packet.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
packet.Header.Classification != 0 ||
!bytes.Equal(packet.Body, want) ||
len(rest) != 0 {
t.Fatalf("special monster retirement packet = header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
}
snapshot = runtime.Room.Snapshot()
scene, _ = runtime.Session.Scene()
if snapshot.ExtendedActors[1].State != runtimeDungeonMonsterDefeated ||
snapshot.Monsters[0].State != runtimeDungeonMonsterAnnounced ||
scene.Cleared {
t.Fatalf("special monster retirement changed owner state = room=%+v scene=%+v", snapshot, scene)
}
conn.write.Reset()
if err := service.handleDungeonMonsterDeath(session, specialDeathBody); err != nil {
t.Fatal(err)
}
duplicate, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if duplicate.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
duplicate.Header.Classification != 0 ||
!bytes.Equal(duplicate.Body, want) ||
len(rest) != 0 {
t.Fatalf("duplicate special monster retirement packet = header=%+v body=%x rest=%x", duplicate.Header, duplicate.Body, rest)
}
snapshot = runtime.Room.Snapshot()
scene, _ = runtime.Session.Scene()
if snapshot.ExtendedActors[1].State != runtimeDungeonMonsterDefeated ||
snapshot.Monsters[0].State != runtimeDungeonMonsterAnnounced ||
scene.Cleared {
t.Fatalf("duplicate special monster retirement changed owner state = room=%+v scene=%+v", snapshot, scene)
}
}
func TestRuntimeDungeonRoomAnnounceAllActorsRejectsConflictWithoutStateAdvance(t *testing.T) {
runtime := prepareSyntheticDungeonRuntimeForEntryTest(t, bridgeDungeonPVF(false), nil)
reference := worldmap.HostileReference{Kind: worldmap.HostileMonster, Index: 0}
if err := runtime.Session.BindHostileObject(reference, 999); err != nil {
t.Fatal(err)
}
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); !errors.Is(err, errDungeonActorOwnerConflict) {
t.Fatalf("conflict error=%v", err)
}
if got := runtime.Room.Snapshot().Monsters[0].State; got != runtimeDungeonMonsterPlanned {
t.Fatalf("monster advanced to %s", got)
}
}
func TestHandleDungeonSelectUpperDefersEntryPacketValidationUntilTypedScenePhases(t *testing.T) {
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: "99", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
// An equipped weapon must survive the selector-page rebind: playguide_5.co
// raises the no-weapon alarm on its `dungeon entrance` event whenever the
// mode1 equipment model is empty.
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "99",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {
SlotIndex: 11,
ItemID: 900001,
RawEntry: buildInitialEquipmentRawEntry(11, 900001, 27),
},
},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 1, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
connection := &bufferConn{}
channel := channelcatalog.Channel{ServerID: 1, ID: 253, Type: 40, Name: "ch.253", Port: 10253}
session := &gameSession{
conn: connection,
channel: channel,
residentChannel: channel,
selectedCharacterID: 99,
connectionTownActorOwnerChannel: byte(channel.ID),
townActorOwnerChannel: byte(channel.ID),
}
bindDungeonSelectorOriginForTestAt(t, service, session, 38, 1, 450, 234)
body := make([]byte, 21)
binary.LittleEndian.PutUint32(body[0:4], 700)
binary.LittleEndian.PutUint16(body[9:11], 0xffff)
binary.LittleEndian.PutUint32(body[16:20], 3145)
if err := service.handleDungeonSelectUpper(session, body); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) {
t.Fatalf("select ACK packet=%+v rest=%x", packet.Header, rest)
}
resourcePacket, rest := splitGameServerUpperPacket(t, rest)
if resourcePacket.Header.Classification != 0 || resourcePacket.Header.MsgID != currentDungeonResourceStateMsgID || !bytes.Equal(resourcePacket.Body, []byte{1, 0, 0xbc, 2, 0, 0, currentDungeonResourceSelectedState}) {
t.Fatalf("op5 packet=%+v body=%x rest=%x", resourcePacket.Header, resourcePacket.Body, rest)
}
rest = assertCurrentPreDungeonContextPlayerStateWithEquipment(t, session, rest, 1)
contextPacket, rest := splitGameServerUpperPacket(t, rest)
if contextPacket.Header.Classification != 0 || contextPacket.Header.MsgID != currentDungeonContextMsgID || len(contextPacket.Body) != 37 {
t.Fatalf("op27 packet=%+v body_len=%d rest=%x", contextPacket.Header, len(contextPacket.Body), rest)
}
infoPacket, rest := splitGameServerUpperPacket(t, rest)
if infoPacket.Header.Classification != 0 || infoPacket.Header.MsgID != currentDungeonInfoNotification || len(infoPacket.Body) != 36 {
t.Fatalf("op28 packet=%+v body_len=%d rest=%x", infoPacket.Header, len(infoPacket.Body), rest)
}
startPacket, rest := splitGameServerUpperPacket(t, rest)
if startPacket.Header.Classification != 0 || startPacket.Header.MsgID != currentDungeonStartNotification || len(startPacket.Body) < 23 {
t.Fatalf("op29 packet=%+v body_len=%d rest=%x", startPacket.Header, len(startPacket.Body), rest)
}
rest = assertCurrentPostStartMapPlayerState(t, session, rest, true, true)
if len(rest) != 0 {
t.Fatalf("post-op29 trailing bytes=%x", rest)
}
if !session.preDungeonContextPlayerStateSent || !session.postStartMapPlayerStateSent || !session.sceneBootstrapTailSent || session.sceneBootstrapTailDeferred {
t.Fatalf("scene flags pre=%v placed=%v tail=%v deferred=%v", session.preDungeonContextPlayerStateSent, session.postStartMapPlayerStateSent, session.sceneBootstrapTailSent, session.sceneBootstrapTailDeferred)
}
if session.dungeon.runtime == nil || session.dungeon.runtime.Request.RuntimeToken != 0xffff || session.dungeon.runtime.Request.LeaderObjectKey != 3145 {
t.Fatalf("runtime request=%+v", session.dungeon.runtime)
}
}
func TestHandleDungeonSelectUpperCommitsStartMapBeforePostPlayerState(t *testing.T) {
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: "99", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 1, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
connection := &failNthDungeonWriteConn{failAt: 23, err: errors.New("unexpected twenty-third write")}
session := &gameSession{conn: connection, selectedCharacterID: 99}
bindDungeonSelectorOriginForTestAt(t, service, session, 38, 1, 450, 234)
body := make([]byte, 21)
binary.LittleEndian.PutUint32(body[0:4], 700)
if err := service.handleDungeonSelectUpper(session, body); err == nil || !strings.Contains(err.Error(), "unexpected twenty-third write") {
t.Fatalf("handler error=%v, want post-op29 placement failure", err)
}
if connection.writes != 23 {
t.Fatalf("write calls=%d", connection.writes)
}
if session.dungeon.runtime == nil {
t.Fatal("runtime reservation missing after select-dungeon ACK")
}
snapshot := session.dungeon.runtime.Room.Snapshot()
if snapshot.Monsters[0].State != runtimeDungeonMonsterAnnounced {
t.Fatalf("monster state after start-map notification: %s", snapshot.Monsters[0].State)
}
scene, _ := session.dungeon.runtime.Session.Scene()
if len(scene.RuntimeObjects) != 1 {
t.Fatalf("runtime objects after start-map notification: %+v", scene.RuntimeObjects)
}
if !session.preDungeonContextPlayerStateSent || session.postStartMapPlayerStateSent || !session.selectedUserInfoRefreshSent || session.selectedUserInfoMode3Sent {
t.Fatal("post-start-map placement state committed incorrectly after its final write failed")
}
}
func TestHandleDungeonSelectUpperStopsBeforeOp27WhenLifecycleMode1WriteFails(t *testing.T) {
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: "99", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 1, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
connection := &failNthDungeonWriteConn{failAt: 4, err: errors.New("actor-binding mode1 write failed")}
session := &gameSession{conn: connection, selectedCharacterID: 99}
bindDungeonSelectorOriginForTestAt(t, service, session, 38, 1, 450, 234)
body := make([]byte, 21)
binary.LittleEndian.PutUint32(body[0:4], 700)
if err := service.handleDungeonSelectUpper(session, body); err == nil || !strings.Contains(err.Error(), "actor-binding mode1 write failed") {
t.Fatalf("handler error=%v", err)
}
_, rest := splitGameServerUpperPacket(t, connection.write.Bytes()) // op16 ACK
_, rest = splitGameServerUpperPacket(t, rest) // op5
mode0, rest := splitGameServerUpperPacket(t, rest)
if mode0.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) || len(mode0.Body) == 0 || mode0.Body[0] != 0 || len(rest) != 0 {
t.Fatalf("writes before actor-binding mode1 failure mode0=%+v body=%x rest=%x", mode0.Header, mode0.Body, rest)
}
if session.preDungeonContextPlayerStateSent || session.postStartMapPlayerStateSent {
t.Fatalf("failed actor-binding stage committed flags pre=%v post=%v", session.preDungeonContextPlayerStateSent, session.postStartMapPlayerStateSent)
}
}
func TestSendCurrentPreDungeonContextPlayerStateIsOneShot(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "account-1",
Name: "hero",
Job: "11",
Level: 1,
}
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
connection := &bufferConn{}
channel := channelcatalog.Channel{ServerID: 1, ID: 16, Type: 1, Name: "ch.16", Port: 10016}
session := &gameSession{
conn: connection,
channel: channel,
residentChannel: channel,
selectedCharacterID: 77,
}
if err := service.sendCurrentPreDungeonContextPlayerState(session, "test"); err != nil {
t.Fatal(err)
}
rest := assertCurrentPreDungeonContextPlayerState(t, session, connection.write.Bytes())
if len(rest) != 0 {
t.Fatalf("pre-op27 actor-binding trailing bytes=%x", rest)
}
written := connection.write.Len()
if err := service.sendCurrentPreDungeonContextPlayerState(session, "test_duplicate"); err != nil {
t.Fatal(err)
}
if connection.write.Len() != written {
t.Fatalf("duplicate pre-op27 actor-binding wrote %d extra bytes", connection.write.Len()-written)
}
}
func assertCurrentPreDungeonContextPlayerState(t *testing.T, session *gameSession, rest []byte) []byte {
t.Helper()
return assertCurrentPreDungeonContextPlayerStateWithEquipment(t, session, rest, 0)
}
// assertCurrentPreDungeonContextPlayerStateWithEquipment pins the selector-page
// rebind. The mode1 must carry the character's equipment create rows: the play
// guide playguide_5.co checks "equipment slot weapon empty" on its `dungeon
// entrance` event, so a binding-only mode1 here raised the no-weapon alarm for
// every armed character entering the dungeon selector.
func assertCurrentPreDungeonContextPlayerStateWithEquipment(t *testing.T, session *gameSession, rest []byte, wantCreateRows byte) []byte {
t.Helper()
localObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
ownerChannel := byte(currentSceneObjectContext)
mode0, rest := splitGameServerUpperPacket(t, rest)
if mode0.Header.Classification != 0 || mode0.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
len(mode0.Body) < 0x4e || mode0.Body[0] != 0 ||
mode0.Body[3] != currentSceneObjectRoute ||
mode0.Body[4] != ownerChannel ||
binary.LittleEndian.Uint16(mode0.Body[0x4c:0x4e]) != localObjectKey {
t.Fatalf("pre-op27 mode0 packet=%+v body=%x rest=%x", mode0.Header, mode0.Body, rest)
}
mode1, rest := splitGameServerUpperPacket(t, rest)
if mode1.Header.Classification != 0 || mode1.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
mode1.Body[0] != 1 || binary.LittleEndian.Uint16(mode1.Body[0x15:0x17]) != localObjectKey ||
mode1.Body[3] != currentSceneObjectRoute || mode1.Body[4] != ownerChannel ||
mode1.Body[currentMode1CreateCountOffset] != wantCreateRows {
t.Fatalf("pre-op27 actor-binding mode1 packet=%+v body=%x rest=%x", mode1.Header, mode1.Body, rest)
}
if wantCreateRows == 0 && len(mode1.Body) != currentMode1BaseWireSize {
t.Fatalf("pre-op27 mode1 with no equipment must stay at the base size: len=%d body=%x", len(mode1.Body), mode1.Body)
}
if wantCreateRows > 0 && len(mode1.Body) <= currentMode1BaseWireSize {
t.Fatalf("pre-op27 mode1 with equipment must carry the rows: len=%d body=%x", len(mode1.Body), mode1.Body)
}
if !session.preDungeonContextPlayerStateSent {
t.Fatal("pre-op27 actor-binding stage was not committed")
}
return rest
}
func assertCurrentPostStartMapPlayerState(t *testing.T, session *gameSession, rest []byte, expectUserState bool, expectPreLifecycle bool) []byte {
t.Helper()
rest = consumeOptionalCurrentDungeonFatigueRefreshForTest(t, rest)
ownerChannel := byte(currentSceneObjectContext)
for page := 0; page < currentSceneOverseerPageCount; page++ {
packet, next := splitGameServerUpperPacket(t, rest)
if packet.Header.Classification != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestOverseer) {
t.Fatalf("post-op29 page[%d] = class %d msg %d body=%x", page, packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
rest = next
}
actionTable, rest := splitGameServerUpperPacket(t, rest)
if actionTable.Header.Classification != 0 || actionTable.Header.MsgID != uint16(dnfenum.CmdPacketPVPMissionHpPercent) {
t.Fatalf("post-op29 action table = class %d msg %d body=%x", actionTable.Header.Classification, actionTable.Header.MsgID, actionTable.Body)
}
if !expectPreLifecycle {
mode0, next := splitGameServerUpperPacket(t, rest)
if mode0.Header.Classification != 0 ||
mode0.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
len(mode0.Body) < 0x4e || mode0.Body[0] != 0 ||
mode0.Body[3] != currentSceneObjectRoute ||
mode0.Body[4] != ownerChannel {
t.Fatalf("post-op29 fallback mode0 packet=%+v body=%x", mode0.Header, mode0.Body)
}
rest = next
}
for _, listType := range currentSelectInventoryBootstrapListTypes {
itemPacket, next := splitCurrentSceneItemListPacket(t, rest)
if itemPacket.Header.Classification != 0 || itemPacket.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || len(itemPacket.Body) == 0 || itemPacket.Body[0] != listType {
t.Fatalf("actor-bound pre-mode1 op13 type=%d packet=%+v body=%x", listType, itemPacket.Header, itemPacket.Body)
}
if listType == 2 && (len(itemPacket.Body) != 6 || itemPacket.Body[5] != 0) {
t.Fatalf("pre-mode0 op13 type2 body=%x want zero group terminator", itemPacket.Body)
}
rest = next
}
lockSnapshot, next := splitCurrentGameServerUpperPacketAuto(t, rest)
if lockSnapshot.Header.Classification != 0 ||
lockSnapshot.Header.MsgID != dnfitemlock.LockListMessageID ||
!bytes.Equal(lockSnapshot.Body, []byte{0, 0}) {
t.Fatalf("actor-bound pre-mode1 item-lock snapshot=%+v body=%x", lockSnapshot.Header, lockSnapshot.Body)
}
rest = next
want := make([]struct {
msgID uint16
mode byte
}, 0, 8)
want = append(want,
struct {
msgID uint16
mode byte
}{uint16(dnfenum.CmdPacketSetUDPIPPort), 1},
struct {
msgID uint16
mode byte
}{currentCreatureStateTableMsgID, 0xff},
struct {
msgID uint16
mode byte
}{uint16(dnfenum.CmdPacketInsertOverseer), 0xff},
)
// The pre-op27 mode0 descriptor plus post-op29 mode1 local actor binding
// establish the wrapper. Mode3 is deliberately absent: a fresh live trace
// proved it opens the personal-information panel and still emits no op43.
want = append(want,
struct {
msgID uint16
mode byte
}{currentClearQuestListMsgID, 0xff},
struct {
msgID uint16
mode byte
}{uint16(dnfenum.CmdPacketReportClientSpec), 0xff},
struct {
msgID uint16
mode byte
}{skillSwitchInventoryAllDataWire, 0xff},
struct {
msgID uint16
mode byte
}{uint16(dnfenum.CmdPacketRecoverStamina), 0xff},
struct {
msgID uint16
mode byte
}{uint16(dnfenum.CmdPacketRequestBlacklist), 0xff},
)
// Ordinary dungeon activation is deliberately last: the working client
// produces op43 pickup only when op120 commits the room placement before
// op3 marks the local wrapper active. Tutorials defer op3 to finish-loading.
if expectUserState {
want = append(want, struct {
msgID uint16
mode byte
}{uint16(dnfenum.CmdPacketNotifyUserState), 0xff})
}
for idx, expected := range want {
var packet dnfproto.ChannelPacket
var next []byte
if expected.msgID == currentClearQuestListMsgID {
packet, next = splitLongHengGameServerUpperPacket(t, rest)
} else {
packet, next = splitGameServerUpperPacket(t, rest)
}
if packet.Header.Classification != 0 || packet.Header.MsgID != expected.msgID {
t.Fatalf("post-op29 packet[%d] = class %d msg %d, want msg %d body=%x", idx, packet.Header.Classification, packet.Header.MsgID, expected.msgID, packet.Body)
}
if expected.mode != 0xff && (len(packet.Body) == 0 || packet.Body[0] != expected.mode) {
t.Fatalf("post-op29 msg2 mode%d body=%x", expected.mode, packet.Body)
}
localObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
if expected.mode == 0 && (len(packet.Body) < 0x4e ||
packet.Body[3] != currentSceneObjectRoute ||
packet.Body[4] != ownerChannel ||
binary.LittleEndian.Uint16(packet.Body[0x4c:0x4e]) != localObjectKey) {
t.Fatalf("post-op29 msg2 mode0 scene key body=%x", packet.Body)
}
if expected.msgID == uint16(dnfenum.CmdPacketNotifyUserState) {
wantBody := []byte{1, byte(localObjectKey), byte(localObjectKey >> 8), currentDungeonPlayerUserState}
if !bytes.Equal(packet.Body, wantBody) {
t.Fatalf("post-op29 op3 user-state body=%x want=%x", packet.Body, wantBody)
}
}
if expected.mode == 1 && (len(packet.Body) <= currentMode1CreateCountOffset ||
packet.Body[3] != currentSceneObjectRoute ||
packet.Body[4] != ownerChannel ||
binary.LittleEndian.Uint16(packet.Body[0x15:0x17]) != localObjectKey) {
t.Fatalf("post-op29 msg2 mode1 local owner/equipment body=%x", packet.Body)
}
if expected.mode == 3 && (len(packet.Body) < 15 ||
packet.Body[3] != currentSceneObjectRoute ||
packet.Body[4] != ownerChannel ||
binary.LittleEndian.Uint16(packet.Body[13:15]) != localObjectKey) {
t.Fatalf("post-op29 msg2 mode3 local owner/runtime finalizer body=%x", packet.Body)
}
if expected.msgID == currentClearQuestListMsgID {
plain, err := zlibDecompress(packet.Body)
if err != nil || len(plain) != 30004 || binary.LittleEndian.Uint32(plain[:4]) != 30000 {
t.Fatalf("post-op29 op356 body len=%d plain_len=%d err=%v", len(packet.Body), len(plain), err)
}
if plain[4+int(localObjectKey)] != 0 {
t.Fatalf("post-op29 op356 wrote actor key %#x into clear-quest list", localObjectKey)
}
}
if expected.msgID == uint16(dnfenum.CmdPacketRecoverStamina) && (len(packet.Body) < 12 ||
binary.LittleEndian.Uint16(packet.Body[0:2]) != 1 ||
binary.LittleEndian.Uint16(packet.Body[4:6]) != localObjectKey ||
packet.Body[9] != currentSceneOp9ActorDisplayKind ||
packet.Body[10] != currentSceneObjectRoute ||
packet.Body[11] != ownerChannel) {
t.Fatalf("post-op29 op9 actor display body=%x", packet.Body)
}
if expected.msgID == uint16(dnfenum.CmdPacketRequestBlacklist) && !bytes.Equal(packet.Body, []byte{0, 0}) {
t.Fatalf("post-op29 placement body=%x", packet.Body)
}
rest = next
}
if session.preDungeonContextPlayerStateSent != expectPreLifecycle || !session.selectedUserInfoRefreshSent || session.selectedUserInfoMode3Sent || !session.selectedItemListRefreshSent {
t.Fatalf("post-op29 flags pre=%v placed=%v mode1=%v mode3=%v item_lists=%v", session.preDungeonContextPlayerStateSent, session.postStartMapPlayerStateSent, session.selectedUserInfoRefreshSent, session.selectedUserInfoMode3Sent, session.selectedItemListRefreshSent)
}
return rest
}
func TestCurrentPostStartMapSuppressesTutorialUserState(t *testing.T) {
for _, fixture := range []struct {
name string
options tutorialScopeFixtureOptions
expectUserState bool
expectDeferred bool
mutateSceneOwner bool
}{
{name: "female Slayer tutorial", expectDeferred: true},
{
name: "Knight tutorial",
options: tutorialScopeFixtureOptions{
job: "12",
dungeonReference: tutorialScopeKnightFDungeonReference,
mapDirectory: tutorialScopeKnightFMapDirectory,
},
expectDeferred: true,
},
{
name: "PVF tutorial stale scene owner",
options: tutorialScopeFixtureOptions{
job: "12",
dungeonReference: tutorialScopeKnightFDungeonReference,
mapDirectory: tutorialScopeKnightFMapDirectory,
},
mutateSceneOwner: true,
},
{name: "ordinary non-tutorial dungeon", options: tutorialScopeFixtureOptions{disableTutorial: true}, expectUserState: true},
} {
t.Run(fixture.name, func(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, fixture.options)
service.characterStats = testCharacterStatTable(t)
connection := &bufferConn{}
channel := channelcatalog.Channel{ServerID: 1, ID: 16, Type: 1, Name: "ch.16", Port: 10016}
session := &gameSession{
conn: connection,
channel: channel,
residentChannel: channel,
selectedCharacterID: 99,
}
scene := runtime.Session.Snapshot().Scene
if fixture.mutateSceneOwner {
scene.Map.Map.ID++
}
if err := service.sendCurrentPostStartMapPlayerPlacement(session, runtime, scene, "test_owned_dungeon_user_state"); err != nil {
t.Fatal(err)
}
rest := assertCurrentPostStartMapPlayerState(t, session, connection.write.Bytes(), fixture.expectUserState, false)
if len(rest) != 0 || !session.postStartMapPlayerStateSent {
t.Fatalf("post-op29 state incomplete: rest=%x placed=%v", rest, session.postStartMapPlayerStateSent)
}
wantDeferredObjectKey := uint16(0)
if fixture.expectDeferred {
wantDeferredObjectKey = currentSceneActorObjectKey(session.selectedCharacterID)
}
if session.deferredDungeonUserStateObjectKey != wantDeferredObjectKey {
t.Fatalf(
"deferred dungeon user-state object key=%d want=%d",
session.deferredDungeonUserStateObjectKey,
wantDeferredObjectKey,
)
}
written := connection.write.Len()
if err := service.sendCurrentPostStartMapPlayerPlacement(session, runtime, scene, "test_ordinary_room_change_no_replay"); err != nil {
t.Fatal(err)
}
if connection.write.Len() != written {
t.Fatalf("second room placement replayed %d bytes", connection.write.Len()-written)
}
})
}
}
func TestCurrentPostStartMapRehydratesGuardianGemAfterFullMode1(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true})
repositories, ok := service.repositoryGroup()
if !ok || repositories.Equipment == nil {
t.Fatal("dungeon entry fixture equipment repository unavailable")
}
medalRaw := currentGuardianGemTestRaw(32, 100380017, 1)
binary.LittleEndian.PutUint16(medalRaw[currentGuardianGemRawSocketOffset:currentGuardianGemRawSocketOffset+2], 3)
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "99",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: medalRaw},
},
}); err != nil {
t.Fatalf("save dungeon guardian-gem medal: %v", err)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
channel: channelcatalog.Channel{ID: 16},
residentChannel: channelcatalog.Channel{ID: 16},
selectedCharacterID: 99,
}
scene := runtime.Session.Snapshot().Scene
if err := service.sendCurrentPostStartMapPlayerPlacement(session, runtime, scene, "test_guardian_gem_after_mode1"); err != nil {
t.Fatal(err)
}
sawMode1 := false
sawGuardianGem := false
stream := connection.write.Bytes()
for len(stream) > 0 {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) > 0 && packet.Body[0] == 1 {
sawMode1 = true
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketWalkoutPartyMember) &&
len(packet.Body) == 3+currentItemListEntryWireSize &&
packet.Body[0] == currentSocketListEquipment &&
binary.LittleEndian.Uint16(packet.Body[3:5]) == uint16(currentGuildMedalActorSlot) {
if !sawMode1 {
t.Fatal("guardian-gem op14 preceded dungeon full mode1")
}
if !currentGuardianGemRawSocketOccupied(packet.Body[3:]) {
t.Fatalf("dungeon guardian-gem op14 omitted raw socket state: %x", packet.Body)
}
sawGuardianGem = true
}
stream = rest
}
if !sawMode1 || !sawGuardianGem {
t.Fatalf("dungeon mode1/guardian refresh mode1=%t guardian=%t", sawMode1, sawGuardianGem)
}
}
func TestCurrentPostStartMapProjectsCollectboxAfterFullMode1(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true})
repositories, ok := service.repositoryGroup()
if !ok || repositories.Account == nil {
t.Fatal("dungeon entry fixture account repository unavailable")
}
state := dnfcollectbox.State{Boxes: map[string]map[string]uint32{"5": {"0": 9005}}}
raw, err := dnfcollectbox.EncodeState(state)
if err != nil {
t.Fatal(err)
}
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{dnfcollectbox.MetadataKey: raw},
}); err != nil {
t.Fatal(err)
}
service.collectboxCatalog = &currentCollectboxCatalog{
byIndex: map[byte]dnfcollectbox.Definition{
1: {Index: 1, BaseExpirationDate: "2037-01-05 06:00:00", Slots: []dnfcollectbox.Slot{{ItemID: 9001, Count: 1}}},
5: {Index: 5, BaseExpirationDate: "2037-01-05 06:00:00", Slots: []dnfcollectbox.Slot{{ItemID: 9005, Count: 1}}},
},
indexes: []byte{1, 5},
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
accountID: "account-1",
channel: channelcatalog.Channel{ID: 16},
residentChannel: channelcatalog.Channel{ID: 16},
selectedCharacterID: 99,
}
if err := service.sendCurrentPostStartMapPlayerPlacement(session, runtime, runtime.Session.Snapshot().Scene, "test_collectbox_after_mode1"); err != nil {
t.Fatal(err)
}
mode1Index, cacheIndex, selectionIndex, selectedIndex := -1, -1, -1, -1
stream := connection.write.Bytes()
packetIndex := 0
for len(stream) > 0 {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
switch {
case packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) > 0 && packet.Body[0] == 1:
mode1Index = packetIndex
case packet.Header.Classification == 0 && packet.Header.MsgID == collectboxNotificationMsgID && len(packet.Body) >= 8 && packet.Body[1] == 0:
cacheIndex = packetIndex
case packet.Header.Classification == 1 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSelectCollectbox):
selectionIndex = packetIndex
case packet.Header.Classification == 0 && packet.Header.MsgID == collectboxNotificationMsgID && len(packet.Body) >= 12 && packet.Body[0] == defaultCollectboxIndex && packet.Body[1] == 1:
if binary.LittleEndian.Uint32(packet.Body[8:12]) != 9005 {
t.Fatalf("selected collection-box item body=%x", packet.Body)
}
selectedIndex = packetIndex
}
packetIndex++
stream = rest
}
if !(mode1Index >= 0 && mode1Index < cacheIndex && cacheIndex < selectionIndex && selectionIndex < selectedIndex) {
t.Fatalf("dungeon collection-box order mode1=%d cache=%d select=%d selected=%d", mode1Index, cacheIndex, selectionIndex, selectedIndex)
}
if !session.collectboxSceneProjectionSent {
t.Fatal("dungeon actor generation did not commit collection-box projection")
}
}
func TestCurrentPostStartMapRehydratesPetEnchantAndBuffRegistrationAfterFullMode1(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true})
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("dungeon entry fixture repositories unavailable")
}
const (
petItemID uint32 = 400990167
petSerial uint32 = 37
cardID uint32 = 10008713
chronicleItemID uint32 = 116010135
chronicleSerial uint32 = 20
)
chronicleRaw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint16(chronicleRaw[0:2], 17)
binary.LittleEndian.PutUint32(chronicleRaw[2:6], chronicleItemID)
chronicleRaw[0x27] = 1
binary.LittleEndian.PutUint32(chronicleRaw[0x28:0x2c], currentChronicleCanonicalAuraBase+2)
chronicleRaw[0x30] = 15
chronicleRaw[0x32] = 2
chronicleRaw[0x34] = 12
chronicleRaw[0x36] = 11
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "99",
Slots: map[string]dnfrepo.ItemStack{
"0:17": {
ItemID: int64(chronicleItemID),
Count: 1,
RawEntry: chronicleRaw,
Extra: map[string]string{"item_serial": "20"},
},
},
}); err != nil {
t.Fatalf("save registered Chronicle inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "99",
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {
SlotIndex: 26,
ItemID: int64(petItemID),
RawEntry: testCurrentEquippedCreatureEnchantRaw(26, petItemID, petSerial, cardID, 2),
Extra: map[string]string{
"pet_enchant_card_item_id": "10008713",
"enchant_card_id": "10008713",
"pet_enchant_upgrade_count": "2",
},
},
},
}); err != nil {
t.Fatalf("save dungeon pet equipment: %v", err)
}
if err := repositories.Pet.Save(context.Background(), dnfrepo.PetRecord{
CharacterID: "99",
Entries: map[string]dnfrepo.PetEntry{
"37": {
PetKey: "37",
CreatureKey: petSerial,
ItemID: int64(petItemID),
Name: "PetB",
NameRaw: []byte("PetB"),
Satiety: 72,
Level: 7,
Exp: 0x12345678,
SourceListType: 3,
SourceSlotIndex: 26,
Extra: map[string]string{
"pet_enchant_card_item_id": "10008713",
"pet_enchant_upgrade_count": "2",
},
},
},
EquippedKey: "37",
}); err != nil {
t.Fatalf("save dungeon pet state: %v", err)
}
if err := saveSkillSwitchInventoryState(context.Background(), repositories.Settings, "99", skillSwitchInventoryState{
SkillID: 40,
Items: []skillSwitchBinding{{EquipmentPart: 12, ItemSerial: chronicleSerial, ItemID: chronicleItemID}},
}); err != nil {
t.Fatalf("save dungeon BUFF registration: %v", err)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
channel: channelcatalog.Channel{ID: 16},
residentChannel: channelcatalog.Channel{ID: 16},
selectedCharacterID: 99,
}
if err := service.sendCurrentPostStartMapPlayerPlacement(session, runtime, runtime.Session.Snapshot().Scene, "test_pet_and_buff_after_mode1"); err != nil {
t.Fatal(err)
}
mode1Index, petIndex, stateIndex, growthIndex, buffIndex, insertIndex, layoutIndex, chronicleIndex := -1, -1, -1, -1, -1, -1, -1, -1
stream := connection.write.Bytes()
packetIndex := 0
for len(stream) > 0 {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) > 0 && packet.Body[0] == 1 {
mode1Index = packetIndex
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketWalkoutPartyMember) &&
len(packet.Body) == 3+currentItemListEntryWireSize &&
packet.Body[0] == currentSocketListEquipment &&
binary.LittleEndian.Uint16(packet.Body[3:5]) == 26 {
assertEquippedCreatureItemRefreshPacket(t, packet, petItemID, petSerial, cardID, 2)
petIndex = packetIndex
}
if packet.Header.MsgID == currentCreatureStateTableMsgID {
stateIndex = packetIndex
}
if packet.Header.MsgID == currentCreatureGrowthMsgID {
growthIndex = packetIndex
}
if packet.Header.MsgID == skillSwitchInventoryAllDataWire {
buffIndex = packetIndex
if want := []byte{40, 0, 1, 1, 12, 0, 17, 0}; !bytes.Equal(packet.Body, want) {
t.Fatalf("dungeon BUFF refresh body=%x want=%x", packet.Body, want)
}
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketInsertOverseer) {
insertIndex = packetIndex
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketReportClientSpec) {
layoutIndex = packetIndex
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketWalkoutPartyMember) &&
len(packet.Body) == 3+currentItemListEntryWireSize &&
packet.Body[0] == 0 &&
binary.LittleEndian.Uint16(packet.Body[3:5]) == 17 {
if packet.Body[3+0x27] != 1 ||
binary.LittleEndian.Uint32(packet.Body[3+0x28:3+0x2c]) != currentChronicleCanonicalAuraBase+2 {
t.Fatalf("dungeon Chronicle BUFF item refresh=%x", packet.Body)
}
chronicleIndex = packetIndex
}
packetIndex++
stream = rest
}
if mode1Index < 0 || petIndex < 0 || stateIndex < 0 || growthIndex < 0 || buffIndex < 0 || insertIndex < 0 || layoutIndex < 0 || chronicleIndex < 0 {
t.Fatalf("dungeon refresh packets mode1=%d pet=%d state=%d growth=%d insert=%d layout=%d chronicle=%d buff=%d", mode1Index, petIndex, stateIndex, growthIndex, insertIndex, layoutIndex, chronicleIndex, buffIndex)
}
if !(mode1Index < petIndex && petIndex < stateIndex && stateIndex < growthIndex && growthIndex < insertIndex && insertIndex < layoutIndex && layoutIndex < chronicleIndex && chronicleIndex < buffIndex) {
t.Fatalf("dungeon refresh order mode1=%d pet=%d state=%d growth=%d insert=%d layout=%d chronicle=%d buff=%d", mode1Index, petIndex, stateIndex, growthIndex, insertIndex, layoutIndex, chronicleIndex, buffIndex)
}
}
func TestCurrentPostStartMapInitializesSkillsAfterDungeonMode1(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true})
repositories, ok := service.repositoryGroup()
if !ok || repositories.Skill == nil {
t.Fatal("dungeon entry fixture skill repository unavailable")
}
catalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "11 `job11.lst`\n",
"skill/job11.lst": "1 `job11/active.skl`\n",
"skill/job11/active.skl": "[skill type]\n`active`\n",
})
if err != nil {
t.Fatal(err)
}
service.skillCatalog = catalog
service.initialSkillsByJob = map[byte][]initialSkillEntry{
11: {{SkillID: 1, Level: 1}},
}
service.initialSPTable = map[int]int{20: 20}
service.initialTPTable = map[int]int{20: 0}
if err := repositories.Skill.Save(context.Background(), dnfrepo.SkillRecord{
CharacterID: "99",
Points: dnfrepo.SkillPointState{TotalSP: 20, RemainingSP: 20, SyncedLevel: 20},
Skills: map[int64]dnfrepo.SkillState{1: {Level: 1, Enabled: true}},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
channel: channelcatalog.Channel{ID: 16},
residentChannel: channelcatalog.Channel{ID: 16},
selectedCharacterID: 99,
}
if err := service.sendCurrentPostStartMapPlayerPlacement(session, runtime, runtime.Session.Snapshot().Scene, "test_dungeon_skill_initialization"); err != nil {
t.Fatal(err)
}
mode1Index, buffIndex, skillIndex, insertIndex, layoutIndex := -1, -1, -1, -1, -1
stream := connection.write.Bytes()
packetIndex := 0
for len(stream) > 0 {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) > 0 && packet.Body[0] == 1 {
mode1Index = packetIndex
}
if packet.Header.MsgID == skillSwitchInventoryAllDataWire {
buffIndex = packetIndex
}
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSkillInfoMsgID {
skillIndex = packetIndex
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketInsertOverseer) {
insertIndex = packetIndex
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketReportClientSpec) {
layoutIndex = packetIndex
}
packetIndex++
stream = rest
}
if mode1Index < 0 || buffIndex < 0 || skillIndex < 0 || insertIndex < 0 || layoutIndex < 0 {
t.Fatalf("dungeon skill packets mode1=%d skill=%d insert=%d layout=%d buff=%d", mode1Index, skillIndex, insertIndex, layoutIndex, buffIndex)
}
if !(mode1Index < skillIndex && skillIndex < insertIndex && insertIndex < layoutIndex && layoutIndex < buffIndex) {
t.Fatalf("dungeon skill packet order mode1=%d skill=%d insert=%d layout=%d buff=%d", mode1Index, skillIndex, insertIndex, layoutIndex, buffIndex)
}
if !session.dungeonSceneSkillInfoSent {
t.Fatal("dungeonSceneSkillInfoSent = false after op19")
}
}
func TestCurrentPostStartMapPreinstallsSwordDemonSnakeSwordBeforeDungeonMode1(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true})
repositories, ok := service.repositoryGroup()
if !ok || repositories.Skill == nil || repositories.Character == nil {
t.Fatal("Sword Demon dungeon entry repositories unavailable")
}
character, found, err := repositories.Character.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load Sword Demon character: found=%t err=%v", found, err)
}
character.Stats["grow_type"] = int64(currentSwordDemonFirstGrowType)
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
catalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "11 `job11.lst`\n",
"skill/job11.lst": "31 `atswordman/calldaimus.skl`\n",
"skill/atswordman/calldaimus.skl": "[skill type]\n`passive`\n[skill class]\n3\n[skill fitness growtype]\n3\n",
})
if err != nil {
t.Fatal(err)
}
service.skillCatalog = catalog
service.professionMu.Lock()
service.professionProfiles = nil
service.professionProfilesErr = nil
service.professionMu.Unlock()
service.initialSkillsByJob = map[byte][]initialSkillEntry{
currentATSwordmanJob: {{SkillID: currentSwordDemonSnakeSwordSkillID, Level: 1}},
}
service.initialSPTable = map[int]int{20: 20}
service.initialTPTable = map[int]int{20: 0}
skillRecord := dnfrepo.SkillRecord{
CharacterID: "99",
Points: dnfrepo.SkillPointState{TotalSP: 20, RemainingSP: 20, SyncedLevel: 20},
Skills: map[int64]dnfrepo.SkillState{
currentSwordDemonSnakeSwordSkillID: {Level: 1, Enabled: true},
},
}
dnfrepo.SetSkillTree(&skillRecord, 1, dnfrepo.SkillTreeState{
Skills: map[int64]dnfrepo.SkillState{
currentSwordDemonSnakeSwordSkillID: {Level: 1, Enabled: true},
},
Points: skillRecord.Points,
})
if err := repositories.Skill.Save(context.Background(), skillRecord); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
channel: channelcatalog.Channel{ID: 16},
residentChannel: channelcatalog.Channel{ID: 16},
selectedCharacterID: 99,
}
if err := service.sendCurrentPostStartMapPlayerPlacement(
session,
runtime,
runtime.Session.Snapshot().Scene,
"test_sword_demon_snake_sword_prebind",
); err != nil {
t.Fatal(err)
}
mode1Index := -1
skillIndices := make([]int, 0, 2)
skillBodies := make([][]byte, 0, 2)
stream := connection.write.Bytes()
for packetIndex := 0; len(stream) > 0; packetIndex++ {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) > 0 && packet.Body[0] == 1 {
mode1Index = packetIndex
}
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSkillInfoMsgID {
skillIndices = append(skillIndices, packetIndex)
skillBodies = append(skillBodies, append([]byte(nil), packet.Body...))
}
stream = rest
}
if mode1Index < 0 || len(skillIndices) != 1 || !(skillIndices[0] < mode1Index) {
t.Fatalf("Snake Sword dungeon order skills=%v mode1=%d (post-mode1 op19 must be skipped, otherwise the one-shot weapon form is rebuilt away)", skillIndices, mode1Index)
}
for index, body := range skillBodies {
slots := currentSkillInfoFirstTreeSlots(t, body)
foundSnakeSword := false
for _, skillID := range slots {
foundSnakeSword = foundSnakeSword || int64(skillID) == currentSwordDemonSnakeSwordSkillID
}
if !foundSnakeSword {
t.Fatalf("Snake Sword op19[%d] slots=%v", index, slots)
}
}
if !session.dungeonSceneSkillInfoSent {
t.Fatal("dungeonSceneSkillInfoSent = false after final Sword Demon op19")
}
}
func TestCurrentDungeonActorPassivePrebindSelectsOnlyPVFProvenGrowths(t *testing.T) {
tests := []struct {
name string
job string
growType int64
wantSkill int64
want bool
}{
{name: "Soul Bender first awakening", job: "0", growType: 0x12, wantSkill: currentSoulBenderFearAuraSkillID, want: true},
{name: "Soul Bender second awakening", job: "0", growType: 0x22, wantSkill: currentSoulBenderFearAuraSkillID, want: true},
{name: "Soul Bender before awakening", job: "0", growType: 0x02},
{name: "Swordman other awakened branch", job: "0", growType: 0x11},
{name: "Kunoichi Leave Trace TP", job: "6", growType: 0x03, wantSkill: currentKunoichiLeaveTraceEXSkillID, want: true},
{name: "Thief other branch", job: "6", growType: 0x02},
{name: "Male Brawler second awakening", job: "7", growType: 0x23, wantSkill: currentMaleBrawlerRuleBreakSkillID, want: true},
{name: "Male Brawler first awakening", job: "7", growType: 0x13},
{name: "Dimension Walker second awakening", job: "8", growType: 0x25, wantSkill: currentDimensionWalkerChaosStoneSkillID, want: true},
{name: "Male Mage other second awakening", job: "8", growType: 0x24},
{name: "Sword Demon", job: "11", growType: 0x03, wantSkill: currentSwordDemonSnakeSwordSkillID, want: true},
{name: "Paladin first awakening", job: "12", growType: 0x13, wantSkill: currentPaladinGloryOfLightSkillID, want: true},
{name: "Paladin second awakening", job: "12", growType: 0x23, wantSkill: currentPaladinCommandmentSkillID, want: true},
{name: "Dragon Knight second awakening", job: "12", growType: 0x24, wantSkill: currentDragonKnightHeirSkillID, want: true},
{name: "Dragon Knight first awakening", job: "12", growType: 0x14},
{name: "out of range grow type", job: "0", growType: 0x112},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
character := dnfrepo.CharacterRecord{
Job: test.job,
Stats: map[string]int64{"grow_type": test.growType},
}
rule, found := currentDungeonActorPassivePrebindForCharacter(character)
if found != test.want {
t.Fatalf("prebind found=%t, want %t rule=%+v", found, test.want, rule)
}
if found && !currentSkillIDsContain(rule.skillIDs, test.wantSkill) {
t.Fatalf("prebind skills=%v, want %d", rule.skillIDs, test.wantSkill)
}
})
}
}
func TestCurrentDungeonActorPassivePrebindDoesNotSendUnlearnedPaidFearAura(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
catalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "0 `swordman.lst`\n",
"skill/swordman.lst": "46 `swordman/active.skl`\n89 `swordman/darkaura.skl`\n",
"skill/swordman/active.skl": "[type]\n`active`\n",
"skill/swordman/darkaura.skl": "[type]\n`passive`\n[skill class]\n3\n[skill fitness second growtype]\n1 1\n[purchase cost]\n45\n",
})
if err != nil {
t.Fatal(err)
}
record := dnfrepo.SkillRecord{
CharacterID: "99",
Skills: map[int64]dnfrepo.SkillState{
46: {Level: 1, Enabled: true},
},
Points: dnfrepo.SkillPointState{TotalSP: 20, RemainingSP: 20, SyncedLevel: 20},
}
dnfrepo.SetSkillTree(&record, 1, dnfrepo.SkillTreeState{Skills: record.Skills, Points: record.Points})
if err := repositories.Skill.Save(context.Background(), record); err != nil {
t.Fatal(err)
}
service := &Service{
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
skillCatalog: catalog,
initialSkillsByJob: map[byte][]initialSkillEntry{
currentSwordmanJob: {{SkillID: 46, Level: 1}},
},
initialSPTable: map[int]int{20: 20},
initialTPTable: map[int]int{20: 0},
}
connection := &bufferConn{}
projection, ready, err := service.buildAndSendDungeonActorPassivePrebind(
&gameSession{conn: connection},
context.Background(),
dnfrepo.CharacterRecord{
CharacterID: "99",
Job: "0",
Level: 20,
Stats: map[string]int64{"grow_type": 0x12},
},
"test_unlearned_paid_fear_aura",
)
if err != nil || !ready {
t.Fatalf("unlearned Fear Aura projection ready=%t err=%v", ready, err)
}
if currentProjectionHasEnabledSkill(projection, currentSoulBenderFearAuraSkillID) {
t.Fatalf("unlearned paid Fear Aura entered projection: %+v", projection.learnedSkillIDs)
}
if connection.write.Len() != 0 {
t.Fatalf("unlearned paid Fear Aura sent pre-mode1 op19: %x", connection.write.Bytes())
}
}
func TestCurrentDungeonActorPassivePrebindSendsPaladinSkillsInOneProjection(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
catalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "12 `knight.lst`\n",
"skill/knight.lst": "120 `knight/gloryofthelight.skl`\n149 `knight/commandment.skl`\n",
"skill/knight/gloryofthelight.skl": "[type]\n`passive`\n[skill class]\n3\n[skill fitness second growtype]\n1\n[purchase cost]\n45\n",
"skill/knight/commandment.skl": "[type]\n`passive`\n[skill class]\n3\n[skill fitness second growtype]\n2\n[purchase cost]\n80\n",
})
if err != nil {
t.Fatal(err)
}
record := dnfrepo.SkillRecord{
CharacterID: "99",
Skills: map[int64]dnfrepo.SkillState{
currentPaladinGloryOfLightSkillID: {Level: 1, Enabled: true},
currentPaladinCommandmentSkillID: {Level: 1, Enabled: true},
},
Points: dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 0, SyncedLevel: 90},
}
dnfrepo.SetSkillTree(&record, 1, dnfrepo.SkillTreeState{Skills: record.Skills, Points: record.Points})
if err := repositories.Skill.Save(context.Background(), record); err != nil {
t.Fatal(err)
}
service := &Service{
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
skillCatalog: catalog,
initialSkillsByJob: map[byte][]initialSkillEntry{currentActorPassiveKnightJob: {}},
initialSPTable: map[int]int{90: 100},
initialTPTable: map[int]int{90: 0},
}
connection := &bufferConn{}
projection, ready, err := service.buildAndSendDungeonActorPassivePrebind(
&gameSession{conn: connection},
context.Background(),
dnfrepo.CharacterRecord{
CharacterID: "99",
Job: "12",
Level: 90,
Stats: map[string]int64{"grow_type": 0x23},
},
"test_paladin_multiple_entry_passives",
)
if err != nil || !ready {
t.Fatalf("Paladin prebind ready=%t err=%v", ready, err)
}
for _, skillID := range []int64{currentPaladinGloryOfLightSkillID, currentPaladinCommandmentSkillID} {
if !currentProjectionHasEnabledSkill(projection, skillID) {
t.Fatalf("Paladin projection omitted skill %d: %v", skillID, projection.learnedSkillIDs)
}
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || packet.Header.Classification != 0 || packet.Header.MsgID != currentSkillInfoMsgID {
t.Fatalf("Paladin prebind packet=%+v trailing=%x", packet.Header, trailing)
}
slots := currentSkillInfoFirstTreeSlots(t, packet.Body)
for _, want := range []int64{currentPaladinGloryOfLightSkillID, currentPaladinCommandmentSkillID} {
found := false
for _, skillID := range slots {
found = found || int64(skillID) == want
}
if !found {
t.Fatalf("Paladin one-shot prebind omitted skill %d from slots=%v", want, slots)
}
}
}
func TestCurrentPostStartMapPreinstallsSoulBenderFearAuraBeforeDungeonMode1(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true})
repositories, ok := service.repositoryGroup()
if !ok || repositories.Skill == nil || repositories.Character == nil {
t.Fatal("Soul Bender dungeon entry repositories unavailable")
}
character, found, err := repositories.Character.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load Soul Bender character: found=%t err=%v", found, err)
}
character.Job = "0"
character.Stats["grow_type"] = 0x12
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
catalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "0 `swordman.lst`\n",
"skill/swordman.lst": "89 `swordman/darkaura.skl`\n",
"skill/swordman/darkaura.skl": "[type]\n`passive`\n[skill class]\n3\n[skill fitness second growtype]\n1 1\n[purchase cost]\n45\n",
})
if err != nil {
t.Fatal(err)
}
service.skillCatalog = catalog
service.professionMu.Lock()
service.professionProfiles = nil
service.professionProfilesErr = nil
service.professionMu.Unlock()
service.initialSkillsByJob = map[byte][]initialSkillEntry{currentSwordmanJob: {}}
service.initialSPTable = map[int]int{20: 20}
service.initialTPTable = map[int]int{20: 0}
skillRecord := dnfrepo.SkillRecord{
CharacterID: "99",
Points: dnfrepo.SkillPointState{TotalSP: 20, RemainingSP: 20, SyncedLevel: 20},
Skills: map[int64]dnfrepo.SkillState{
currentSoulBenderFearAuraSkillID: {Level: 1, Enabled: true},
},
}
dnfrepo.SetSkillTree(&skillRecord, 1, dnfrepo.SkillTreeState{
Skills: map[int64]dnfrepo.SkillState{
currentSoulBenderFearAuraSkillID: {Level: 1, Enabled: true},
},
Points: skillRecord.Points,
})
if err := repositories.Skill.Save(context.Background(), skillRecord); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
channel: channelcatalog.Channel{ID: 16},
residentChannel: channelcatalog.Channel{ID: 16},
selectedCharacterID: 99,
}
if err := service.sendCurrentPostStartMapPlayerPlacement(
session,
runtime,
runtime.Session.Snapshot().Scene,
"test_soul_bender_fear_aura_prebind",
); err != nil {
t.Fatal(err)
}
mode1Index := -1
skillIndices := make([]int, 0, 2)
skillBodies := make([][]byte, 0, 2)
stream := connection.write.Bytes()
for packetIndex := 0; len(stream) > 0; packetIndex++ {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) > 0 && packet.Body[0] == 1 {
mode1Index = packetIndex
}
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSkillInfoMsgID {
skillIndices = append(skillIndices, packetIndex)
skillBodies = append(skillBodies, append([]byte(nil), packet.Body...))
}
stream = rest
}
if mode1Index < 0 || len(skillIndices) != 2 || !(skillIndices[0] < mode1Index && mode1Index < skillIndices[1]) {
t.Fatalf("Fear Aura dungeon order skills=%v mode1=%d", skillIndices, mode1Index)
}
for index, body := range skillBodies {
slots := currentSkillInfoFirstTreeSlots(t, body)
foundFearAura := false
for _, skillID := range slots {
foundFearAura = foundFearAura || int64(skillID) == currentSoulBenderFearAuraSkillID
}
if !foundFearAura {
t.Fatalf("Fear Aura op19[%d] slots=%v", index, slots)
}
}
if !session.dungeonSceneSkillInfoSent {
t.Fatal("dungeonSceneSkillInfoSent = false after final Soul Bender op19")
}
}
type failNthDungeonWriteConn struct {
bufferConn
writes int
failAt int
err error
}
func (connection *failNthDungeonWriteConn) Write(data []byte) (int, error) {
connection.writes++
if connection.writes == connection.failAt {
return 0, connection.err
}
return connection.bufferConn.Write(data)
}
func prepareSyntheticDungeonRuntimeForEntryTest(
t *testing.T,
source bridgePVFSource,
aiCatalog *pvfDungeonAICharacterCatalog,
) *runtimeDungeonState {
t.Helper()
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Level: 20,
Stats: map[string]int64{"fatigue": 100},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonAICharacterTable: aiCatalog,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{selectedCharacterID: 99}
runtime, _, err := service.prepareDungeonRuntime(
context.Background(),
session,
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
)
if err != nil {
t.Fatal(err)
}
return runtime
}