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