Files
90CNServer/server/internal/services/dnfbridge/select_character_scene_gate_test.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1429 lines
60 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
dnfcollectbox "longheng.io/server/internal/modules/dnf/collectbox"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfinventory "longheng.io/server/internal/modules/dnf/inventory"
dnfitemlock "longheng.io/server/internal/modules/dnf/itemlock"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestSelectedCharacterStartsInTutorialUsesPersistedCompletion(t *testing.T) {
tests := []struct {
name string
character dnfrepo.CharacterRecord
hasCharacter bool
want bool
}{
{
name: "new character pending",
character: dnfrepo.CharacterRecord{Level: 1, Stats: map[string]int64{currentDungeonTutorialCompletedKey: 0}},
hasCharacter: true,
want: true,
},
{
name: "pending marker remains authoritative after level change",
character: dnfrepo.CharacterRecord{Level: 2, Stats: map[string]int64{currentDungeonTutorialCompletedKey: 0}},
hasCharacter: true,
want: true,
},
{
name: "completed tutorial starts in town",
character: dnfrepo.CharacterRecord{Level: 1, Stats: map[string]int64{currentDungeonTutorialCompletedKey: currentDungeonTutorialCompleteFlag}},
hasCharacter: true,
want: false,
},
{
name: "legacy established character without marker starts in town",
character: dnfrepo.CharacterRecord{Level: 30, Stats: map[string]int64{}},
hasCharacter: true,
want: false,
},
{
name: "legacy level one character without marker starts tutorial",
character: dnfrepo.CharacterRecord{Level: 1, Stats: map[string]int64{}},
hasCharacter: true,
want: true,
},
{
name: "missing character does not invent tutorial state",
hasCharacter: false,
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := selectedCharacterStartsInTutorial(test.character, test.hasCharacter); got != test.want {
t.Fatalf("selectedCharacterStartsInTutorial() = %v, want %v", got, test.want)
}
})
}
}
func TestSpecialChannelSelectCharacterHelpersUseOrdinaryScenePath(t *testing.T) {
service, session, _ := newTownMoveTest(t)
connection := session.conn.(*bufferConn)
session.channel = channelcatalog.Channel{ServerID: 1, ID: 200, Type: 23, Name: "special", Port: 10200}
session.residentChannel = session.channel
connection.write.Reset()
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
if err := service.sendUpperCSharpSelectInit(session, request); err != nil {
t.Fatal(err)
}
if connection.write.Len() == 0 {
t.Fatal("special channel upper select helper remained blocked")
}
connection.write.Reset()
legacyRequest := make([]byte, 11)
if err := service.sendSelectCharacterState(session, legacyRequest); err != nil {
t.Fatal(err)
}
if connection.write.Len() == 0 {
t.Fatal("special channel legacy select helper remained blocked")
}
}
func TestCompletedTutorialSelectUsesAckPage1RouteWithoutParallelTransition(t *testing.T) {
const characterID = uint16(60042)
repos := testRepositoryGroup()
repos.Character.(*fakeCharacterStore).records = map[string]dnfrepo.CharacterRecord{
"60042": {
CharacterID: "60042",
AccountID: defaultAccountPrefix + "1",
Slot: 0,
Name: "completed",
Job: "0",
Level: 1,
Stats: map[string]int64{
"town_id": 38,
"area_id": 1,
"pos_x": 450,
"pos_y": 234,
"direction": 5,
"area_state": 3,
currentDungeonTutorialCompletedKey: currentDungeonTutorialCompleteFlag,
},
},
}
service := &Service{
options: options{accountPrefix: defaultAccountPrefix, pvfPath: "Script.pvf"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 16, Type: 1, Name: "ch.16", Port: 10016},
residentChannel: channelcatalog.Channel{ID: 16, Type: 1, Name: "ch.16", Port: 10016},
}
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, characterID)
if err := service.sendUpperCSharpSelectInit(session, request); err != nil {
t.Fatalf("send completed select init: %v", err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketSelectCharacter) {
t.Fatalf("first packet msg=%d, want select ACK", ack.Header.MsgID)
}
flag, count, ok := currentSelectAckTutorialState(ack.Body)
if !ok || flag != 0 || count != 1 {
t.Fatalf("completed select ACK tutorial state flag=%d count=%d ok=%t, want 0/1/true", flag, count, ok)
}
indexes, ok := currentSelectAckTutorialIndexes(ack.Body)
if !ok || len(indexes) != 1 || indexes[0] != currentSelectAckPage1RouteIndex {
t.Fatalf("completed select ACK tutorial indexes=%v ok=%t, want [%d]/true", indexes, ok, currentSelectAckPage1RouteIndex)
}
rest = requireCurrentStoryDigestLastLevelPacket(t, rest, 0)
if len(rest) != 0 {
next, _ := splitGameServerUpperPacket(t, rest)
t.Fatalf("completed character received parallel scene packet class=%d msg=%d body=%x", next.Header.Classification, next.Header.MsgID, next.Body)
}
if session.initialTownRouteCharacterID != characterID ||
session.initialTownRouteStage != currentInitialTownRouteArmed {
t.Fatalf("completed select route char=%d stage=%d, want %d/armed", session.initialTownRouteCharacterID, session.initialTownRouteStage, characterID)
}
if returnSelectTownReentryPending(session) {
t.Fatal("cold first select invented a return-select re-entry owner")
}
}
func TestReturnSelectReselectResumesTownRouteWithoutSyntheticProgress36Ack(t *testing.T) {
service, session, repositories := newTownMoveTest(t)
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{
AccountID: defaultAccountPrefix + "1",
Metadata: make(map[string]string),
}); err != nil {
t.Fatal(err)
}
skillCatalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "0 `job0.lst`\n",
"skill/job0.lst": "33 `job0/equip_lightsword.skl`\n46 `job0/initial.skl`\n",
"skill/job0/equip_lightsword.skl": "[skill type]\n`passive`\n",
"skill/job0/initial.skl": "[skill type]\n`active`\n",
})
if err != nil {
t.Fatal(err)
}
service.initialSkillsByJob = map[byte][]initialSkillEntry{0: {
{SkillID: 33, Level: 2},
{SkillID: 46, Level: 1},
}}
service.initialSPTable = map[int]int{1: 20}
service.skillCatalog = skillCatalog
service.questCatalog = buildQuestListTestCatalog(t)
service.options.accountPrefix = defaultAccountPrefix
service.adventureGroupTable = loadAdventureGroupTestTables(t)
service.collectboxCatalog = &currentCollectboxCatalog{
byIndex: map[byte]dnfcollectbox.Definition{
defaultCollectboxIndex: {Index: defaultCollectboxIndex},
},
indexes: []byte{defaultCollectboxIndex},
}
questIndex, err := dnfpvf.Build(context.Background(), questListTestSource{
dnfquest.DefaultList: "900 `visible.qst`\n901 `active.qst`\n",
"n_quest/visible.qst": "[grade]\n`[epic]`\n[level]\n1 10\n[exposed by npc]\n1\n",
"n_quest/active.qst": "[grade]\n`[epic]`\n[level]\n1 10\n[exposed by npc]\n1\n",
}, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
service.questCatalog, err = dnfquest.Load(context.Background(), questIndex)
if err != nil {
t.Fatal(err)
}
character, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load town character found=%t err=%v", found, err)
}
character.Name = "return-select-reentry"
character.Job = "0"
character.Level = 4
character.Stats["exp"] = 4948
character.Stats[currentDungeonTutorialCompletedKey] = currentDungeonTutorialCompleteFlag
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
skillRecord, found, err := repositories.Skill.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load light-sword skill record found=%t err=%v", found, err)
}
skillRecord.Skills[33] = dnfrepo.SkillState{Level: 2, Enabled: true}
if skillRecord.Layouts == nil {
skillRecord.Layouts = make(map[int]dnfrepo.SkillLayout)
}
if skillRecord.Layouts[currentSkillInfoTreeIndex] == nil {
skillRecord.Layouts[currentSkillInfoTreeIndex] = make(dnfrepo.SkillLayout)
}
skillRecord.Layouts[currentSkillInfoTreeIndex][33] = 33
if err := repositories.Skill.Save(context.Background(), skillRecord); err != nil {
t.Fatal(err)
}
session.channel = channelcatalog.Channel{ServerID: 1, ID: 253, Type: 40, Name: "ch.253", Port: 10253}
session.residentChannel = session.channel
session.connectionTownActorOwnerChannel = 253
session.selectedCharacterID = 0
markReturnSelectTownReentry(session, 29)
session.crystalContractTownUIReadyStateSent = true
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
if err := service.sendUpperCSharpSelectInit(session, request); err != nil {
t.Fatal(err)
}
fullRouteStream := append([]byte(nil), session.conn.(*bufferConn).write.Bytes()...)
assertNoCurrentChannelNoticeInTownRoute(t, fullRouteStream)
if got := countCurrentActorModePackets(t, fullRouteStream, 0); got != 1 {
t.Fatalf("return-select mode0 packet count=%d want one actor creation", got)
}
if got := countCurrentActorModePackets(t, fullRouteStream, 1); got != 1 {
t.Fatalf("return-select mode1 packet count=%d want one complete actor state", got)
}
stream := fullRouteStream
selectAck, stream := splitGameServerUpperPacket(t, stream)
if selectAck.Header.Classification != dnfproto.DefaultChannelClassification ||
selectAck.Header.MsgID != uint16(dnfenum.CmdPacketSelectCharacter) {
t.Fatalf("select ack=%+v body=%x", selectAck.Header, selectAck.Body)
}
stream = requireCurrentStoryDigestLastLevelPacket(t, stream, 0)
actionTable, stream := splitGameServerUpperPacket(t, stream)
if actionTable.Header.Classification != 0 ||
actionTable.Header.MsgID != uint16(dnfenum.CmdPacketPVPMissionHpPercent) ||
!bytes.Equal(actionTable.Body, buildCurrentActionTableStateBody()) {
t.Fatalf("action table=%+v body=%x", actionTable.Header, actionTable.Body)
}
retainedActorRemoval, stream := splitLongHengGameServerUpperPacket(t, stream)
if retainedActorRemoval.Header.Classification != 0 ||
retainedActorRemoval.Header.MsgID != currentTownUserLeaveMsgID ||
!bytes.Equal(retainedActorRemoval.Body, buildTownUserLeaveBody(29)) {
t.Fatalf("same-character retained actor removal=%+v body=%x", retainedActorRemoval.Header, retainedActorRemoval.Body)
}
mode0, stream := splitGameServerUpperPacket(t, stream)
actorOwner := currentTownActorOwnerContext(session)
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] != actorOwner {
t.Fatalf("mode0=%+v body=%x", mode0.Header, mode0.Body)
}
transition, preparedState := splitReturnSelectEarlyTransition(t, stream)
rest := requireNoCurrentChannelNoticeAfterTownTransition(t, preparedState)
if transition.Header.Classification != 0 || transition.Header.MsgID != currentSceneTransitionMsgID {
t.Fatalf("transition=%+v body=%x rest=%x", transition.Header, transition.Body, rest)
}
for len(rest) > 0 {
packet, next := splitCurrentGameServerUpperPacketAuto(t, rest)
if packetLooksLikeInitialTownTransition(packet) {
t.Fatalf("return-select emitted a second op24 body=%x", packet.Body)
}
rest = next
}
rest = nil
if session.townPostTransition.stage != currentTownPostTransitionIdle {
t.Fatalf("return-select post-transition stage=%d want idle", session.townPostTransition.stage)
}
if len(preparedState) == 0 {
t.Fatal("return-select transition had no complete player state after op24")
}
returnSelectOp9 := 0
returnSelectActorRemoval := 0
returnSelectPlacement := 0
returnSelectCollectboxACK := 0
transitionIndex := -1
packetIndex := 0
for ownerStream := fullRouteStream; len(ownerStream) > 0; packetIndex++ {
packet, next := splitCurrentGameServerUpperPacketAuto(t, ownerStream)
ownerStream = next
if packetLooksLikeInitialTownTransition(packet) {
transitionIndex = packetIndex
}
if packet.Header.Classification == 0 &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketRecoverStamina) {
returnSelectOp9++
assertCurrentPartylessTownOp9Body(t, packet.Body, byte(253))
if transitionIndex >= 0 {
t.Fatal("return-select op9 was sent after op24")
}
}
if packet.Header.Classification == 0 && packet.Header.MsgID == currentTownUserLeaveMsgID {
returnSelectActorRemoval++
if !bytes.Equal(packet.Body, buildTownUserLeaveBody(29)) || transitionIndex >= 0 {
t.Fatalf("same-character actor removal body=%x transition_index=%d", packet.Body, transitionIndex)
}
}
if packet.Header.Classification == 0 &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketRequestBlacklist) &&
bytes.Equal(packet.Body, buildCurrentSceneActorPlacementBody()) {
returnSelectPlacement++
if transitionIndex >= 0 {
t.Fatal("return-select op120 placement was sent after op24")
}
}
if packet.Header.Classification == dnfproto.DefaultChannelClassification &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketSelectCollectbox) {
returnSelectCollectboxACK++
}
}
if returnSelectOp9 != 1 || returnSelectActorRemoval != 1 || returnSelectPlacement != 1 || returnSelectCollectboxACK != 1 || transitionIndex < 0 {
t.Fatalf("same-character return-select op9/op6/op120/op1021/op24=%d/%d/%d/%d/%d want 1/1/1/1/present", returnSelectOp9, returnSelectActorRemoval, returnSelectPlacement, returnSelectCollectboxACK, transitionIndex)
}
if selectAck.Header.MsgID == uint16(dnfenum.CmdPacketChangeTutorialFlag) ||
actionTable.Header.MsgID == uint16(dnfenum.CmdPacketChangeTutorialFlag) {
t.Fatal("return-select re-entry emitted a synthetic op143 ACK")
}
if returnSelectTownReentryPending(session) ||
session.initialTownRouteStage != currentInitialTownRoutePlayerStateSent ||
!session.currentSceneObjectListSent ||
session.townActorOwnerChannel != actorOwner {
t.Fatalf(
"re-entry pending=%t stage=%d object=%t owner=%d want=%d",
returnSelectTownReentryPending(session),
session.initialTownRouteStage,
session.currentSceneObjectListSent,
session.townActorOwnerChannel,
actorOwner,
)
}
connection := session.conn.(*bufferConn)
connection.write.Reset()
barrier, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCheckUserConnection),
nil,
0,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, barrier); err != nil {
t.Fatal(err)
}
barrierAck, playerState := splitGameServerUpperPacket(t, connection.write.Bytes())
if barrierAck.Header.MsgID != uint16(dnfenum.UpperMsgCheckUserConnection) ||
!bytes.Equal(barrierAck.Body, upperSuccessBody(buildCurrentCheckUserConnectionSuccessPayload())) || len(playerState) != 0 {
t.Fatalf("barrier ack=%+v body=%x player_state=%d", barrierAck.Header, barrierAck.Body, len(playerState))
}
if session.initialTownRouteStage != currentInitialTownRoutePlayerStateSent ||
!session.selectedUserInfoRefreshSent || session.selectedUserInfoMode3Sent ||
session.sceneBootstrapTailSent || !session.sceneBootstrapTailDeferred {
t.Fatalf("re-entry full state stage=%d mode1=%t mode3_sent=%t tail_sent=%t tail_deferred=%t",
session.initialTownRouteStage,
session.selectedUserInfoRefreshSent,
session.selectedUserInfoMode3Sent,
session.sceneBootstrapTailSent,
session.sceneBootstrapTailDeferred)
}
op37Count := 0
op19Count := 0
for stream := playerState; len(stream) > 0; {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
stream = rest
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSkillInfoMsgID {
op19Count++
}
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketFinishLoading) {
op37Count++
}
}
if op37Count != 0 || op19Count != 0 || session.currentFinishLoadingStateSent {
t.Fatalf("return-select unsolicited progression=%d skill_replays=%d gate=%t", op37Count, op19Count, session.currentFinishLoadingStateSent)
}
}
// splitReturnSelectEarlyTransition proves that reselect commits the persisted
// town after exactly one complete actor binding and the scene/location rows.
func splitReturnSelectEarlyTransition(t *testing.T, stream []byte) (dnfproto.ChannelPacket, []byte) {
t.Helper()
sceneSnapshot := 0
userPosition := 0
userArea := 0
adventureInfo := 0
mode1 := 0
for len(stream) > 0 {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
stream = rest
if packetLooksLikeInitialTownTransition(packet) {
if sceneSnapshot != 1 || userPosition != 1 || userArea != 1 || adventureInfo != 1 || mode1 != 1 {
t.Fatalf("return-select early op24 prerequisites op800=%d noti16=%d noti17=%d op1340=%d mode1=%d want 1/1/1/1/1", sceneSnapshot, userPosition, userArea, adventureInfo, mode1)
}
if len(packet.Body) != 12 || packet.Body[0] != 38 || packet.Body[1] != 1 ||
binary.LittleEndian.Uint16(packet.Body[6:8]) != 450 ||
binary.LittleEndian.Uint16(packet.Body[8:10]) != 234 || packet.Body[10] != 5 {
t.Fatalf("return-select early op24 body=%x want town=38 area=1 x=450 y=234 direction=5", packet.Body)
}
return packet, stream
}
switch {
case packet.Header.Classification == 0 && packet.Header.MsgID == currentTownActorSceneSnapshotMsgID:
sceneSnapshot++
case packet.Header.Classification == 0 && packet.Header.MsgID == currentTownUserPositionNotificationMsgID:
userPosition++
case packet.Header.Classification == 0 && packet.Header.MsgID == currentTownUserAreaNotificationMsgID:
userArea++
case packet.Header.Classification == 0 && packet.Header.MsgID == currentAdventureInfoPushMsgID:
adventureInfo++
case packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) > 0 && packet.Body[0] == 1:
mode1++
}
}
t.Fatal("return-select route omitted early op24")
return dnfproto.ChannelPacket{}, nil
}
func TestCompletedSelectProgress36BindsRealActorBeforeTypedTownTransition(t *testing.T) {
service, session, repositories := newTownMoveTest(t)
skillCatalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "0 `job0.lst`\n",
"skill/job0.lst": "33 `job0/equip_lightsword.skl`\n46 `job0/initial.skl`\n",
"skill/job0/equip_lightsword.skl": "[skill type]\n`passive`\n",
"skill/job0/initial.skl": "[skill type]\n`active`\n",
})
if err != nil {
t.Fatal(err)
}
service.initialSkillsByJob = map[byte][]initialSkillEntry{0: {
{SkillID: 33, Level: 2},
{SkillID: 46, Level: 1},
}}
service.initialSPTable = map[int]int{1: 20}
service.skillCatalog = skillCatalog
service.questCatalog = buildQuestListTestCatalog(t)
service.options.accountPrefix = defaultAccountPrefix
service.adventureGroupTable = loadAdventureGroupTestTables(t)
character, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load town character found=%t err=%v", found, err)
}
character.Name = "completed-town"
character.Job = "0"
character.Level = 4
character.Stats["exp"] = 4948
character.Stats["area_id"] = 0
character.Stats["pos_x"] = 55
character.Stats["pos_y"] = 267
character.Stats[currentDungeonTutorialCompletedKey] = currentDungeonTutorialCompleteFlag
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
skillRecord, found, err := repositories.Skill.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load light-sword skill record found=%t err=%v", found, err)
}
skillRecord.Skills[33] = dnfrepo.SkillState{Level: 2, Enabled: true}
if skillRecord.Layouts == nil {
skillRecord.Layouts = make(map[int]dnfrepo.SkillLayout)
}
if skillRecord.Layouts[currentSkillInfoTreeIndex] == nil {
skillRecord.Layouts[currentSkillInfoTreeIndex] = make(dnfrepo.SkillLayout)
}
skillRecord.Layouts[currentSkillInfoTreeIndex][33] = 33
if err := repositories.Skill.Save(context.Background(), skillRecord); err != nil {
t.Fatal(err)
}
const lightSwordItemID = int64(101040191)
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "29",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {
SlotIndex: 12,
ItemID: lightSwordItemID,
RawEntry: buildInitialEquipmentRawEntry(12, lightSwordItemID, 45),
Extra: map[string]string{
"current_exe_equipment_type": "12",
"current_exe_runtime_move": "1",
"item_kind": "equipment",
},
},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{
AccountID: defaultAccountPrefix + "1",
Metadata: map[string]string{currentRentalPointMetadataKey: "124"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "29",
States: map[int64]dnfrepo.QuestState{
901: {Status: "active", ProgressValue: 1},
3149: {Status: "completed"},
},
}); err != nil {
t.Fatal(err)
}
if body, ok := service.buildCurrentAcceptableQuestListBodyForSession(context.Background(), session); !ok || len(body) <= 4 {
_, _, loadedCharacter, selectedFound := service.selectedCharacterForEnter(context.Background(), session)
record, recordFound, recordErr := repositories.Quest.Load(context.Background(), "29")
_, repoOK := service.repositoryGroup()
t.Fatalf("completed-town quest snapshot fixture skipped ok=%t body_len=%d repo_ok=%t catalog_nil=%t selected_found=%t loaded=%+v quest_found=%t quest_err=%v quest=%+v",
ok, len(body), repoOK, service.questCatalog == nil, selectedFound, loadedCharacter, recordFound, recordErr, record)
}
seededSlots := map[string]dnfrepo.ItemStack{
"0:3": {ItemID: 1003, Count: 1},
"0:4": {ItemID: 1004, Count: 2},
"0:5": {ItemID: 3227, Count: 4},
"0:6": {ItemID: 1006, Count: 1},
"0:7": {ItemID: 1007, Count: 1},
"0:8": {ItemID: 1008, Count: 1},
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "29",
Slots: seededSlots,
}); err != nil {
t.Fatal(err)
}
inventoryOwner, err := dnfinventory.NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
moveResult, err := inventoryOwner.Move(context.Background(), dnfinventory.Command{
SelectedCharacterID: 29,
SourceListType: dnfrepo.MainInventoryListType,
SourceSlotIndex: 3,
MoveCount: 1,
DestinationListType: dnfrepo.MainInventoryListType,
DestinationSlotIndex: 4,
DestinationStack: 2,
})
if err != nil {
t.Fatal(err)
}
if !moveResult.Changed || moveResult.Mode != "swap" {
t.Fatalf("quick-slot move result=%+v", moveResult)
}
pickupSlot, _, err := grantCurrentDungeonPickupItem(
context.Background(),
repositories.CharacterItems,
"29",
dungeonDropItemDefinition{
ItemID: 3227,
Kind: dungeonDropItemStackable,
PVFPath: "stackable/material/test_drop.stk",
StackableType: "[material]",
StackLimit: 999,
SlotStart: 65,
SlotEnd: 120,
},
2,
time.Unix(1_700_000_000, 0).UTC(),
nil,
)
if err != nil {
t.Fatal(err)
}
if pickupSlot != 65 {
t.Fatalf("material pickup slot=%d, want category slot 65", pickupSlot)
}
storedInventory, found, err := repositories.Inventory.Load(context.Background(), "29")
if err != nil || !found || storedInventory.Slots["0:5"].Count != 4 || storedInventory.Slots["0:65"].Count != 2 {
t.Fatalf("material pickup inventory found=%t err=%v slots=%+v", found, err, storedInventory.Slots)
}
connection := session.conn.(*bufferConn)
transitionWaitCalls := 0
transitionWaitDuration := time.Duration(0)
transitionWaitSawPassGate := false
transitionWaitSawFinalLocation := false
transitionWaitSawTypedTransition := false
service.initialTownEntryWait = func(delay time.Duration) {
transitionWaitCalls++
transitionWaitDuration = delay
for waitStream := append([]byte(nil), connection.write.Bytes()...); len(waitStream) > 0; {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, waitStream)
waitStream = rest
switch packet.Header.MsgID {
case uint16(dnfenum.CmdPacketPassGateObject):
transitionWaitSawPassGate = true
case currentTownUserPositionNotificationMsgID,
currentTownUserAreaNotificationMsgID:
transitionWaitSawFinalLocation = true
case currentSceneTransitionMsgID:
transitionWaitSawTypedTransition = true
}
}
}
session.channel = channelcatalog.Channel{ServerID: 1, ID: 253, Type: 40, Name: "ch.253", Port: 10253}
session.residentChannel = session.channel
session.connectionTownActorOwnerChannel = 253
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
if err := service.sendUpperCSharpSelectInit(session, request); err != nil {
t.Fatal(err)
}
selectAck, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if selectAck.Header.MsgID != uint16(dnfenum.CmdPacketSelectCharacter) {
t.Fatalf("completed select ack=%+v rest=%x", selectAck.Header, rest)
}
// sub_1D01C50 resolves this byte through the town catalog and initializes
// that town's gate before mode0 creates the actor, so it is the town scene
// the client builds first -- not a roster slot key.
selectAckTownID, selectAckTownIDOK := currentSelectAckTownIDFromBody(selectAck.Body)
if !selectAckTownIDOK || selectAckTownID != newCharacterInitialTownID {
t.Fatalf("completed select ACK town id=%d ok=%t want %d", selectAckTownID, selectAckTownIDOK, newCharacterInitialTownID)
}
rest = requireCurrentStoryDigestLastLevelPacket(t, rest, 0)
if len(rest) != 0 {
t.Fatalf("completed select emitted unexpected scene stream=%x", rest)
}
if session.initialTownRouteCharacterID != 29 || session.initialTownRouteStage != currentInitialTownRouteArmed {
t.Fatalf("route after select char=%d stage=%d", session.initialTownRouteCharacterID, session.initialTownRouteStage)
}
connection.write.Reset()
progress36 := []byte{currentDungeonTutorialFinalPrefix, 36, 0, 0, 0, currentDungeonTutorialFinalCommit}
if err := service.handleDungeonTutorialFlag(session, progress36); err != nil {
t.Fatal(err)
}
fullRouteStream := append([]byte(nil), connection.write.Bytes()...)
assertNoCurrentChannelNoticeInTownRoute(t, fullRouteStream)
actorOwner := currentTownActorOwnerContext(session)
partyOwner := byte(253)
if got := countCurrentActorModePackets(t, fullRouteStream, 1); got != 1 {
t.Fatalf("cold completed-character mode1 packet count=%d want complete pre-op24 state", got)
}
ack143, stream := splitGameServerUpperPacket(t, fullRouteStream)
if ack143.Header.Classification != dnfproto.DefaultChannelClassification ||
ack143.Header.MsgID != uint16(dnfenum.CmdPacketChangeTutorialFlag) ||
!bytes.Equal(ack143.Body, []byte{1, 0}) {
t.Fatalf("progress36 ack=%+v body=%x", ack143.Header, ack143.Body)
}
actionTable, stream := splitGameServerUpperPacket(t, stream)
if actionTable.Header.Classification != 0 ||
actionTable.Header.MsgID != uint16(dnfenum.CmdPacketPVPMissionHpPercent) ||
!bytes.Equal(actionTable.Body, buildCurrentActionTableStateBody()) {
t.Fatalf("action table=%+v body=%x", actionTable.Header, actionTable.Body)
}
mode0, stream := splitGameServerUpperPacket(t, stream)
objectKey := currentSceneActorObjectKey(29)
if mode0.Header.Classification != 0 ||
mode0.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
len(mode0.Body) < 0x4e || mode0.Body[0] != 0 ||
mode0.Body[3] != 0 || mode0.Body[4] != actorOwner ||
binary.LittleEndian.Uint16(mode0.Body[0x4c:0x4e]) != objectKey {
t.Fatalf("mode0=%+v body=%x", mode0.Header, mode0.Body)
}
if offset, ok := currentSceneObjectTailActorStateEventArgOffset(mode0.Body); !ok ||
binary.LittleEndian.Uint32(mode0.Body[offset:offset+4]) != currentSceneActorStateEventArg {
t.Fatalf("cold-login mode0 actor-state event changed body=%x", mode0.Body)
}
preTransitionPlayerStateStream, transition, stream := splitInitialTownPreparedStateBeforeTransition(t, stream)
// The ACK already made the client build the gate scene for its town byte.
// The transition must name the same town, otherwise the client loads the
// ACK town and then loads the real one again.
if len(transition.Body) < 2 {
t.Fatalf("initial town transition body=%x", transition.Body)
}
if transition.Body[0] != selectAckTownID {
t.Fatalf(
"initial town transition town=%d area=%d, select ACK town=%d",
transition.Body[0],
transition.Body[1],
selectAckTownID,
)
}
stream = requireInitialTownPostTransitionQuestRefresh(t, stream)
stream = requireInitialTownGuildStateProjection(t, stream, nil)
stream = requireInitialTownCeraShopWalletProjection(t, stream)
if len(stream) != 0 {
t.Fatalf("initial town route post-op24 quest refresh trailing=%x", stream)
}
if session.townPostTransition.stage != currentTownPostTransitionIdle {
t.Fatalf("initial town post-transition stage=%d want idle", session.townPostTransition.stage)
}
fullMode1Count := 0
lightSwordCreated := false
op9Count := 0
initializationLists := make(map[byte]dnfproto.ChannelPacket, len(currentSelectInventoryBootstrapListTypes))
for ownerStream := preTransitionPlayerStateStream; len(ownerStream) > 0; {
packet, next := splitCurrentGameServerUpperPacketAuto(t, ownerStream)
ownerStream = next
switch {
case packet.Header.Classification == 0 &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) &&
len(packet.Body) > currentMode1BaseWireSize && packet.Body[0] == 1:
fullMode1Count++
if packet.Body[3] != 0 || packet.Body[4] != actorOwner {
t.Fatalf("cold full mode1 owner=%x want 00/%02x", packet.Body[:5], actorOwner)
}
if len(packet.Body) > currentMode1CreateRowsOffset+5 &&
packet.Body[currentMode1CreateCountOffset] > 0 &&
packet.Body[currentMode1CreateRowsOffset] == 12 &&
binary.LittleEndian.Uint32(packet.Body[currentMode1CreateRowsOffset+1:currentMode1CreateRowsOffset+5]) == uint32(lightSwordItemID) {
lightSwordCreated = true
}
case packet.Header.Classification == 0 &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketRecoverStamina):
op9Count++
assertCurrentPartylessTownOp9Body(t, packet.Body, partyOwner)
case packet.Header.Classification == 0 &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketLeaveParty) &&
len(packet.Body) > 0:
initializationLists[packet.Body[0]] = packet
}
}
if fullMode1Count != 1 || !lightSwordCreated || op9Count != 1 {
t.Fatalf("cold target-owned finalizers full_mode1=%d light_sword=%t op9=%d want 1/true/1", fullMode1Count, lightSwordCreated, op9Count)
}
stream = requireNoCurrentChannelNoticeAfterTownTransition(t, stream)
if transitionWaitCalls != 1 || transitionWaitDuration != currentInitialTownEntryDelay {
t.Fatalf("town entry cover boundary calls=%d duration=%s", transitionWaitCalls, transitionWaitDuration)
}
if !transitionWaitSawFinalLocation || transitionWaitSawTypedTransition {
t.Fatalf(
"town entry cover boundary pass_gate=%t final_location=%t typed_transition=%t",
transitionWaitSawPassGate,
transitionWaitSawFinalLocation,
transitionWaitSawTypedTransition,
)
}
wantTransition, err := buildCurrentSceneTransitionBody(newCharacterInitialTownID, newCharacterInitialAreaID, []currentSceneTransitionRow{{
ObjectOrResourceKey: objectKey,
Value1: newCharacterInitialPosX,
Value2: newCharacterInitialPosY,
Value3: newCharacterInitialDirection,
Value4: newCharacterInitialAreaState,
}})
if err != nil {
t.Fatal(err)
}
if transition.Header.Classification != 0 ||
transition.Header.MsgID != currentSceneTransitionMsgID ||
!bytes.Equal(transition.Body, wantTransition) || len(stream) != 0 {
t.Fatalf("transition=%+v body=%x want=%x rest=%x", transition.Header, transition.Body, wantTransition, stream)
}
preTransitionOp37 := 0
for stateStream := preTransitionPlayerStateStream; len(stateStream) > 0; {
packet, next := splitCurrentGameServerUpperPacketAuto(t, stateStream)
stateStream = next
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketFinishLoading) {
preTransitionOp37++
}
}
if preTransitionOp37 != 0 || session.currentFinishLoadingStateSent {
t.Fatalf("pre-op24 unsolicited progression snapshots=%d gate=%t", preTransitionOp37, session.currentFinishLoadingStateSent)
}
if session.initialTownRouteStage != currentInitialTownRoutePlayerStateSent ||
!session.currentSceneObjectListSent || session.postStartMapPlayerStateSent ||
session.townActorOwnerChannel != actorOwner {
t.Fatalf(
"route stage=%d object=%t post_map=%t owner=%d want=%d",
session.initialTownRouteStage,
session.currentSceneObjectListSent,
session.postStartMapPlayerStateSent,
session.townActorOwnerChannel,
actorOwner,
)
}
stored, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found || stored.Stats["town_id"] != newCharacterInitialTownID || stored.Stats["area_id"] != newCharacterInitialAreaID ||
stored.Stats["pos_x"] != newCharacterInitialPosX || stored.Stats["pos_y"] != newCharacterInitialPosY ||
stored.Stats["direction"] != newCharacterInitialDirection || stored.Stats["area_state"] != newCharacterInitialAreaState {
t.Fatalf("character-list login did not persist runtime-PVF Seria location found=%t err=%v stats=%+v", found, err, stored.Stats)
}
connection.write.Reset()
barrier, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCheckUserConnection),
nil,
0,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, barrier); err != nil {
t.Fatal(err)
}
if connection.write.Len() == 0 || session.sceneBootstrapTailSent || !session.sceneBootstrapTailDeferred {
t.Fatalf("town scene tail bytes=%d sent=%t deferred=%t", connection.write.Len(), session.sceneBootstrapTailSent, session.sceneBootstrapTailDeferred)
}
if session.initialTownRouteStage != currentInitialTownRoutePlayerStateSent ||
!session.selectedUserInfoRefreshSent || session.selectedUserInfoMode3Sent {
t.Fatalf("town player state stage=%d mode1=%t mode3_sent=%t", session.initialTownRouteStage, session.selectedUserInfoRefreshSent, session.selectedUserInfoMode3Sent)
}
actorReady, actorReadySource := service.currentSceneActorReadyForState(session)
if !actorReady || actorReadySource != "initial_town_player_state_finalized" {
t.Fatalf("pre-type1345 town actor ready=%t source=%q", actorReady, actorReadySource)
}
indexes := map[string]int{
"op376": -1,
"op391": -1,
"mode1": -1,
"mode3": -1,
"op1340": -1,
"op19pre": -1,
"op359": -1,
"op356": -1,
"op124": -1,
"op9": -1,
"op120": -1,
"op37": -1,
"op30": -1,
"op19": -1,
"op120h": -1,
"op21": -1,
"op574": -1,
"op105": -1,
"op985": -1,
}
barrierAck, sceneTailStream := splitGameServerUpperPacket(t, connection.write.Bytes())
sceneTailStream = append([]byte(nil), sceneTailStream...)
if barrierAck.Header.Classification != dnfproto.DefaultChannelClassification ||
barrierAck.Header.MsgID != uint16(dnfenum.UpperMsgCheckUserConnection) ||
!bytes.Equal(barrierAck.Body, upperSuccessBody(buildCurrentCheckUserConnectionSuccessPayload())) {
t.Fatalf("town barrier ack=%+v body=%x", barrierAck.Header, barrierAck.Body)
}
postTransitionListCounts := make(map[byte]int)
postTransitionOp37 := 0
postTransitionSkillCount := 0
for postStream := sceneTailStream; len(postStream) > 0; {
packetWire := postStream
packet, rest := splitCurrentGameServerUpperPacketAuto(t, postStream)
postStream = rest
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketFinishLoading) {
postTransitionOp37++
}
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSkillInfoMsgID {
postTransitionSkillCount++
}
if packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) {
continue
}
if packet.Header.Classification != 0 || len(packet.Body) < 3 ||
len(packetWire) < dnfproto.GameServerUpperHeaderSize16 ||
!bytes.Equal(packetWire[11:16], make([]byte, 5)) {
t.Fatalf("post-op24 item-list header=%+v wire=%x", packet.Header, packetWire[:minInt(len(packetWire), int(packet.Header.Length))])
}
listType := packet.Body[0]
postTransitionListCounts[listType]++
}
for _, listType := range currentSelectedItemListTypes {
if postTransitionListCounts[listType] != 0 {
t.Fatalf("post-op24 scene-ready list%d refreshes=%d want=0, all=%+v", listType, postTransitionListCounts[listType], postTransitionListCounts)
}
if _, ok := initializationLists[listType]; !ok {
t.Fatalf("pre-mode1 initialization missing list%d, all=%+v", listType, initializationLists)
}
}
if postTransitionOp37 != 0 || session.currentFinishLoadingStateSent {
t.Fatalf("post-op24 scene tail emitted unsolicited progression snapshots=%d gate=%t", postTransitionOp37, session.currentFinishLoadingStateSent)
}
if postTransitionSkillCount != 0 ||
session.townPostTransition.stage != currentTownPostTransitionIdle {
t.Fatalf(
"CheckUserConnection actor tail skill packets=%d stage=%d, want 0/idle",
postTransitionSkillCount,
session.townPostTransition.stage,
)
}
list0 := initializationLists[dnfrepo.MainInventoryListType].Body
list0Count := int(binary.LittleEndian.Uint16(list0[3:5]))
if list0Count != 7 || len(list0) != 5+list0Count*currentItemListEntryWireSize {
t.Fatalf("post-op24 list0 count=%d body_len=%d body=%x", list0Count, len(list0), list0)
}
wantQuickSlots := map[uint16]struct {
itemID uint32
count uint32
}{
3: {itemID: 1004, count: 2},
4: {itemID: 1003, count: 1},
5: {itemID: 3227, count: 4},
6: {itemID: 1006, count: 1},
7: {itemID: 1007, count: 1},
8: {itemID: 1008, count: 1},
65: {itemID: 3227, count: 2},
}
for rowIndex := 0; rowIndex < list0Count; rowIndex++ {
row := list0[5+rowIndex*currentItemListEntryWireSize:]
slot := binary.LittleEndian.Uint16(row[0:2])
want, ok := wantQuickSlots[slot]
if !ok {
t.Fatalf("post-op24 list0 unexpected slot=%d row=%x", slot, row[:currentItemListEntryWireSize])
}
if itemID := binary.LittleEndian.Uint32(row[2:6]); itemID != want.itemID {
t.Fatalf("post-op24 list0 slot=%d item=%d want=%d", slot, itemID, want.itemID)
}
if count := binary.LittleEndian.Uint32(row[6:10]); count != want.count {
t.Fatalf("post-op24 list0 slot=%d count=%d want=%d", slot, count, want.count)
}
delete(wantQuickSlots, slot)
}
if len(wantQuickSlots) != 0 {
t.Fatalf("post-op24 list0 missing persisted quick slots=%+v", wantQuickSlots)
}
connection.write.Reset()
if err := service.handleLegacyGamePacket(session, buildLegacyTownSceneReadyAckPacket()); err != nil {
t.Fatal(err)
}
type1345PlayerStateStream, gotInitialHUDSkillBody, type1345Trailing := splitInitialTownType1345PlayerState(
t,
connection.write.Bytes(),
actorOwner,
)
if len(type1345PlayerStateStream) != 0 ||
len(gotInitialHUDSkillBody) != 0 {
t.Fatalf(
"type1345 rebuilt player state or replayed skill player=%x skill=%x",
type1345PlayerStateStream,
gotInitialHUDSkillBody,
)
}
sceneTailStream = type1345Trailing
if len(sceneTailStream) == 0 {
t.Fatal("type1345 omitted deferred scene tail")
}
actorReady, actorReadySource = service.currentSceneActorReadyForState(session)
if !actorReady || actorReadySource != "initial_town_player_state_finalized" {
t.Fatalf("post-type1345 town actor ready=%t source=%q", actorReady, actorReadySource)
}
firstHUDWriteLen := connection.write.Len()
if err := service.handleLegacyGamePacket(session, buildLegacyTownSceneReadyAckPacket()); err != nil {
t.Fatal(err)
}
if connection.write.Len() != firstHUDWriteLen {
t.Fatalf("duplicate type1345 grew HUD stream from %d to %d", firstHUDWriteLen, connection.write.Len())
}
playerStateStream := append([]byte(nil), preTransitionPlayerStateStream...)
playerStateStream = append(playerStateStream, type1345PlayerStateStream...)
playerStateStream = append(playerStateStream, sceneTailStream...)
packetIndex := 0
skillInfoCount := 0
var skillPreloadBody []byte
lightMode1StateCount := 0
fullMode1StateCount := 0
for stream := playerStateStream; len(stream) > 0; packetIndex++ {
packetWire := stream
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
stream = rest
switch packet.Header.MsgID {
case uint16(dnfenum.CmdPacketPVPMissionHpPercent):
if !bytes.Equal(packet.Body, buildCurrentActionTableStateBody()) {
t.Fatalf("town player-state op376 body=%x", packet.Body)
}
indexes["op376"] = packetIndex
case uint16(dnfenum.CmdPacketCancelCargoPad):
if packet.Header.Classification != 0 || len(packetWire) < dnfproto.GameServerUpperHeaderSize16 ||
packetWire[15] != 1 || !bytes.Equal(packetWire[dnfproto.GameServerUpperHeaderSize16:packet.Header.Length], buildCurrentCancelCargoPadTransportBody()) {
t.Fatalf("town op391 cargo reset header=%+v wire=%x", packet.Header, packetWire[:minInt(len(packetWire), int(packet.Header.Length))])
}
indexes["op391"] = packetIndex
case uint16(dnfenum.CmdPacketSetUDPIPPort):
if len(packet.Body) >= currentMode1BaseWireSize && packet.Body[0] == 1 {
if len(packet.Body) == currentMode1BaseWireSize {
lightMode1StateCount++
} else {
fullMode1StateCount++
indexes["mode1"] = packetIndex
}
}
if len(packet.Body) > 0 && packet.Body[0] == 3 && indexes["mode3"] < 0 {
indexes["mode3"] = packetIndex
}
case currentAdventureInfoPushMsgID:
if packet.Header.Classification != 0 || len(packet.Body) != 7442 ||
binary.LittleEndian.Uint16(packet.Body[0:2]) != objectKey ||
binary.LittleEndian.Uint32(packet.Body[14:18]) != currentAdventureInfoRawLength {
t.Fatalf("town scene op1340 header=%+v body_len=%d prefix=%x", packet.Header, len(packet.Body), packet.Body[:minInt(len(packet.Body), 24)])
}
// The pre-mode1 model push is required before op24. A separate,
// HUD-ready replay may follow the legacy scene-ready callback so the
// native overhead adventure-name widget can bind it; retain the first
// packet when asserting this pre-transition order.
if indexes["op1340"] < 0 {
indexes["op1340"] = packetIndex
}
case uint16(dnfenum.CmdPacketInsertOverseer):
indexes["op359"] = packetIndex
case currentClearQuestListMsgID:
if len(packetWire) < dnfproto.GameServerUpperHeaderSize16 || int(packet.Header.Length) > len(packetWire) {
t.Fatalf("town op356 fixed16 wire malformed: header=%+v wire_len=%d", packet.Header, len(packetWire))
}
transport := packetWire[dnfproto.GameServerUpperHeaderSize16:packet.Header.Length]
plain, err := zlibDecompress(transport)
if err != nil || len(plain) != 4+currentClearQuestListPayloadSize ||
binary.LittleEndian.Uint32(plain[:4]) != currentClearQuestListPayloadSize {
t.Fatalf("town op356 clear-quest body_len=%d plain_len=%d err=%v", len(transport), len(plain), err)
}
if plain[4+3149] != 1 {
t.Fatalf("town op356 missing persisted completed quest 3149: flag=%d", plain[4+3149])
}
if plain[4+int(objectKey)] != 0 {
t.Fatalf("town op356 wrote actor key %#x into clear-quest table", objectKey)
}
indexes["op356"] = packetIndex
case uint16(dnfenum.CmdPacketReportClientSpec):
if len(packet.Body) != 0 {
t.Fatalf("town op124 body=%x", packet.Body)
}
indexes["op124"] = packetIndex
case uint16(dnfenum.CmdPacketRecoverStamina):
indexes["op9"] = packetIndex
case uint16(dnfenum.CmdPacketRequestBlacklist):
if indexes["op120"] < 0 {
indexes["op120"] = packetIndex
} else {
indexes["op120h"] = packetIndex
}
case uint16(dnfenum.CmdPacketFinishLoading):
indexes["op37"] = packetIndex
case currentIncreaseStatusResultMsgID:
indexes["op30"] = packetIndex
case currentAcceptableQuestListMsgID:
if indexes["op21"] < 0 {
indexes["op21"] = packetIndex
}
case currentActiveQuestSnapshotMsgID:
if indexes["op574"] < 0 {
indexes["op574"] = packetIndex
}
case currentCreatureStateTableMsgID:
indexes["op105"] = packetIndex
case currentRentalStateMsgID:
if packet.Header.Classification != 0 || len(packet.Body) != 8 ||
binary.LittleEndian.Uint32(packet.Body[:4]) != 124 ||
binary.LittleEndian.Uint32(packet.Body[4:]) != 0 {
t.Fatalf("town op985 rental state header=%+v body=%x", packet.Header, packet.Body)
}
indexes["op985"] = packetIndex
case uint16(dnfenum.CmdPacketWalkoutPartyMember):
t.Fatalf("town initialization emitted acquisition-style op14 header=%+v body=%x", packet.Header, packet.Body)
case currentSkillInfoMsgID:
skillInfoCount++
switch skillInfoCount {
case 1:
indexes["op19pre"] = packetIndex
skillPreloadBody = append([]byte(nil), packet.Body...)
case 2:
indexes["op19"] = packetIndex
if !bytes.Equal(packet.Body, skillPreloadBody) {
t.Fatalf("town skill preload=%x final=%x, want identical selected-character projections", skillPreloadBody, packet.Body)
}
default:
t.Fatalf("town initialization emitted %d skill projections", skillInfoCount)
}
if slots := currentSkillInfoFirstTreeSlots(t, packet.Body); slots[33] != 33 {
t.Fatalf("pre-equipment skill projection missing light-sword mastery 33: %+v", slots)
}
}
}
// Login must not send mode3: the current client routes it to the
// user-initiated personal-information panel. Mode1 supplies the real
// state/equipment model required by the pre-transition packets.
if indexes["mode3"] >= 0 {
t.Fatalf("town login unexpectedly sent mode3 personal-info refresh: %+v", indexes)
}
wantOrder := []string{
"op376",
"op391",
"op1340",
"op19pre",
"mode1",
"op19",
"op105",
"op21",
"op574",
"op359",
"op124",
"op9",
"op120",
"op356",
"op985",
}
for i, name := range wantOrder {
if indexes[name] < 0 {
t.Fatalf("town pre-finish packet %s missing: %+v", name, indexes)
}
if i > 0 && indexes[wantOrder[i-1]] >= indexes[name] {
t.Fatalf("town pre-finish order %+v", indexes)
}
}
if skillInfoCount != 2 {
t.Fatalf("town skill initialization count=%d want weapon-validation preload before full mode1 plus final before op21/op574/op24, indexes=%+v", skillInfoCount, indexes)
}
if lightMode1StateCount != 0 || fullMode1StateCount != 1 {
t.Fatalf("town actor mode1 light/full=%d/%d want 0/1 before op24, indexes=%+v", lightMode1StateCount, fullMode1StateCount, indexes)
}
if indexes["op985"] <= indexes["op105"] {
t.Fatalf("deferred-tail rental state must follow the pre-op24 actor state: %+v", indexes)
}
connection.write.Reset()
if err := service.handleGameUpper(session, barrier); err != nil {
t.Fatal(err)
}
duplicateBarrierAck, duplicateBarrierRest := splitGameServerUpperPacket(t, connection.write.Bytes())
if duplicateBarrierAck.Header.MsgID != uint16(dnfenum.UpperMsgCheckUserConnection) ||
!bytes.Equal(duplicateBarrierAck.Body, upperSuccessBody(buildCurrentCheckUserConnectionSuccessPayload())) || len(duplicateBarrierRest) != 0 {
t.Fatalf("duplicate town barrier ack=%+v body=%x rest=%x", duplicateBarrierAck.Header, duplicateBarrierAck.Body, duplicateBarrierRest)
}
connection.write.Reset()
if err := service.handleDungeonTutorialFlag(session, progress36); err != nil {
t.Fatal(err)
}
duplicateAck, duplicateRest := splitGameServerUpperPacket(t, connection.write.Bytes())
if duplicateAck.Header.MsgID != uint16(dnfenum.CmdPacketChangeTutorialFlag) ||
!bytes.Equal(duplicateAck.Body, []byte{1, 0}) || len(duplicateRest) != 0 {
t.Fatalf("duplicate progress36 ack=%+v body=%x rest=%x", duplicateAck.Header, duplicateAck.Body, duplicateRest)
}
}
func TestPendingTutorialSelectKeepsEmptyAckAndEnterDungeonPreview(t *testing.T) {
const characterID = uint16(60043)
repos := testRepositoryGroup()
repos.Character.(*fakeCharacterStore).records = map[string]dnfrepo.CharacterRecord{
"60043": {
CharacterID: "60043",
AccountID: defaultAccountPrefix + "1",
Slot: 0,
Name: "pending",
Job: "0",
Level: 1,
Stats: map[string]int64{
currentDungeonTutorialCompletedKey: 0,
// A fresh character already has a durable Seria-room login point.
// Selecting it must not turn that town row into a tutorial-spawn
// transition; its first scene remains the PVF tutorial dungeon.
"town_id": newCharacterInitialTownID,
"area_id": newCharacterInitialAreaID,
"pos_x": newCharacterInitialPosX,
"pos_y": newCharacterInitialPosY,
"direction": newCharacterInitialDirection,
"area_state": newCharacterInitialAreaState,
},
},
}
service := &Service{
options: options{accountPrefix: defaultAccountPrefix},
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 16, Type: 1, Name: "ch.16", Port: 10016},
residentChannel: channelcatalog.Channel{ID: 16, Type: 1, Name: "ch.16", Port: 10016},
}
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, characterID)
if err := service.sendUpperCSharpSelectInit(session, request); err != nil {
t.Fatalf("send pending select init: %v", err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketSelectCharacter) {
t.Fatalf("first packet msg=%d, want select ACK", ack.Header.MsgID)
}
flag, count, ok := currentSelectAckTutorialState(ack.Body)
if !ok || flag != 0 || count != 0 {
t.Fatalf("pending select ACK tutorial state flag=%d count=%d ok=%t, want 0/0/true", flag, count, ok)
}
rest = requireCurrentStoryDigestLastLevelPacket(t, rest, 0)
enterDungeon, _ := splitGameServerUpperPacket(t, rest)
if enterDungeon.Header.Classification != dnfproto.DefaultChannelClassification || enterDungeon.Header.MsgID != uint16(dnfenum.CmdPacketEnterSelectDungeon) {
t.Fatalf("pending next packet class=%d msg=%d, want current upper-success op15", enterDungeon.Header.Classification, enterDungeon.Header.MsgID)
}
if session.initialTownRouteCharacterID != 0 || session.initialTownRouteStage != currentInitialTownRouteIdle {
t.Fatalf("pending tutorial armed town route char=%d stage=%d", session.initialTownRouteCharacterID, session.initialTownRouteStage)
}
if session.townSceneReadyCharacterID != 0 || session.townPositionSnapshot.CharacterID != 0 {
t.Fatalf("pending tutorial initialized a town scene instead of the PVF dungeon: ready=%d snapshot=%+v", session.townSceneReadyCharacterID, session.townPositionSnapshot)
}
stored, found, err := repos.Character.Load(context.Background(), "60043")
if err != nil || !found {
t.Fatalf("reload pending character found=%t err=%v", found, err)
}
for key, want := range map[string]int64{
"town_id": newCharacterInitialTownID,
"area_id": newCharacterInitialAreaID,
"pos_x": newCharacterInitialPosX,
"pos_y": newCharacterInitialPosY,
"direction": newCharacterInitialDirection,
"area_state": newCharacterInitialAreaState,
} {
if got := stored.Stats[key]; got != want {
t.Fatalf("tutorial selection changed durable login field %s=%d, want %d", key, got, want)
}
}
}
func TestReturnSelectReentryBootstrapsListsAndRehydratesGuardianGemAfterFullMode1(t *testing.T) {
service, session, repositories := newTownMoveTest(t)
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{
AccountID: defaultAccountPrefix + "1",
Metadata: make(map[string]string),
}); err != nil {
t.Fatal(err)
}
skillCatalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
"skill/skilllist.lst": "0 `job0.lst`\n",
"skill/job0.lst": "46 `job0/initial.skl`\n",
"skill/job0/initial.skl": "[skill type]\n`active`\n",
})
if err != nil {
t.Fatal(err)
}
service.initialSkillsByJob = map[byte][]initialSkillEntry{0: {{SkillID: 46, Level: 1}}}
service.initialSPTable = map[int]int{1: 20}
service.skillCatalog = skillCatalog
service.questCatalog = buildQuestListTestCatalog(t)
service.options.accountPrefix = defaultAccountPrefix
service.adventureGroupTable = loadAdventureGroupTestTables(t)
questIndex, err := dnfpvf.Build(context.Background(), questListTestSource{
dnfquest.DefaultList: "900 `visible.qst`\n901 `active.qst`\n",
"n_quest/visible.qst": "[grade]\n`[epic]`\n[level]\n1 10\n[exposed by npc]\n1\n",
"n_quest/active.qst": "[grade]\n`[epic]`\n[level]\n1 10\n[exposed by npc]\n1\n",
}, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
service.questCatalog, err = dnfquest.Load(context.Background(), questIndex)
if err != nil {
t.Fatal(err)
}
character, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load town character found=%t err=%v", found, err)
}
character.Name = "return-select-reentry-op14"
character.Job = "0"
character.Level = 4
character.Stats["exp"] = 4948
character.Stats["gold"] = 12345
character.Stats[currentDungeonTutorialCompletedKey] = currentDungeonTutorialCompleteFlag
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
raw := bytes.Repeat([]byte{0xCD}, currentItemListEntryWireSize)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "29",
Slots: map[string]dnfrepo.ItemStack{
"0:3": {ItemID: 3227, Count: 2, RawEntry: raw},
"0:5": {ItemID: 1033, Count: 1},
},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
medalRaw := currentGuardianGemTestRaw(int16(currentGuildMedalActorSlot), 100380017, 1)
binary.LittleEndian.PutUint16(medalRaw[currentGuardianGemRawSocketOffset:currentGuardianGemRawSocketOffset+currentGuardianGemRawSocketWidth], 3)
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "29",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: medalRaw},
},
}); err != nil {
t.Fatal(err)
}
session.channel = channelcatalog.Channel{ServerID: 1, ID: 11, Type: 1, Name: "ch.11", Port: 10011}
session.residentChannel = session.channel
session.selectedCharacterID = 0
markReturnSelectTownReentry(session, 29)
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
if err := service.sendUpperCSharpSelectInit(session, request); err != nil {
t.Fatal(err)
}
stream := session.conn.(*bufferConn).write.Bytes()
selectAck, stream := splitGameServerUpperPacket(t, stream)
if selectAck.Header.Classification != dnfproto.DefaultChannelClassification ||
selectAck.Header.MsgID != uint16(dnfenum.CmdPacketSelectCharacter) {
t.Fatalf("select ack=%+v body=%x", selectAck.Header, selectAck.Body)
}
stream = requireCurrentStoryDigestLastLevelPacket(t, stream, 0)
actionTable, stream := splitGameServerUpperPacket(t, stream)
if actionTable.Header.Classification != 0 ||
actionTable.Header.MsgID != uint16(dnfenum.CmdPacketPVPMissionHpPercent) ||
!bytes.Equal(actionTable.Body, buildCurrentActionTableStateBody()) {
t.Fatalf("action table=%+v body=%x", actionTable.Header, actionTable.Body)
}
retainedActorRemoval, stream := splitLongHengGameServerUpperPacket(t, stream)
if retainedActorRemoval.Header.Classification != 0 ||
retainedActorRemoval.Header.MsgID != currentTownUserLeaveMsgID ||
!bytes.Equal(retainedActorRemoval.Body, buildTownUserLeaveBody(29)) {
t.Fatalf("same-character retained actor removal=%+v body=%x", retainedActorRemoval.Header, retainedActorRemoval.Body)
}
mode0, stream := splitGameServerUpperPacket(t, stream)
targetOwner := currentTownActorOwnerContext(session)
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] != targetOwner {
t.Fatalf("mode0=%+v body=%x", mode0.Header, mode0.Body)
}
transition, preparedState := splitReturnSelectEarlyTransition(t, stream)
rest := requireNoCurrentChannelNoticeAfterTownTransition(t, preparedState)
rest = requireNoCurrentChannelNoticeAfterTownTransition(t, rest)
if transition.Header.MsgID != currentSceneTransitionMsgID {
t.Fatalf("transition=%+v body=%x rest=%x", transition.Header, transition.Body, rest)
}
for len(rest) > 0 {
packet, next := splitCurrentGameServerUpperPacketAuto(t, rest)
if packetLooksLikeInitialTownTransition(packet) {
t.Fatalf("return-select inventory route emitted a second op24 body=%x", packet.Body)
}
rest = next
}
if session.townPostTransition.stage != currentTownPostTransitionIdle {
t.Fatalf("return-select post-transition stage=%d want idle", session.townPostTransition.stage)
}
fullMode1Index := -1
op13Count := 0
op13Types := make([]byte, 0, len(currentSelectInventoryBootstrapListTypes))
bootstrapLists := make(map[byte]dnfproto.ChannelPacket, len(currentSelectInventoryBootstrapListTypes))
lastOp13Index := -1
itemLockSnapshotIndex := -1
itemLockSnapshotBody := []byte(nil)
op14Count := 0
guardianGemOp14Index := -1
for packetIndex, packetStream := 0, preparedState; len(packetStream) > 0; packetIndex++ {
packet, packetRest := splitCurrentGameServerUpperPacketAuto(t, packetStream)
packetStream = packetRest
switch packet.Header.MsgID {
case uint16(dnfenum.CmdPacketSetUDPIPPort):
if len(packet.Body) > 0 && packet.Body[0] == 1 {
fullMode1Index = packetIndex
}
case uint16(dnfenum.CmdPacketLeaveParty):
op13Count++
if len(packet.Body) > 0 {
op13Types = append(op13Types, packet.Body[0])
bootstrapLists[packet.Body[0]] = packet
}
lastOp13Index = packetIndex
case dnfitemlock.LockListMessageID:
if packet.Header.Classification != 0 {
t.Fatalf("item-lock snapshot header=%+v body=%x", packet.Header, packet.Body)
}
itemLockSnapshotIndex = packetIndex
itemLockSnapshotBody = packet.Body
case uint16(dnfenum.CmdPacketWalkoutPartyMember):
op14Count++
if packet.Header.Classification != 0 ||
len(packet.Body) != 3+currentItemListEntryWireSize ||
packet.Body[0] != currentSocketListEquipment ||
binary.LittleEndian.Uint16(packet.Body[1:3]) != 1 ||
binary.LittleEndian.Uint16(packet.Body[3:5]) != uint16(currentGuildMedalActorSlot) ||
binary.LittleEndian.Uint32(packet.Body[5:9]) != 100380017 ||
binary.LittleEndian.Uint16(packet.Body[3+currentGuardianGemRawSocketOffset:3+currentGuardianGemRawSocketOffset+currentGuardianGemRawSocketWidth]) != 3 {
t.Fatalf("guardian-gem login rehydrate op14=%+v body=%x", packet.Header, packet.Body)
}
guardianGemOp14Index = packetIndex
}
}
if fullMode1Index != -1 ||
op13Count != len(currentSelectInventoryBootstrapListTypes) ||
!bytes.Equal(op13Types, currentSelectInventoryBootstrapListTypes) ||
lastOp13Index < 0 ||
itemLockSnapshotIndex < 0 ||
!bytes.Equal(itemLockSnapshotBody, []byte{0, 0}) ||
op14Count != 1 || guardianGemOp14Index <= lastOp13Index {
t.Fatalf("re-entry inventory placement: full_mode1=%d op13_end=%d lock_snapshot=%d/%x op13_types=%v op14=%d guardian_op14=%d", fullMode1Index, lastOp13Index, itemLockSnapshotIndex, itemLockSnapshotBody, op13Types, op14Count, guardianGemOp14Index)
}
list0 := bootstrapLists[dnfrepo.MainInventoryListType].Body
if len(list0) != 5+3*currentItemListEntryWireSize ||
int(binary.LittleEndian.Uint16(list0[3:5])) != 3 {
t.Fatalf("re-entry bootstrap list0 body=%x", list0)
}
// list0 rows leave in descending slot order (reverseCurrentMainItemListRows):
// backpack (5), quick slot (3), then the projected gold wallet (0).
rows := list0[5:]
backpack := rows[:currentItemListEntryWireSize]
if binary.LittleEndian.Uint16(backpack[0:2]) != 5 ||
binary.LittleEndian.Uint32(backpack[2:6]) != 1033 ||
binary.LittleEndian.Uint32(backpack[6:10]) != 1 {
t.Fatalf("re-entry bootstrap backpack row=%x", backpack[:0x0E])
}
quick := rows[currentItemListEntryWireSize : 2*currentItemListEntryWireSize]
if binary.LittleEndian.Uint16(quick[0:2]) != 3 ||
binary.LittleEndian.Uint32(quick[2:6]) != 3227 ||
binary.LittleEndian.Uint32(quick[6:10]) != 2 {
t.Fatalf("re-entry bootstrap quick-slot row=%x", quick[:0x0E])
}
wallet := rows[2*currentItemListEntryWireSize:]
if binary.LittleEndian.Uint16(wallet[0:2]) != 0 ||
binary.LittleEndian.Uint32(wallet[2:6]) != 0 ||
binary.LittleEndian.Uint32(wallet[6:10]) != 12345 {
t.Fatalf("re-entry bootstrap gold wallet row=%x", wallet[:0x0E])
}
}