服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2247 lines
88 KiB
Go
2247 lines
88 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"reflect"
|
||
"testing"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnftown "longheng.io/server/internal/modules/dnf/town"
|
||
)
|
||
|
||
type townMoveTestSource map[string]string
|
||
|
||
func (source townMoveTestSource) ReadText(name string) (string, error) {
|
||
value, ok := source[name]
|
||
if !ok {
|
||
return "", fmt.Errorf("missing %s", name)
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func splitTownAreaTransitionSequence(
|
||
t *testing.T,
|
||
data []byte,
|
||
actorKey uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
x uint16,
|
||
y uint16,
|
||
direction byte,
|
||
areaState byte,
|
||
) (dnfproto.ChannelPacket, []byte) {
|
||
t.Helper()
|
||
position, rest := splitCurrentGameServerUpperPacketAuto(t, data)
|
||
wantPosition := buildCurrentTownUserPositionNotificationBody(actorKey, x, y, direction, currentTownInitialMovementRate)
|
||
if position.Header.MsgID != currentTownUserPositionNotificationMsgID ||
|
||
position.Header.Classification != 0 ||
|
||
!bytes.Equal(position.Body, wantPosition) {
|
||
t.Fatalf("town op22 position header=%+v body=%x want=%x", position.Header, position.Body, wantPosition)
|
||
}
|
||
area, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
wantArea := buildCurrentTownUserAreaNotificationBody(
|
||
actorKey,
|
||
townID,
|
||
areaID,
|
||
x,
|
||
y,
|
||
direction,
|
||
areaState,
|
||
)
|
||
if area.Header.MsgID != currentTownUserAreaNotificationMsgID ||
|
||
area.Header.Classification != 0 ||
|
||
!bytes.Equal(area.Body, wantArea) {
|
||
t.Fatalf("town op23 area header=%+v body=%x want=%x", area.Header, area.Body, wantArea)
|
||
}
|
||
transition, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if transition.Header.MsgID != currentSceneTransitionMsgID || transition.Header.Classification != 0 {
|
||
t.Fatalf("town op24 transition header=%+v body=%x", transition.Header, transition.Body)
|
||
}
|
||
return transition, rest
|
||
}
|
||
|
||
func splitTownAreaTransitionSequenceWithClearQuest(
|
||
t *testing.T,
|
||
data []byte,
|
||
actorKey uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
x uint16,
|
||
y uint16,
|
||
direction byte,
|
||
areaState byte,
|
||
) (dnfproto.ChannelPacket, dnfproto.ChannelPacket, []byte) {
|
||
t.Helper()
|
||
position, rest := splitCurrentGameServerUpperPacketAuto(t, data)
|
||
wantPosition := buildCurrentTownUserPositionNotificationBody(actorKey, x, y, direction, currentTownInitialMovementRate)
|
||
if position.Header.MsgID != currentTownUserPositionNotificationMsgID ||
|
||
position.Header.Classification != 0 ||
|
||
!bytes.Equal(position.Body, wantPosition) {
|
||
t.Fatalf("town op22 position header=%+v body=%x want=%x", position.Header, position.Body, wantPosition)
|
||
}
|
||
area, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
wantArea := buildCurrentTownUserAreaNotificationBody(actorKey, townID, areaID, x, y, direction, areaState)
|
||
if area.Header.MsgID != currentTownUserAreaNotificationMsgID ||
|
||
area.Header.Classification != 0 ||
|
||
!bytes.Equal(area.Body, wantArea) {
|
||
t.Fatalf("town op23 area header=%+v body=%x want=%x", area.Header, area.Body, wantArea)
|
||
}
|
||
clearQuest, rest := splitLongHengGameServerUpperPacket(t, rest)
|
||
if clearQuest.Header.MsgID != currentClearQuestListMsgID || clearQuest.Header.Classification != 0 {
|
||
t.Fatalf("town pre-op24 op356 header=%+v body=%x", clearQuest.Header, clearQuest.Body)
|
||
}
|
||
transition, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if transition.Header.MsgID != currentSceneTransitionMsgID || transition.Header.Classification != 0 {
|
||
t.Fatalf("town op24 transition header=%+v body=%x", transition.Header, transition.Body)
|
||
}
|
||
return clearQuest, transition, rest
|
||
}
|
||
|
||
func splitTownPostTransitionPlayerState(
|
||
t *testing.T,
|
||
data []byte,
|
||
session *gameSession,
|
||
actorKey uint16,
|
||
wantSkillBody []byte,
|
||
wantCreatureGrowth bool,
|
||
) []byte {
|
||
t.Helper()
|
||
wantOwner := currentConnectionTownActorOwnerContext(session)
|
||
if currentTownActorOwnerContext(session) != wantOwner {
|
||
t.Fatalf(
|
||
"town owner mismatch connection=%d actor=%d",
|
||
wantOwner,
|
||
currentTownActorOwnerContext(session),
|
||
)
|
||
}
|
||
|
||
mode0, rest := splitCurrentGameServerUpperPacketAuto(t, data)
|
||
if mode0.Header.Classification == 0 &&
|
||
mode0.Header.MsgID == uint16(dnfenum.CmdPacketPVPMissionHpPercent) {
|
||
if !bytes.Equal(mode0.Body, buildCurrentActionTableStateBody()) {
|
||
t.Fatalf("post-op24 op376 action table body=%x", mode0.Body)
|
||
}
|
||
mode0, rest = splitCurrentGameServerUpperPacketAuto(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] != wantOwner ||
|
||
binary.LittleEndian.Uint16(mode0.Body[0x4c:0x4e]) != actorKey {
|
||
t.Fatalf(
|
||
"post-op24 native-owner mode0 header=%+v prefix=%x owner=%d key=%d",
|
||
mode0.Header,
|
||
mode0.Body[:minInt(len(mode0.Body), 0x4e)],
|
||
wantOwner,
|
||
actorKey,
|
||
)
|
||
}
|
||
|
||
mode1, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if mode1.Header.Classification != 0 ||
|
||
mode1.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
|
||
len(mode1.Body) < currentMode1BaseWireSize ||
|
||
mode1.Body[0] != 1 ||
|
||
mode1.Body[3] != currentSceneObjectRoute ||
|
||
mode1.Body[4] != wantOwner ||
|
||
binary.LittleEndian.Uint16(mode1.Body[0x15:0x17]) != actorKey {
|
||
t.Fatalf(
|
||
"post-op24 native-owner mode1 header=%+v prefix=%x owner=%d key=%d",
|
||
mode1.Header,
|
||
mode1.Body[:minInt(len(mode1.Body), 0x17)],
|
||
wantOwner,
|
||
actorKey,
|
||
)
|
||
}
|
||
creature, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if creature.Header.Classification != 0 ||
|
||
creature.Header.MsgID != currentCreatureStateTableMsgID {
|
||
t.Fatalf("post-op24 creature table header=%+v body=%x", creature.Header, creature.Body)
|
||
}
|
||
if wantCreatureGrowth {
|
||
growth, next := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if growth.Header.Classification != 0 ||
|
||
growth.Header.MsgID != currentCreatureGrowthMsgID {
|
||
t.Fatalf("post-op24 creature growth header=%+v body=%x", growth.Header, growth.Body)
|
||
}
|
||
rest = next
|
||
}
|
||
finishState, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if finishState.Header.Classification != 0 ||
|
||
finishState.Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) ||
|
||
len(finishState.Body) != currentFinishLoadingCharacterStateBodySize {
|
||
t.Fatalf("post-op24 op37 header=%+v body=%x", finishState.Header, finishState.Body)
|
||
}
|
||
completion, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if completion.Header.Classification != 0 ||
|
||
completion.Header.MsgID != currentIncreaseStatusResultMsgID ||
|
||
!bytes.Equal(completion.Body, buildCurrentIncreaseStatusResultBody()) {
|
||
t.Fatalf("post-op24 op30 header=%+v body=%x", completion.Header, completion.Body)
|
||
}
|
||
buff, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if buff.Header.Classification != 0 || buff.Header.MsgID != skillSwitchInventoryAllDataWire || len(buff.Body) < 3 {
|
||
t.Fatalf("post-op24 BUFF registration header=%+v body=%x", buff.Header, buff.Body)
|
||
}
|
||
placement, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if placement.Header.Classification != 0 ||
|
||
placement.Header.MsgID != uint16(dnfenum.CmdPacketRequestBlacklist) ||
|
||
!bytes.Equal(placement.Body, buildCurrentSceneActorPlacementBody()) {
|
||
t.Fatalf("post-op24 op120 placement header=%+v body=%x", placement.Header, placement.Body)
|
||
}
|
||
if len(rest) != 0 {
|
||
packet, next := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if packet.Header.Classification == 0 &&
|
||
packet.Header.MsgID == currentDungeonConsumableUseGateResetWireOpcode {
|
||
if len(packet.Body) != 0 {
|
||
t.Fatalf("post-op24 final op42 body=%x", packet.Body)
|
||
}
|
||
rest = next
|
||
}
|
||
}
|
||
_ = wantSkillBody
|
||
return rest
|
||
}
|
||
|
||
func splitTownTransitionAndPostState(
|
||
t *testing.T,
|
||
data []byte,
|
||
session *gameSession,
|
||
actorKey uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
x uint16,
|
||
y uint16,
|
||
direction byte,
|
||
areaState byte,
|
||
wantSkillBody []byte,
|
||
wantCreatureGrowth bool,
|
||
) (dnfproto.ChannelPacket, []byte) {
|
||
t.Helper()
|
||
first, firstRest := splitCurrentGameServerUpperPacketAuto(t, data)
|
||
if first.Header.Classification == 0 && first.Header.MsgID == currentSceneTransitionMsgID {
|
||
return first, firstRest
|
||
}
|
||
transition, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
data,
|
||
actorKey,
|
||
townID,
|
||
areaID,
|
||
x,
|
||
y,
|
||
direction,
|
||
areaState,
|
||
)
|
||
// Ordinary town, personal-teleport, and potion routes end their scene
|
||
// transition at op24. Feature-specific packets (for example op898 crystal
|
||
// state or the potion inventory/ACK suffix) remain for the caller to assert;
|
||
// the actor-generation rebuild belongs only to the explicit dungeon-return
|
||
// owner and must not be invented by this shared parser.
|
||
_ = session
|
||
_ = wantSkillBody
|
||
_ = wantCreatureGrowth
|
||
return transition, rest
|
||
}
|
||
|
||
func townPostTransitionPacketSignatures(t *testing.T, data []byte) []string {
|
||
t.Helper()
|
||
signatures := make([]string, 0, 9)
|
||
for len(data) != 0 {
|
||
packet, rest := splitCurrentGameServerUpperPacketAuto(t, data)
|
||
switch packet.Header.MsgID {
|
||
case uint16(dnfenum.CmdPacketPVPMissionHpPercent):
|
||
if packet.Header.Classification != 0 ||
|
||
!bytes.Equal(packet.Body, buildCurrentActionTableStateBody()) {
|
||
t.Fatalf("unexpected post-op24 op376 body=%x", packet.Body)
|
||
}
|
||
signatures = append(signatures, "op376")
|
||
case uint16(dnfenum.CmdPacketSetUDPIPPort):
|
||
if len(packet.Body) == 0 || (packet.Body[0] != 0 && packet.Body[0] != 1) {
|
||
t.Fatalf("unexpected post-op24 op2 body=%x", packet.Body)
|
||
}
|
||
signatures = append(signatures, fmt.Sprintf("mode%d", packet.Body[0]))
|
||
case currentChatEmoticonConfigMsgID:
|
||
signatures = append(signatures, "op411")
|
||
case uint16(dnfenum.CmdPacketBuyItem):
|
||
signatures = append(signatures, "op21")
|
||
case uint16(dnfenum.CmdPacketCollectPrivateSerialData):
|
||
signatures = append(signatures, "op574")
|
||
case currentSceneTransitionMsgID:
|
||
signatures = append(signatures, "op24")
|
||
case currentWeaknessConditionMsg:
|
||
// The health cap published after the rebuild. One byte, 0..100.
|
||
if packet.Header.Classification != 0 || len(packet.Body) != 1 || packet.Body[0] > 100 {
|
||
t.Fatalf("unexpected post-op24 op4 weakness body=%x", packet.Body)
|
||
}
|
||
signatures = append(signatures, "op33weakness")
|
||
case currentTownUserPositionNotificationMsgID,
|
||
currentTownUserAreaNotificationMsgID,
|
||
currentClearQuestListMsgID:
|
||
// The unified dungeon-return transition prefix is asserted directly
|
||
// by its dedicated ordering regressions. Keep this legacy lifecycle
|
||
// signature helper focused on actor/gate packets.
|
||
case currentCreatureStateTableMsgID:
|
||
signatures = append(signatures, "op105")
|
||
case currentCreatureGrowthMsgID:
|
||
signatures = append(signatures, "op102")
|
||
case uint16(dnfenum.CmdPacketWalkoutPartyMember):
|
||
if packet.Header.Classification != 0 ||
|
||
len(packet.Body) != 3+currentItemListEntryWireSize ||
|
||
packet.Body[0] != currentSocketListEquipment ||
|
||
binary.LittleEndian.Uint16(packet.Body[1:3]) != 1 {
|
||
t.Fatalf("unexpected post-op24 op14 body=%x", packet.Body)
|
||
}
|
||
slot := binary.LittleEndian.Uint16(packet.Body[3:5])
|
||
signatures = append(signatures, fmt.Sprintf("slot%d_op14", slot))
|
||
case uint16(dnfenum.CmdPacketFinishLoading):
|
||
signatures = append(signatures, "op37")
|
||
case currentHellPartyMonsterInfoWireOpcode:
|
||
if packet.Header.Classification != 0 ||
|
||
!bytes.Equal(packet.Body, buildCurrentHellPartyMonsterInfoBody(nil)) {
|
||
t.Fatalf("unexpected post-op24 op679 body=%x", packet.Body)
|
||
}
|
||
signatures = append(signatures, "op679_empty")
|
||
case currentIncreaseStatusResultMsgID:
|
||
signatures = append(signatures, "op30")
|
||
case currentSkillInfoMsgID:
|
||
signatures = append(signatures, "op19")
|
||
case currentExpertJobInfoNotification:
|
||
signatures = append(signatures, "op205")
|
||
case skillSwitchInventoryAllDataWire:
|
||
signatures = append(signatures, "op559")
|
||
case uint16(dnfenum.CmdPacketRequestBlacklist):
|
||
signatures = append(signatures, "op120")
|
||
case currentDungeonConsumableUseGateResetWireOpcode:
|
||
if packet.Header.Classification != 0 || len(packet.Body) != 0 {
|
||
t.Fatalf("unexpected post-op24 op42 body=%x", packet.Body)
|
||
}
|
||
signatures = append(signatures, "op42")
|
||
default:
|
||
t.Fatalf("unexpected post-op24 packet header=%+v body=%x", packet.Header, packet.Body)
|
||
}
|
||
data = rest
|
||
}
|
||
return signatures
|
||
}
|
||
|
||
func splitConfirmedDungeonReturnRoute(t *testing.T, data []byte) ([]string, []byte) {
|
||
t.Helper()
|
||
signatures := make([]string, 0, 8)
|
||
for len(data) != 0 {
|
||
_, rest := splitCurrentGameServerUpperPacketAuto(t, data)
|
||
packetLen := len(data) - len(rest)
|
||
one := townPostTransitionPacketSignatures(t, data[:packetLen])
|
||
if len(one) != 1 {
|
||
t.Fatalf("confirmed-return packet signatures=%v", one)
|
||
}
|
||
signatures = append(signatures, one[0])
|
||
data = rest
|
||
if one[0] == "op42" {
|
||
return signatures, data
|
||
}
|
||
}
|
||
t.Fatalf("confirmed-return route ended without final op42: %v", signatures)
|
||
return nil, nil
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaPersistsPVFTargetAndSendsOneAuthoritativeTransition(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
service.onlinePlayers = newOnlinePlayerManager()
|
||
session.currentFinishLoadingStateSent = true
|
||
session.currentFinishLoadingCompletionSent = true
|
||
session.postFinishLoadingPlayerStateSent = true
|
||
session.initialTownLegacySceneReadyAccepted = true
|
||
body := buildTownMoveRequest(38, 0, 900, 250, 5)
|
||
binary.LittleEndian.PutUint16(body[7:9], 0x1234)
|
||
binary.LittleEndian.PutUint16(body[9:11], 0x5678)
|
||
binary.LittleEndian.PutUint32(body[11:15], 0x90abcdef)
|
||
body[15] = 0x42
|
||
|
||
if err := service.handleTownSetUserArea(session, body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
packet, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
0,
|
||
900,
|
||
250,
|
||
5,
|
||
3,
|
||
)
|
||
wantBody, err := buildCurrentSceneTransitionBody(38, 0, []currentSceneTransitionRow{{
|
||
ObjectOrResourceKey: 29,
|
||
Value1: uint16(900),
|
||
Value2: uint16(250),
|
||
Value3: 5,
|
||
Value4: 3,
|
||
}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if packet.Header.MsgID != currentSceneTransitionMsgID || packet.Header.Classification != 0 ||
|
||
!bytes.Equal(packet.Body, wantBody) {
|
||
t.Fatalf("town response header=%+v body=%x rest=%x want=%x", packet.Header, packet.Body, rest, wantBody)
|
||
}
|
||
if len(rest) != 0 {
|
||
t.Fatalf("town response replayed a second self transition: %x", rest)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load stored character found=%t err=%v", found, err)
|
||
}
|
||
if stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 900 || stored.Stats["pos_y"] != 250 || stored.Stats["direction"] != 5 {
|
||
t.Fatalf("stored town location=%+v", stored.Stats)
|
||
}
|
||
if session.currentFinishLoadingStateSent ||
|
||
session.currentFinishLoadingCompletionSent ||
|
||
session.postFinishLoadingPlayerStateSent ||
|
||
session.initialTownLegacySceneReadyAccepted {
|
||
t.Fatalf("town transition state was not rearmed: current=%t completion=%t post=%t legacy=%t",
|
||
session.currentFinishLoadingStateSent,
|
||
session.currentFinishLoadingCompletionSent,
|
||
session.postFinishLoadingPlayerStateSent,
|
||
session.initialTownLegacySceneReadyAccepted)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaDoesNotReplaceMainInventoryWithAccountSharedRows(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||
CharacterID: "29",
|
||
Slots: map[string]dnfrepo.ItemStack{},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repositories.AccountInventory.Save(context.Background(), dnfrepo.AccountInventoryRecord{
|
||
AccountID: "dnf:1",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
dnfrepo.AccountSharedInventorySlotKey(dnfrepo.CrystalWarehouseFirstSlot): {
|
||
ItemID: 3033,
|
||
Count: 2,
|
||
},
|
||
dnfrepo.AccountSharedInventorySlotKey(dnfrepo.SoulWarehouseFirstSlot + 3): {
|
||
ItemID: 10099774,
|
||
Count: 5,
|
||
},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
transition, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
0,
|
||
900,
|
||
250,
|
||
5,
|
||
3,
|
||
)
|
||
if transition.Header.Classification != 0 || transition.Header.MsgID != currentSceneTransitionMsgID {
|
||
t.Fatalf("town transition header=%+v body=%x", transition.Header, transition.Body)
|
||
}
|
||
if len(rest) != 0 {
|
||
t.Fatalf("town transition replaced the main inventory with an account-only snapshot: %x", rest)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaSendsCompleteAreaRosterWithSelectedActorLast(t *testing.T) {
|
||
service, session, _ := newTownMoveTest(t)
|
||
service.onlinePlayers = newOnlinePlayerManager()
|
||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||
CharacterID: 17,
|
||
ChannelID: townPresenceChannelID(session),
|
||
TownID: 38,
|
||
AreaID: 0,
|
||
PositionX: 320,
|
||
PositionY: 210,
|
||
Direction: 1,
|
||
AreaState: 4,
|
||
})
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
packet, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
0,
|
||
900,
|
||
250,
|
||
5,
|
||
3,
|
||
)
|
||
wantBody, err := buildCurrentSceneTransitionBody(38, 0, []currentSceneTransitionRow{
|
||
{
|
||
ObjectOrResourceKey: currentSceneActorObjectKey(17),
|
||
Value1: 320,
|
||
Value2: 210,
|
||
Value3: 1,
|
||
Value4: 4,
|
||
},
|
||
{
|
||
ObjectOrResourceKey: currentSceneActorObjectKey(29),
|
||
Value1: 900,
|
||
Value2: 250,
|
||
Value3: 5,
|
||
Value4: 3,
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if packet.Header.MsgID != currentSceneTransitionMsgID ||
|
||
packet.Header.Classification != 0 ||
|
||
!bytes.Equal(packet.Body, wantBody) {
|
||
t.Fatalf(
|
||
"complete area roster header=%+v body=%x rest=%x want=%x",
|
||
packet.Header,
|
||
packet.Body,
|
||
rest,
|
||
wantBody,
|
||
)
|
||
}
|
||
if len(rest) != 0 {
|
||
t.Fatalf("complete area roster replayed peer packets without a live peer session: %x", rest)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaPreservesStationaryPartyRosterWithAreaUpdate(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
service.options.gameUpperBodyCodec = gameUpperBodyCodecPlain
|
||
service.onlinePlayers = newOnlinePlayerManager()
|
||
service.bindGameSessionCharacter(session, session.selectedCharacterID)
|
||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||
CharacterID: 29,
|
||
AccountID: "dnf:1",
|
||
Name: "mover",
|
||
Level: 1,
|
||
TownID: 38,
|
||
AreaID: 1,
|
||
PositionX: 450,
|
||
PositionY: 234,
|
||
Direction: 5,
|
||
AreaState: 3,
|
||
Session: session,
|
||
})
|
||
|
||
peerCharacter := dnfrepo.CharacterRecord{
|
||
CharacterID: "17",
|
||
AccountID: "dnf:2",
|
||
Name: "peer",
|
||
Job: "1",
|
||
Level: 2,
|
||
Stats: map[string]int64{
|
||
"town_id": 38, "area_id": 1, "pos_x": 400, "pos_y": 200,
|
||
"direction": 5, "area_state": 3,
|
||
},
|
||
}
|
||
if err := repositories.Character.Save(context.Background(), peerCharacter); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
peerConn := &bufferConn{}
|
||
peer := &gameSession{
|
||
conn: peerConn,
|
||
channel: session.channel,
|
||
residentChannel: session.residentChannel,
|
||
selectedCharacterID: 17,
|
||
townSceneReadyCharacterID: 17,
|
||
townActorOwnerChannel: byte(session.channel.ID),
|
||
}
|
||
service.bindGameSessionCharacter(peer, 17)
|
||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||
CharacterID: 17,
|
||
AccountID: "dnf:2",
|
||
Name: "peer",
|
||
Job: 1,
|
||
Level: 2,
|
||
TownID: 38,
|
||
AreaID: 1,
|
||
PositionX: 400,
|
||
PositionY: 200,
|
||
Direction: 5,
|
||
AreaState: 3,
|
||
Session: peer,
|
||
})
|
||
state := alignedcmd.PartyState{
|
||
PartyID: 1, UserID: 29, UserState: 1, IsLeader: true, MaxMembers: 4,
|
||
Members: []alignedcmd.PartyMemberState{
|
||
{UserID: 29, UserState: 1},
|
||
{UserID: 17, UserState: 1},
|
||
},
|
||
}
|
||
storeRuntimePartyState(session, state)
|
||
storeRuntimePartyState(peer, state)
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
transition, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
0,
|
||
900,
|
||
250,
|
||
5,
|
||
3,
|
||
)
|
||
if transition.Header.MsgID != currentSceneTransitionMsgID {
|
||
t.Fatalf("first packet msg=%d want town transition", transition.Header.MsgID)
|
||
}
|
||
remoteMode0, rest := splitGameServerUpperPacket(t, rest)
|
||
remoteMode1, rest := splitGameServerUpperPacket(t, rest)
|
||
remoteParty, rest := splitGameServerUpperPacket(t, rest)
|
||
// The remote actor's op9 record carries this party's model, so the observing
|
||
// member's own frame is closed immediately with its op153 health rows
|
||
// (broadcastCurrentPartyActorProjection): a frame left on the bare op9 shows
|
||
// empty HP/MP bars for every teammate.
|
||
rest = assertRuntimePartyRealtimeRows(t, rest, 2)
|
||
remoteArea, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||
if remoteMode0.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) || len(remoteMode0.Body) == 0 || remoteMode0.Body[0] != 0 ||
|
||
remoteMode1.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) || len(remoteMode1.Body) == 0 || remoteMode1.Body[0] != 1 ||
|
||
remoteParty.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
|
||
remoteArea.Header.MsgID != currentTownUserAreaNotificationMsgID {
|
||
t.Fatalf("remote party actor sequence mode0=%+v mode1=%+v op9=%+v area=%+v",
|
||
remoteMode0.Header, remoteMode1.Header, remoteParty.Header, remoteArea.Header)
|
||
}
|
||
if len(remoteArea.Body) < 10 || remoteArea.Body[2] != 38 || remoteArea.Body[3] != 1 {
|
||
t.Fatalf("remote party actor area body=%x want town=38 area=1", remoteArea.Body)
|
||
}
|
||
assertCurrentPartyFrameSelectedSlot(t, rest, 0)
|
||
rest = assertRuntimePartySceneRefresh(t, rest, 2)
|
||
if len(rest) != 0 {
|
||
t.Fatalf("post-town party roster has unexpected trailing packets=%x", rest)
|
||
}
|
||
peerArea, peerRest := splitCurrentGameServerUpperPacketAuto(t, peerConn.write.Bytes())
|
||
if peerArea.Header.MsgID != currentTownUserAreaNotificationMsgID ||
|
||
len(peerArea.Body) < 10 || peerArea.Body[2] != 38 || peerArea.Body[3] != 0 {
|
||
t.Fatalf("stationary peer area update header=%+v body=%x", peerArea.Header, peerArea.Body)
|
||
}
|
||
if len(peerRest) != 0 {
|
||
t.Fatalf("stationary peer received actor or party-table replay after area-only update: %x", peerRest)
|
||
}
|
||
|
||
// When the second member follows into the same area, both clients now own
|
||
// the newcomer's actor. The stationary client must finish with one op9
|
||
// roster rebind, without replaying op153/op11 and restarting P2P.
|
||
session.conn.(*bufferConn).write.Reset()
|
||
peerConn.write.Reset()
|
||
if err := service.handleTownSetUserArea(peer, buildTownMoveRequest(38, 0, 870, 248, 6)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
peerTransition, peerRest := splitTownAreaTransitionSequence(
|
||
t,
|
||
peerConn.write.Bytes(),
|
||
17,
|
||
38,
|
||
0,
|
||
870,
|
||
248,
|
||
6,
|
||
3,
|
||
)
|
||
wantPeerTransition, err := buildCurrentSceneTransitionBody(38, 0, []currentSceneTransitionRow{
|
||
{
|
||
ObjectOrResourceKey: currentSceneActorObjectKey(29),
|
||
Value1: 900,
|
||
Value2: 250,
|
||
Value3: 5,
|
||
Value4: 3,
|
||
},
|
||
{
|
||
ObjectOrResourceKey: currentSceneActorObjectKey(17),
|
||
Value1: 870,
|
||
Value2: 248,
|
||
Value3: 6,
|
||
Value4: 3,
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !bytes.Equal(peerTransition.Body, wantPeerTransition) {
|
||
t.Fatalf("party follower op24 body=%x want=%x", peerTransition.Body, wantPeerTransition)
|
||
}
|
||
// 城镇共现只发 mode0(2026-09-26):mode1 是接收者自己的属性/装备快照,客户端已停用
|
||
// 战斗力、没有任何 mode1 消费者,服务端不再给 peer 发。
|
||
peerRemoteMode0, peerRest := splitGameServerUpperPacket(t, peerRest)
|
||
peerRemoteParty, peerRest := splitGameServerUpperPacket(t, peerRest)
|
||
peerRest = assertRuntimePartyRealtimeRows(t, peerRest, 2)
|
||
peerRemoteArea, peerRest := splitCurrentGameServerUpperPacketAuto(t, peerRest)
|
||
if peerRemoteMode0.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) || len(peerRemoteMode0.Body) == 0 || peerRemoteMode0.Body[0] != 0 ||
|
||
peerRemoteParty.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
|
||
peerRemoteArea.Header.MsgID != currentTownUserAreaNotificationMsgID || len(peerRemoteArea.Body) < 10 || peerRemoteArea.Body[3] != 0 {
|
||
t.Fatalf("party follower remote sequence mode0=%+v op9=%+v area=%+v body=%x",
|
||
peerRemoteMode0.Header, peerRemoteParty.Header, peerRemoteArea.Header, peerRemoteArea.Body)
|
||
}
|
||
assertCurrentPartyFrameSelectedSlot(t, peerRest, 0)
|
||
peerRest = assertRuntimePartySceneRefresh(t, peerRest, 2)
|
||
if len(peerRest) != 0 {
|
||
t.Fatalf("party follower transition left unparsed packets=%x", peerRest)
|
||
}
|
||
stationary := session.conn.(*bufferConn).write.Bytes()
|
||
// 同上:城镇共现不发 mode1。
|
||
newcomerMode0, stationary := splitGameServerUpperPacket(t, stationary)
|
||
newcomerParty, stationary := splitGameServerUpperPacket(t, stationary)
|
||
stationary = assertRuntimePartyRealtimeRows(t, stationary, 2)
|
||
newcomerArea, stationary := splitCurrentGameServerUpperPacketAuto(t, stationary)
|
||
if newcomerMode0.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) || len(newcomerMode0.Body) == 0 || newcomerMode0.Body[0] != 0 ||
|
||
newcomerParty.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
|
||
newcomerArea.Header.MsgID != currentTownUserAreaNotificationMsgID || len(newcomerArea.Body) < 10 || newcomerArea.Body[3] != 0 {
|
||
t.Fatalf("same-area newcomer sequence mode0=%+v op9=%+v area=%+v body=%x",
|
||
newcomerMode0.Header, newcomerParty.Header, newcomerArea.Header, newcomerArea.Body)
|
||
}
|
||
stationary = assertRuntimePartySceneRefresh(t, stationary, 2)
|
||
if len(stationary) != 0 {
|
||
t.Fatalf("same-area stationary peer received unexpected trailing packets: %x", stationary)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaRejectsColdLoginBeforeTownActorReadyWithoutMutation(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
session.townSceneReadyCharacterID = 0
|
||
session.initialTownRouteCharacterID = session.selectedCharacterID
|
||
session.initialTownRouteStage = currentInitialTownRouteActorBound
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("cold-login op36 wrote=%x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 450 || stored.Stats["pos_y"] != 234 || stored.Stats["direction"] != 5 {
|
||
t.Fatalf("cold-login op36 mutated character found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaRefreshesPVFDBQuestListAfterFullDungeonReturn(t *testing.T) {
|
||
service, session, runtime := newBackToVillageRuntime(t)
|
||
service.questCatalog = buildQuestListTestCatalog(t)
|
||
townTable, err := dnftown.Load(context.Background(), townMoveTestSource{
|
||
"town/town.lst": "7 `return_town.twn`",
|
||
"town/return_town.twn": "[area]\n3 `return_town.map` `[normal]`\n[/area]\n[name]\n`ReturnTown`",
|
||
}, dnftown.Options{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service.townCatalog = townTable
|
||
repositories, ok := service.repositoryGroup()
|
||
if !ok || repositories.Character == nil {
|
||
t.Fatal("return fixture character repository unavailable")
|
||
}
|
||
character, found, err := repositories.Character.Load(context.Background(), "99")
|
||
if err != nil || !found {
|
||
t.Fatalf("load return character found=%t err=%v", found, err)
|
||
}
|
||
character.Job = "2"
|
||
character.Level = 2
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleDungeonBackToVillage(session, nil); err != nil {
|
||
t.Fatalf("start pending return: %v", err)
|
||
}
|
||
if !runtime.townReturnPending || session.dungeon.runtime != nil {
|
||
t.Fatalf("full return state pending=%t owner=%p", runtime.townReturnPending, session.dungeon.runtime)
|
||
}
|
||
connection := session.conn.(*bufferConn)
|
||
// An interrupted (op42/op132) return never replays op356/op21/op574 quest
|
||
// state: no dungeon quest finished, so the client still owns the pre-entry
|
||
// quest state and the map [quest condition] gates it was left with. The
|
||
// reference active-giveup route publishes only the actor restore and the
|
||
// typed op24 below; quest markers refresh on the follow-up town move.
|
||
if got := townPostTransitionPacketSignatures(t, connection.write.Bytes()); !reflect.DeepEqual(
|
||
got,
|
||
[]string{"op42", "op376", "mode0", "mode1", "op205", "op24", "op559", "op33weakness"},
|
||
) {
|
||
t.Fatalf("interrupted return route=%v", got)
|
||
}
|
||
session.connectionTownActorOwnerChannel = byte(session.channel.ID)
|
||
session.townActorOwnerChannel = byte(session.channel.ID)
|
||
wantSkillBody := enableTownMoveSkillProjection(t, service, repositories, "99")
|
||
connection.write.Reset()
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(7, 3, 474, 234, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if session.dungeon.runtime != nil {
|
||
t.Fatalf("town request restored dungeon runtime=%+v", session.dungeon.runtime)
|
||
}
|
||
_ = wantSkillBody
|
||
townPacket, rest := splitTownAreaTransitionSequence(t, connection.write.Bytes(), 99, 7, 3, 474, 234, 5, 3)
|
||
if townPacket.Header.Classification != 0 || townPacket.Header.MsgID != currentSceneTransitionMsgID {
|
||
t.Fatalf("first packet after confirmed return header=%+v body=%x", townPacket.Header, townPacket.Body)
|
||
}
|
||
crystalPacket, rest := splitGameServerUpperPacket(t, rest)
|
||
if crystalPacket.Header.Classification != 0 ||
|
||
crystalPacket.Header.MsgID != currentCrystalContractStateMsgID ||
|
||
!bytes.Equal(crystalPacket.Body, []byte{0, 0xff}) || len(rest) != 0 {
|
||
t.Fatalf(
|
||
"crystal state after confirmed return header=%+v body=%x trailing=%x",
|
||
crystalPacket.Header,
|
||
crystalPacket.Body,
|
||
rest,
|
||
)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaDoesNotRefreshQuestsForOrdinaryTownRoomMove(t *testing.T) {
|
||
service, session, _ := newTownMoveTest(t)
|
||
service.questCatalog = buildQuestListTestCatalog(t)
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
packet, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
0,
|
||
900,
|
||
250,
|
||
5,
|
||
3,
|
||
)
|
||
if packet.Header.MsgID != currentSceneTransitionMsgID || packet.Header.Classification != 0 || len(rest) != 0 {
|
||
t.Fatalf("ordinary town move header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaSeedsCompletedQuestSceneProjectionBeforeOp24(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
completedAt := time.Date(2026, 8, 4, 1, 30, 0, 0, time.UTC)
|
||
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
||
CharacterID: "29",
|
||
States: map[int64]dnfrepo.QuestState{
|
||
3162: {Status: "completed", ProgressValue: 1, UpdatedAt: completedAt},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
clearQuest, transition, trailing := splitTownAreaTransitionSequenceWithClearQuest(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
0,
|
||
900,
|
||
250,
|
||
5,
|
||
3,
|
||
)
|
||
if transition.Header.Classification != 0 || transition.Header.MsgID != currentSceneTransitionMsgID || len(trailing) != 0 {
|
||
t.Fatalf("completed quest restore transition=%+v trailing=%x", transition.Header, trailing)
|
||
}
|
||
plain, err := zlibDecompress(clearQuest.Body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(plain) != 4+currentClearQuestListPayloadSize || plain[4+3162] != 1 {
|
||
t.Fatalf("completed quest restore len=%d flag=%d", len(plain), plain[4+3162])
|
||
}
|
||
persisted, found, err := repositories.Quest.Load(context.Background(), "29")
|
||
if err != nil || !found || persisted.States[3162].Status != "completed" || !persisted.States[3162].UpdatedAt.Equal(completedAt) {
|
||
t.Fatalf("completed quest projection mutated persistence found=%t err=%v state=%+v", found, err, persisted.States[3162])
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaRejectsMissingAreaAndUnprovedQuestOrCrossTownRoutes(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
body []byte
|
||
}{
|
||
{name: "missing area", body: buildTownMoveRequest(38, 99, 1, 2, 5)},
|
||
{name: "ordinary quest gated", body: buildTownMoveRequest(38, 3, 1, 2, 5)},
|
||
{name: "ordinary cross town", body: buildTownMoveRequest(39, 0, 1, 2, 5)},
|
||
}
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
if err := service.handleTownSetUserArea(session, test.body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("blocked move wrote=%x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 450 || stored.Stats["pos_y"] != 234 {
|
||
t.Fatalf("blocked move changed character found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaAllowsOnlyAuthenticatedRobotPublicPlacement(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
session.robotSessionBound = true
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(39, 0, 640, 240, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 39 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 640 || stored.Stats["pos_y"] != 240 {
|
||
t.Fatalf("robot public placement not committed found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) == 0 {
|
||
t.Fatal("robot public placement emitted no town transition")
|
||
}
|
||
|
||
service, session, repositories = newTownMoveTest(t)
|
||
session.robotSessionBound = true
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 3, 571, 315, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stored, found, err = repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 3 {
|
||
t.Fatalf("robot assigned quest-gated placement not committed found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
|
||
service, session, repositories = newTownMoveTest(t)
|
||
session.robotSessionBound = true
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(107, 0, 640, 240, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("robot non-public placement emitted packets: %x", got)
|
||
}
|
||
stored, found, err = repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 {
|
||
t.Fatalf("robot non-public placement changed location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaRejectsTeleportArrayTargetsWithUnmetPVFNeedQuests(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
body []byte
|
||
}{
|
||
{name: "quest gated same town", body: buildTownTeleportArrayRequest(38, 3, 571, 315, 5)},
|
||
{name: "cross town quest gated", body: buildTownTeleportArrayRequest(39, 2, 418, 209, 5)},
|
||
}
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
if err := service.handleTownSetUserArea(session, test.body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("unmet teleport prerequisite emitted packets: %x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 450 || stored.Stats["pos_y"] != 234 {
|
||
t.Fatalf("unmet teleport changed character found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if _, found, err := repositories.Quest.Load(context.Background(), "29"); err != nil || found {
|
||
t.Fatalf("unmet teleport persisted quest completion found=%t err=%v", found, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaRejectsWorldMapStationTailZeroTargetWithUnmetNeedQuest(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 40
|
||
character.Stats["area_id"] = 1
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownMapStationRequest(40, 0, 1519, 489, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("unmet map-station prerequisite emitted packets: %x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 40 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 450 || stored.Stats["pos_y"] != 234 {
|
||
t.Fatalf("unmet map-station changed character found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if _, found, err := repositories.Quest.Load(context.Background(), "29"); err != nil || found {
|
||
t.Fatalf("unmet map-station persisted quest completion found=%t err=%v", found, err)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaDoesNotPersistTransportNeedQuest(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 40
|
||
character.Stats["area_id"] = 1
|
||
character.Stats["pos_x"] = 777
|
||
character.Stats["pos_y"] = 333
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.handleTownSetUserArea(session, buildTownMapStationRequest(40, 0, 1519, 489, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("unmet transport prerequisite emitted town packets: %x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 40 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 777 || stored.Stats["pos_y"] != 333 {
|
||
t.Fatalf("unmet transport prerequisite moved character found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if _, found, err := repositories.Quest.Load(context.Background(), "29"); err != nil || found {
|
||
t.Fatalf("unmet transport prerequisite persisted completion found=%t err=%v", found, err)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaAcceptsCrossTownSeriaRoomPortalWithOneOp24(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 40
|
||
character.Stats["area_id"] = 0
|
||
character.Stats["pos_x"] = 1519
|
||
character.Stats["pos_y"] = 489
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownSeriaRoomPortalRequest(38, 1, 433, 311, 5, 40)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
packet, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
1,
|
||
433,
|
||
311,
|
||
5,
|
||
3,
|
||
)
|
||
if packet.Header.MsgID != currentSceneTransitionMsgID || packet.Header.Classification != 0 || len(rest) != 0 {
|
||
t.Fatalf("Seria portal response header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 433 || stored.Stats["pos_y"] != 311 {
|
||
t.Fatalf("Seria portal location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaAcceptsCrossTownPortalTailZero(t *testing.T) {
|
||
t.Run("west coast to HendonMyre", func(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 40
|
||
character.Stats["area_id"] = 0
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownCrossTownPortalRequest(39, 0, 3547, 270, 5, 40)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
packet, rest := splitTownAreaTransitionSequence(t, session.conn.(*bufferConn).write.Bytes(), 29, 39, 0, 3547, 270, 5, 3)
|
||
if packet.Header.MsgID != currentSceneTransitionMsgID || packet.Header.Classification != 0 || len(rest) != 0 {
|
||
t.Fatalf("cross-town portal response header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 39 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 3547 || stored.Stats["pos_y"] != 270 {
|
||
t.Fatalf("cross-town portal location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if area, found := service.townArea(39, 0); !found || area.MapPath != "new_HendonMyre.map" {
|
||
t.Fatalf("target map found=%t area=%+v", found, area)
|
||
}
|
||
})
|
||
|
||
t.Run("HendonMyre to west coast need quest", func(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 39
|
||
character.Stats["area_id"] = 0
|
||
character.Stats["pos_x"] = 701
|
||
character.Stats["pos_y"] = 802
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownCrossTownPortalRequest(40, 0, 1519, 489, 5, 39)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("unmet cross-town prerequisite emitted packets: %x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 39 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 701 || stored.Stats["pos_y"] != 802 {
|
||
t.Fatalf("unmet cross-town prerequisite changed character found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if _, found, err := repositories.Quest.Load(context.Background(), "29"); err != nil || found {
|
||
t.Fatalf("unmet cross-town prerequisite persisted quest completion found=%t err=%v", found, err)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaAcceptsPVFProvenNPCEnterTownRoute(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Level = 50
|
||
character.Stats["town_id"] = 38
|
||
character.Stats["area_id"] = 0
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
||
CharacterID: "29",
|
||
States: map[int64]dnfrepo.QuestState{
|
||
8618: {Status: "completed"},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownNPCEnterRequest(107, 0, 916, 224, 5, 38, 20276)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, packet, _ := splitTownAreaTransitionSequenceWithClearQuest(t, session.conn.(*bufferConn).write.Bytes(), 29, 107, 0, 916, 224, 5, 3)
|
||
if packet.Header.MsgID != currentSceneTransitionMsgID {
|
||
t.Fatalf("NPC enter-town response header=%+v", packet.Header)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 107 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 916 || stored.Stats["pos_y"] != 224 {
|
||
t.Fatalf("NPC enter-town location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if !session.townTransportEnterSelectPending {
|
||
t.Fatal("NPC enter-town route did not arm the destination dungeon selector")
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaRejectsUnprovedNPCEnterTownRoute(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Level = 50
|
||
character.Stats["town_id"] = 38
|
||
character.Stats["area_id"] = 0
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownNPCEnterRequest(107, 0, 916, 224, 5, 38, 20277)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("unproved NPC route emitted packets: %x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 0 {
|
||
t.Fatalf("unproved NPC route changed location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestHandlePrevVillageReturnsFromSeriaRoomToBoundOrigin(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 40
|
||
character.Stats["area_id"] = 0
|
||
character.Stats["pos_x"] = 1519
|
||
character.Stats["pos_y"] = 489
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
session.townPositionSnapshot = currentTownPositionSnapshot{
|
||
CharacterID: 29,
|
||
TownID: 40,
|
||
AreaID: 0,
|
||
PositionX: 1500,
|
||
PositionY: 500,
|
||
MovementCode: 5,
|
||
PositionValid: true,
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownSeriaRoomPortalRequest(38, 1, 433, 311, 5, 40)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, enterSeriaTrailing := splitTownAreaTransitionSequence(
|
||
t,
|
||
session.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
38,
|
||
1,
|
||
433,
|
||
311,
|
||
5,
|
||
3,
|
||
)
|
||
if len(enterSeriaTrailing) != 0 {
|
||
t.Fatalf("entering Seria left unparsed transition packets=%x", enterSeriaTrailing)
|
||
}
|
||
if session.townPrevVillageSnapshot.CharacterID != 29 ||
|
||
session.townPrevVillageSnapshot.TownID != 40 ||
|
||
session.townPrevVillageSnapshot.AreaID != 0 ||
|
||
session.townPrevVillageSnapshot.PositionX != 1500 ||
|
||
session.townPrevVillageSnapshot.PositionY != 500 {
|
||
t.Fatalf("prev-village origin after entering Seria=%+v", session.townPrevVillageSnapshot)
|
||
}
|
||
storedSeria, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found ||
|
||
storedSeria.Stats[currentTownPrevVillageTownStat] != 40 ||
|
||
storedSeria.Stats[currentTownPrevVillageAreaStat] != 0 ||
|
||
storedSeria.Stats[currentTownPrevVillagePosXStat] != 1500 ||
|
||
storedSeria.Stats[currentTownPrevVillagePosYStat] != 500 ||
|
||
storedSeria.Stats[currentTownPrevVillageDirectionStat] != 5 {
|
||
t.Fatalf("entering Seria did not persist prev-village origin found=%t err=%v stats=%+v", found, err, storedSeria.Stats)
|
||
}
|
||
connection := session.conn.(*bufferConn)
|
||
connection.write.Reset()
|
||
completedAt := time.Date(2026, 9, 1, 8, 50, 0, 0, time.UTC)
|
||
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
||
CharacterID: "29",
|
||
States: map[int64]dnfrepo.QuestState{
|
||
4840: {Status: "completed", ProgressValue: 1, UpdatedAt: completedAt},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketPrevVillage), nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
clearQuest, packet, rest := splitTownAreaTransitionSequenceWithClearQuest(
|
||
t,
|
||
connection.write.Bytes(),
|
||
29,
|
||
40,
|
||
0,
|
||
1500,
|
||
500,
|
||
5,
|
||
3,
|
||
)
|
||
if len(rest) != 0 {
|
||
t.Fatalf("prev-village response header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
||
}
|
||
plain, err := zlibDecompress(clearQuest.Body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(plain) != 4+currentClearQuestListPayloadSize || plain[4+4840] != 1 {
|
||
t.Fatalf("prev-village completed quest projection len=%d flag=%d", len(plain), plain[4+4840])
|
||
}
|
||
wantBody, err := buildCurrentSceneTransitionBody(40, 0, []currentSceneTransitionRow{{
|
||
ObjectOrResourceKey: 29,
|
||
Value1: 1500,
|
||
Value2: 500,
|
||
Value3: 5,
|
||
Value4: 3,
|
||
}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !bytes.Equal(packet.Body, wantBody) {
|
||
t.Fatalf("prev-village body=%x want=%x", packet.Body, wantBody)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 40 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 1500 || stored.Stats["pos_y"] != 500 {
|
||
t.Fatalf("prev-village location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if session.townPrevVillageSnapshot.PositionValid {
|
||
t.Fatalf("prev-village origin was not cleared after return: %+v", session.townPrevVillageSnapshot)
|
||
}
|
||
}
|
||
|
||
func TestHandlePrevVillageRestoresPersistedOriginAfterRelog(t *testing.T) {
|
||
service, _, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = newCharacterInitialTownID
|
||
character.Stats["area_id"] = newCharacterInitialAreaID
|
||
character.Stats["pos_x"] = newCharacterInitialPosX
|
||
character.Stats["pos_y"] = newCharacterInitialPosY
|
||
character.Stats["direction"] = newCharacterInitialDirection
|
||
character.Stats[currentTownPrevVillageTownStat] = 40
|
||
character.Stats[currentTownPrevVillageAreaStat] = 0
|
||
character.Stats[currentTownPrevVillagePosXStat] = 1500
|
||
character.Stats[currentTownPrevVillagePosYStat] = 500
|
||
character.Stats[currentTownPrevVillageDirectionStat] = 5
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
channel := channelcatalog.Channel{ServerID: 1, ID: 19, Type: 1, Name: "ch.19", Port: 10019}
|
||
reloginSession := &gameSession{
|
||
conn: &bufferConn{},
|
||
channel: channel,
|
||
residentChannel: channel,
|
||
connectionTownActorOwnerChannel: byte(channel.ID),
|
||
townActorOwnerChannel: byte(channel.ID),
|
||
selectedCharacterID: 29,
|
||
townSceneReadyCharacterID: 29,
|
||
}
|
||
if err := service.handleGameCommand(reloginSession, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketPrevVillage), nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
packet, rest := splitTownAreaTransitionSequence(
|
||
t,
|
||
reloginSession.conn.(*bufferConn).write.Bytes(),
|
||
29,
|
||
40,
|
||
0,
|
||
1500,
|
||
500,
|
||
5,
|
||
3,
|
||
)
|
||
if len(rest) != 0 {
|
||
t.Fatalf("persisted prev-village response header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
||
}
|
||
wantBody, err := buildCurrentSceneTransitionBody(40, 0, []currentSceneTransitionRow{{
|
||
ObjectOrResourceKey: 29,
|
||
Value1: 1500,
|
||
Value2: 500,
|
||
Value3: 5,
|
||
Value4: 3,
|
||
}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !bytes.Equal(packet.Body, wantBody) {
|
||
t.Fatalf("persisted prev-village body=%x want=%x", packet.Body, wantBody)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 40 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 1500 || stored.Stats["pos_y"] != 500 || stored.Stats["direction"] != 5 {
|
||
t.Fatalf("persisted prev-village location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestHandlePrevVillageTransitionWriteFailureRollsBackLocation(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = newCharacterInitialTownID
|
||
character.Stats["area_id"] = newCharacterInitialAreaID
|
||
character.Stats["pos_x"] = newCharacterInitialPosX
|
||
character.Stats["pos_y"] = newCharacterInitialPosY
|
||
character.Stats["direction"] = newCharacterInitialDirection
|
||
character.Stats[currentTownPrevVillageTownStat] = 40
|
||
character.Stats[currentTownPrevVillageAreaStat] = 0
|
||
character.Stats[currentTownPrevVillagePosXStat] = 1500
|
||
character.Stats[currentTownPrevVillagePosYStat] = 500
|
||
character.Stats[currentTownPrevVillageDirectionStat] = 5
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
wantErr := errors.New("previous-village op24 write failed")
|
||
session.conn = &failNthDungeonWriteConn{failAt: 3, err: wantErr}
|
||
if err := service.handleGameCommand(
|
||
session,
|
||
byte(dnfenum.GameCmdCommand),
|
||
uint16(dnfenum.CmdPacketPrevVillage),
|
||
nil,
|
||
); !errors.Is(err, wantErr) {
|
||
t.Fatalf("previous-village op24 failure=%v want=%v", err, wantErr)
|
||
}
|
||
stored, found, loadErr := repositories.Character.Load(context.Background(), "29")
|
||
if loadErr != nil || !found ||
|
||
stored.Stats["town_id"] != newCharacterInitialTownID ||
|
||
stored.Stats["area_id"] != newCharacterInitialAreaID ||
|
||
stored.Stats["pos_x"] != newCharacterInitialPosX ||
|
||
stored.Stats["pos_y"] != newCharacterInitialPosY {
|
||
t.Fatalf("previous-village op24 rollback found=%t err=%v stats=%+v", found, loadErr, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaWriteFailureRollsBackLocation(t *testing.T) {
|
||
for _, test := range []struct {
|
||
name string
|
||
failAt int
|
||
}{
|
||
{name: "op22", failAt: 1},
|
||
{name: "op23", failAt: 2},
|
||
{name: "op24", failAt: 3},
|
||
} {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
wantErr := fmt.Errorf("town %s write failed", test.name)
|
||
session.conn = &failNthDungeonWriteConn{failAt: test.failAt, err: wantErr}
|
||
err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5))
|
||
if !errors.Is(err, wantErr) {
|
||
t.Fatalf("write error=%v want=%v", err, wantErr)
|
||
}
|
||
stored, found, loadErr := repositories.Character.Load(context.Background(), "29")
|
||
if loadErr != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 450 || stored.Stats["pos_y"] != 234 {
|
||
t.Fatalf("rollback found=%t err=%v stats=%+v", found, loadErr, stored.Stats)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestTownSetUserAreaCurrentWriterBoundaryAndClass(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
for _, body := range [][]byte{make([]byte, currentTownSetUserAreaBodySize-1), make([]byte, currentTownSetUserAreaBodySize+1)} {
|
||
if err := service.handleTownSetUserArea(session, body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
packet, err := dnfproto.BuildChannelPacket(36, buildTownMoveRequest(38, 0, 900, 250, 5), 1, 2)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.handleGameUpper(session, packet); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Bytes(); len(got) != 0 {
|
||
t.Fatalf("invalid boundary/class wrote=%x", got)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["area_id"] != 1 {
|
||
t.Fatalf("invalid boundary/class changed character found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestInitialTownTransitionUsesPersistedNormalAreaWithoutMutation(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 39
|
||
character.Stats["area_id"] = 0
|
||
character.Stats["pos_x"] = 70
|
||
character.Stats["pos_y"] = 80
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
updated, townID, areaID, row, mapPath, err := service.currentInitialTownTransition(
|
||
context.Background(),
|
||
session,
|
||
29,
|
||
character,
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if townID != 39 || areaID != 0 || row.Value1 != 70 || row.Value2 != 80 ||
|
||
row.Value3 != 5 || row.Value4 != 3 || mapPath != "new_HendonMyre.map" ||
|
||
updated.Stats["town_id"] != 39 || updated.Stats["area_id"] != 0 {
|
||
t.Fatalf("persisted transition town=%d area=%d row=%+v map=%q stats=%+v", townID, areaID, row, mapPath, updated.Stats)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 39 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 70 || stored.Stats["pos_y"] != 80 ||
|
||
stored.Stats["direction"] != 5 || stored.Stats["area_state"] != 3 {
|
||
t.Fatalf("login mutated persisted location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestInitialTownTransitionLeavesPersistedPVFNeedQuestAreaReadOnly(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 38
|
||
character.Stats["area_id"] = 3
|
||
character.Stats["pos_x"] = 571
|
||
character.Stats["pos_y"] = 315
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
_, townID, areaID, _, mapPath, err := service.currentInitialTownTransition(
|
||
context.Background(),
|
||
session,
|
||
29,
|
||
character,
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if townID != 38 || areaID != 3 || mapPath != "Elvengard_hendon.map" {
|
||
t.Fatalf("persisted need-quest transition town=%d area=%d map=%q", townID, areaID, mapPath)
|
||
}
|
||
if _, found, err := repositories.Quest.Load(context.Background(), "29"); err != nil || found {
|
||
t.Fatalf("persisted transition wrote quest completion found=%t err=%v", found, err)
|
||
}
|
||
plain := buildCurrentClearQuestListBody(dnfrepo.QuestRecord{}, false)
|
||
if got := plain[4+3155]; got != 0 {
|
||
t.Fatalf("persisted need quest op356 flag=%d, want 0", got)
|
||
}
|
||
}
|
||
|
||
func TestCharacterListLoginTransitionPersistsRuntimePVFSeriaGate(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 39
|
||
character.Stats["area_id"] = 0
|
||
character.Stats["pos_x"] = 70
|
||
character.Stats["pos_y"] = 80
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
updated, townID, areaID, row, mapPath, err := service.currentCharacterListLoginTransition(
|
||
context.Background(),
|
||
session,
|
||
29,
|
||
character,
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if townID != newCharacterInitialTownID || areaID != newCharacterInitialAreaID ||
|
||
row.Value1 != newCharacterInitialPosX || row.Value2 != newCharacterInitialPosY ||
|
||
row.Value3 != newCharacterInitialDirection || row.Value4 != newCharacterInitialAreaState ||
|
||
mapPath != "new_seria_room.map" {
|
||
t.Fatalf("Seria login transition town=%d area=%d row=%+v map=%q", townID, areaID, row, mapPath)
|
||
}
|
||
if updated.Stats["town_id"] != newCharacterInitialTownID || updated.Stats["area_id"] != newCharacterInitialAreaID ||
|
||
updated.Stats["pos_x"] != newCharacterInitialPosX || updated.Stats["pos_y"] != newCharacterInitialPosY {
|
||
t.Fatalf("returned Seria login state=%+v", updated.Stats)
|
||
}
|
||
if updated.Stats[currentTownPrevVillageTownStat] != 39 ||
|
||
updated.Stats[currentTownPrevVillageAreaStat] != 0 ||
|
||
updated.Stats[currentTownPrevVillagePosXStat] != 70 ||
|
||
updated.Stats[currentTownPrevVillagePosYStat] != 80 ||
|
||
updated.Stats[currentTownPrevVillageDirectionStat] != 5 ||
|
||
session.townPrevVillageSnapshot.CharacterID != 29 ||
|
||
session.townPrevVillageSnapshot.TownID != 39 ||
|
||
session.townPrevVillageSnapshot.AreaID != 0 ||
|
||
session.townPrevVillageSnapshot.PositionX != 70 ||
|
||
session.townPrevVillageSnapshot.PositionY != 80 {
|
||
t.Fatalf("character-list login did not preserve prev-village origin stats=%+v snapshot=%+v", updated.Stats, session.townPrevVillageSnapshot)
|
||
}
|
||
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("persisted Seria login state found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if stored.Stats[currentTownPrevVillageTownStat] != 39 ||
|
||
stored.Stats[currentTownPrevVillageAreaStat] != 0 ||
|
||
stored.Stats[currentTownPrevVillagePosXStat] != 70 ||
|
||
stored.Stats[currentTownPrevVillagePosYStat] != 80 ||
|
||
stored.Stats[currentTownPrevVillageDirectionStat] != 5 {
|
||
t.Fatalf("persisted Seria login lost prev-village origin stats=%+v", stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestAntonRaidChannelLoginUsesTemporaryPVFGateWithoutMutatingOrdinaryLocation(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
session.channel.Type = dnfenum.RaidChannelType
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Level = 90
|
||
character.Stats["town_id"] = 39
|
||
character.Stats["area_id"] = 0
|
||
character.Stats["pos_x"] = 70
|
||
character.Stats["pos_y"] = 80
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
updated, townID, areaID, row, mapPath, err := service.currentCharacterListLoginTransition(
|
||
context.Background(),
|
||
session,
|
||
29,
|
||
character,
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if townID != byte(currentAntonRaidTownID) || areaID != byte(currentAntonRaidAreaID) ||
|
||
row.Value1 != 474 || row.Value2 != 234 || row.Value3 != currentAntonRaidDirection ||
|
||
row.Value4 != currentAntonRaidAreaState || mapPath != "Town/BattleShip/Gate_BattleShip.map" {
|
||
t.Fatalf("Anton raid login transition town=%d area=%d row=%+v map=%q", townID, areaID, row, mapPath)
|
||
}
|
||
if updated.Stats["town_id"] != 39 || updated.Stats["area_id"] != 0 ||
|
||
updated.Stats["pos_x"] != 70 || updated.Stats["pos_y"] != 80 {
|
||
t.Fatalf("Anton raid transition rewrote returned ordinary location stats=%+v", updated.Stats)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 39 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 70 || stored.Stats["pos_y"] != 80 {
|
||
t.Fatalf("Anton raid login mutated ordinary location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
|
||
_, reconnectTownID, reconnectAreaID, reconnectRow, reconnectMapPath, err := service.currentChannelReconnectTownTransition(
|
||
context.Background(),
|
||
session,
|
||
29,
|
||
character,
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if reconnectTownID != townID || reconnectAreaID != areaID || reconnectRow != row || reconnectMapPath != mapPath {
|
||
t.Fatalf("Anton raid reconnect transition town=%d area=%d row=%+v map=%q", reconnectTownID, reconnectAreaID, reconnectRow, reconnectMapPath)
|
||
}
|
||
}
|
||
|
||
func TestAntonRaidTownSceneSnapshotValidatesOp36AgainstTemporaryTown(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
session.channel.Type = dnfenum.RaidChannelType
|
||
session.townPositionSnapshot = currentTownPositionSnapshot{
|
||
CharacterID: 29,
|
||
TownID: byte(currentAntonRaidTownID),
|
||
AreaID: byte(currentAntonRaidAreaID),
|
||
PositionX: 474,
|
||
PositionY: 234,
|
||
PositionValid: true,
|
||
}
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Level = 90
|
||
character.Stats["town_id"] = newCharacterInitialTownID
|
||
character.Stats["area_id"] = newCharacterInitialAreaID
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(19, 1, 500, 234, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Len(); got == 0 {
|
||
t.Fatal("Anton town op36 was rejected despite the session town snapshot")
|
||
}
|
||
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 {
|
||
t.Fatalf("Anton town op36 leaked temporary location into durable character state found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if snapshot := session.townPositionSnapshot; snapshot.TownID != byte(currentAntonRaidTownID) ||
|
||
snapshot.AreaID != 1 || snapshot.PositionValid {
|
||
t.Fatalf("Anton town op36 did not advance temporary scene snapshot=%+v", snapshot)
|
||
}
|
||
}
|
||
|
||
func TestAntonRaidWaitingAreaRequiresMembershipAndUsesRaidInstance(t *testing.T) {
|
||
newWaitingMove := func(t *testing.T) (*Service, *gameSession) {
|
||
t.Helper()
|
||
service, session, _ := newTownMoveTest(t)
|
||
session.channel.Type = dnfenum.RaidChannelType
|
||
session.townPositionSnapshot = currentTownPositionSnapshot{
|
||
CharacterID: 29,
|
||
TownID: byte(currentAntonRaidTownID),
|
||
AreaID: 1,
|
||
PositionX: 500,
|
||
PositionY: 234,
|
||
PositionValid: true,
|
||
}
|
||
service.onlinePlayers = newOnlinePlayerManager()
|
||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||
CharacterID: 29,
|
||
TownID: byte(currentAntonRaidTownID),
|
||
AreaID: 1,
|
||
Session: session,
|
||
})
|
||
return service, session
|
||
}
|
||
|
||
t.Run("no_membership", func(t *testing.T) {
|
||
service, session := newWaitingMove(t)
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(19, 2, 600, 234, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Len(); got != 0 {
|
||
t.Fatalf("membership-less waiting move wrote %d bytes", got)
|
||
}
|
||
presence, ok := service.onlinePlayers.PlayerForCharacter(29)
|
||
if !ok || presence.AreaID != 1 || presence.InstanceKey != 0 {
|
||
t.Fatalf("membership-less waiting move changed presence: %+v found=%t", presence, ok)
|
||
}
|
||
})
|
||
|
||
t.Run("raid_instance", func(t *testing.T) {
|
||
service, session := newWaitingMove(t)
|
||
const raidKey = uint32(0x0017001d)
|
||
service.raids = map[uint32]*runtimeRaidState{
|
||
raidKey: {
|
||
RaidKey: raidKey,
|
||
LeaderID: 29,
|
||
Members: []runtimeRaidMemberState{{CharID: 29, GroupIndex: 1, SlotOrder: 0}},
|
||
},
|
||
}
|
||
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(19, 2, 600, 234, 5)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := session.conn.(*bufferConn).write.Len(); got == 0 {
|
||
t.Fatal("raid member waiting move emitted no transition")
|
||
}
|
||
presence, ok := service.onlinePlayers.PlayerForCharacter(29)
|
||
if !ok || presence.TownID != byte(currentAntonRaidTownID) ||
|
||
presence.AreaID != currentAntonRaidWaitingAreaID || presence.InstanceKey != raidKey {
|
||
t.Fatalf("raid waiting presence=%+v found=%t", presence, ok)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestNormalChannelReconnectRepairsLeakedAntonTownLocation(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = currentAntonRaidTownID
|
||
character.Stats["area_id"] = 1
|
||
character.Stats["pos_x"] = 500
|
||
character.Stats["pos_y"] = 234
|
||
character.Stats[currentTownPrevVillageTownStat] = currentAntonRaidTownID
|
||
character.Stats[currentTownPrevVillageAreaStat] = 0
|
||
character.Stats[currentTownPrevVillagePosXStat] = 438
|
||
character.Stats[currentTownPrevVillagePosYStat] = 347
|
||
character.Stats[currentTownPrevVillageDirectionStat] = 5
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
updated, townID, areaID, row, mapPath, err := service.currentChannelReconnectTownTransition(
|
||
context.Background(),
|
||
session,
|
||
29,
|
||
character,
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if townID != newCharacterInitialTownID || areaID != newCharacterInitialAreaID ||
|
||
row.Value1 != newCharacterInitialPosX || row.Value2 != newCharacterInitialPosY ||
|
||
mapPath != "new_seria_room.map" {
|
||
t.Fatalf("normal reconnect transition town=%d area=%d row=%+v map=%q", townID, areaID, row, mapPath)
|
||
}
|
||
if updated.Stats["town_id"] != newCharacterInitialTownID || updated.Stats["area_id"] != newCharacterInitialAreaID {
|
||
t.Fatalf("normal reconnect returned leaked state stats=%+v", updated.Stats)
|
||
}
|
||
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[currentTownPrevVillageTownStat] == currentAntonRaidTownID ||
|
||
stored.Stats[currentTownPrevVillageAreaStat] != 0 ||
|
||
stored.Stats[currentTownPrevVillagePosXStat] != 0 ||
|
||
stored.Stats[currentTownPrevVillagePosYStat] != 0 ||
|
||
stored.Stats[currentTownPrevVillageDirectionStat] != 0 {
|
||
t.Fatalf("normal reconnect did not repair leaked Anton location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestInitialTownTransitionAcceptsPersistedAreaWithoutPVFGate(t *testing.T) {
|
||
repositories := testRepositoryGroup()
|
||
character := dnfrepo.CharacterRecord{
|
||
CharacterID: "29",
|
||
AccountID: "dnf:1",
|
||
Stats: map[string]int64{
|
||
"town_id": 40,
|
||
"area_id": 0,
|
||
"pos_x": 70,
|
||
"pos_y": 80,
|
||
"direction": 5,
|
||
"area_state": 3,
|
||
},
|
||
}
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
table, err := dnftown.Load(context.Background(), townMoveTestSource{
|
||
"town/town.lst": "40 `no_gate.twn`",
|
||
"town/no_gate.twn": "[area]\n0 `only_normal.map` `[normal]`\n[/area]\n[name]\n`NoGate`",
|
||
}, dnftown.Options{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := &Service{
|
||
townCatalog: table,
|
||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||
}
|
||
_, townID, areaID, row, mapPath, err := service.currentInitialTownTransition(
|
||
context.Background(),
|
||
&gameSession{selectedCharacterID: 29},
|
||
29,
|
||
character,
|
||
)
|
||
if err != nil || townID != 40 || areaID != 0 || row.Value1 != 70 || row.Value2 != 80 ||
|
||
row.Value3 != 5 || row.Value4 != 3 || mapPath != "only_normal.map" {
|
||
t.Fatalf("normal-area transition town=%d area=%d row=%+v map=%q err=%v", townID, areaID, row, mapPath, err)
|
||
}
|
||
stored, found, loadErr := repositories.Character.Load(context.Background(), "29")
|
||
if loadErr != nil || !found || stored.Stats["town_id"] != 40 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 70 || stored.Stats["pos_y"] != 80 {
|
||
t.Fatalf("login rewrote normal-area character found=%t err=%v stats=%+v", found, loadErr, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func TestInitialTownTransitionRejectsUnknownPersistedAreaWithoutMutation(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["area_id"] = 99
|
||
character.Stats["pos_x"] = 70
|
||
character.Stats["pos_y"] = 80
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, _, _, _, _, err = service.currentInitialTownTransition(context.Background(), session, 29, character)
|
||
if err == nil {
|
||
t.Fatal("unknown persisted area unexpectedly fell back to the town gate")
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["area_id"] != 99 || stored.Stats["pos_x"] != 70 || stored.Stats["pos_y"] != 80 {
|
||
t.Fatalf("failed login mutated persisted location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
}
|
||
|
||
func newTownMoveTest(t *testing.T) (*Service, *gameSession, dnfrepo.Group) {
|
||
t.Helper()
|
||
repositories := testRepositoryGroup()
|
||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||
CharacterID: "29",
|
||
AccountID: "dnf:1",
|
||
Level: 1,
|
||
Stats: map[string]int64{
|
||
"town_id": 38,
|
||
"area_id": 1,
|
||
"pos_x": 450,
|
||
"pos_y": 234,
|
||
"direction": 5,
|
||
"area_state": 3,
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
list := "10 `Fair_PVP.twn` 19 `BattleShip_Raid.twn` 38 `new_Elvengard.twn` 39 `new_HendonMyre.twn` 40 `new_WestCoast.twn` 107 `event.twn`"
|
||
pvp := "[area]\n0 `Fair_PVP/Gate_Hendon.map` `[gate]` 123 234\n[/area]\n[name]\n`PVP`"
|
||
battleship := "[area]\n0 `Town/BattleShip/Gate_BattleShip.map` `[gate]` 474 234\n[/area]\n" +
|
||
"[area]\n1 `Town/BattleShip/BattleShip_Main.map` `[normal]`\n[/area]\n" +
|
||
"[area]\n2 `Town/BattleShip/BattleShip_Anton.map` `[normal]`\n[/area]\n" +
|
||
"[name]\n`BattleShip`"
|
||
elvengard := "[area]\n0 `new_Elvengard.map` `[normal]`\n[/area]\n" +
|
||
"[area]\n1 `new_seria_room.map` `[gate]` 450 234\n[/area]\n" +
|
||
"[area]\n3 `Elvengard_hendon.map`\n[need quest]\n3155\n[/need quest]\n`[dungeon gate]` 114\n[/area]\n[name]\n`Elvengard`"
|
||
hendon := "[area]\n0 `new_HendonMyre.map` `[normal]`\n[/area]\n" +
|
||
"[area]\n2 `new_HendonMyre_seria.map`\n[need quest]\n3156\n[/need quest]\n`[gate]` 612 244\n[/area]\n" +
|
||
"[name]\n`HendonMyre`"
|
||
westCoast := "[area]\n0 `WestCoast.map`\n[need quest]\n3156\n[/need quest]\n[/area]\n" +
|
||
"[area]\n1 `WestCoast_post.map` `[gate]` 1016 189\n[/area]\n" +
|
||
"[name]\n`WestCoast`"
|
||
eventTown := "[area]\n0 `Town/Event/event.map`\n[need level]\n50\n[need quest]\n8618\n[/need quest]\n`[dungeon gate]` 114\n[/area]\n[name]\n`EventTown`"
|
||
table, err := dnftown.Load(context.Background(), townMoveTestSource{
|
||
"town/Fair_PVP.twn": pvp,
|
||
"town/BattleShip_Raid.twn": battleship,
|
||
"town/town.lst": list,
|
||
"town/new_Elvengard.twn": elvengard,
|
||
"town/new_HendonMyre.twn": hendon,
|
||
"town/new_WestCoast.twn": westCoast,
|
||
"town/event.twn": eventTown,
|
||
"npc/npc.lst": "20275 `entry.npc` 20276 `exit.npc` 20277 `unrelated.npc`",
|
||
"npc/entry.npc": "[role]\n`[enter town]` 20276\n[/role]",
|
||
"npc/exit.npc": "[role]\n`[exit town]` 0\n[/role]",
|
||
"npc/unrelated.npc": "[role]\n`[exit town]` 0\n[/role]",
|
||
"map/Elvengard_hendon.map": "[NPC]\n20276 `[left]` 571 315 0\n[/NPC]",
|
||
"map/new_HendonMyre.map": "[NPC]\n20276 `[left]` 640 240 0\n[/NPC]",
|
||
"map/Town/Event/event.map": "[NPC]\n20276 `[left]` 100 200 0 20277 `[left]` 300 400 0\n[/NPC]",
|
||
}, dnftown.Options{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := &Service{
|
||
townCatalog: table,
|
||
premiumCatalog: ¤tPremiumCatalog{
|
||
contractsByItem: make(map[int64]currentPremiumContractInfo),
|
||
devilSlots: make(map[uint32]currentPremiumDevilSlotInfo),
|
||
crystalCubeIDs: [6]int64{3033, 3034, 3035, 3036, 3037, 3262},
|
||
},
|
||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||
}
|
||
channel := channelcatalog.Channel{ServerID: 1, ID: 19, Type: 1, Name: "ch.19", Port: 10019}
|
||
session := &gameSession{
|
||
conn: &bufferConn{},
|
||
channel: channel,
|
||
residentChannel: channel,
|
||
connectionTownActorOwnerChannel: byte(channel.ID),
|
||
townActorOwnerChannel: byte(channel.ID),
|
||
selectedCharacterID: 29,
|
||
characterGeneration: 1,
|
||
townSceneReadyCharacterID: 29,
|
||
}
|
||
enableTownMoveSkillProjection(t, service, repositories, "29")
|
||
return service, session, repositories
|
||
}
|
||
|
||
func enableTownMoveSkillProjection(
|
||
t *testing.T,
|
||
service *Service,
|
||
repositories dnfrepo.Group,
|
||
characterID string,
|
||
) []byte {
|
||
t.Helper()
|
||
character, found, err := repositories.Character.Load(context.Background(), characterID)
|
||
if err != nil || !found {
|
||
t.Fatalf("load town skill character found=%t err=%v", found, err)
|
||
}
|
||
job, validJob := characterJobByte(character)
|
||
if !validJob {
|
||
job = 0
|
||
character.Job = "0"
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
jobListPath := fmt.Sprintf("job%d.lst", job)
|
||
skillPath := fmt.Sprintf("job%d/active.skl", job)
|
||
catalog, err := buildSkillCatalogFromSource(context.Background(), initialEquipmentMemSource{
|
||
"skill/skilllist.lst": fmt.Sprintf("%d `%s`\n", job, jobListPath),
|
||
"skill/" + jobListPath: "46 `" + skillPath + "`\n",
|
||
"skill/" + skillPath: "[skill type]\n`active`\n",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
record := dnfrepo.SkillRecord{
|
||
CharacterID: characterID,
|
||
Skills: map[int64]dnfrepo.SkillState{
|
||
46: {Level: 1, Enabled: true},
|
||
},
|
||
Layouts: map[int]dnfrepo.SkillLayout{
|
||
currentSkillInfoTreeIndex: {0: 46},
|
||
1: {0: 46},
|
||
},
|
||
Points: dnfrepo.SkillPointState{
|
||
TotalSP: 20,
|
||
RemainingSP: 20,
|
||
SyncedLevel: 1,
|
||
},
|
||
}
|
||
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.initialSkillsByJob = map[byte][]initialSkillEntry{
|
||
job: {{SkillID: 46, Level: 1}},
|
||
}
|
||
service.initialSPTable = map[int]int{1: 20}
|
||
service.skillCatalog = catalog
|
||
body, _, err := buildCurrentSceneSkillInfoBody(record, record.Layouts, currentSkillInfoTreeIndex)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return body
|
||
}
|
||
|
||
func townMoveSkillProjectionBody(
|
||
t *testing.T,
|
||
repositories dnfrepo.Group,
|
||
characterID string,
|
||
) []byte {
|
||
t.Helper()
|
||
record, found, err := repositories.Skill.Load(context.Background(), characterID)
|
||
if err != nil || !found {
|
||
t.Fatalf("load town skill projection found=%t err=%v", found, err)
|
||
}
|
||
body, _, err := buildCurrentSceneSkillInfoBody(record, record.Layouts, currentSkillInfoTreeIndex)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return body
|
||
}
|
||
|
||
func buildTownMoveRequest(townID, areaID byte, x, y int16, direction byte) []byte {
|
||
body := make([]byte, currentTownSetUserAreaBodySize)
|
||
body[0] = townID
|
||
body[1] = areaID
|
||
binary.LittleEndian.PutUint16(body[2:4], uint16(x))
|
||
binary.LittleEndian.PutUint16(body[4:6], uint16(y))
|
||
body[6] = direction
|
||
return body
|
||
}
|
||
|
||
func buildTownTeleportArrayRequest(townID, areaID byte, x, y int16, direction byte) []byte {
|
||
body := buildTownMoveRequest(townID, areaID, x, y, direction)
|
||
binary.LittleEndian.PutUint16(body[7:9], 38)
|
||
binary.LittleEndian.PutUint16(body[9:11], 1)
|
||
body[15] = 5
|
||
return body
|
||
}
|
||
|
||
func buildTownMapStationRequest(townID, areaID byte, x, y int16, direction byte) []byte {
|
||
body := buildTownMoveRequest(townID, areaID, x, y, direction)
|
||
binary.LittleEndian.PutUint16(body[7:9], uint16(townID))
|
||
binary.LittleEndian.PutUint16(body[9:11], 1)
|
||
return body
|
||
}
|
||
|
||
func buildTownNPCEnterRequest(townID, areaID byte, x, y int16, direction byte, sourceTownID uint16, targetNPCID uint32) []byte {
|
||
body := buildTownMoveRequest(townID, areaID, x, y, direction)
|
||
binary.LittleEndian.PutUint16(body[7:9], sourceTownID)
|
||
binary.LittleEndian.PutUint16(body[9:11], 1)
|
||
binary.LittleEndian.PutUint32(body[11:15], targetNPCID)
|
||
return body
|
||
}
|
||
|
||
func buildTownSeriaRoomPortalRequest(townID, areaID byte, x, y int16, direction byte, sourceTownID uint16) []byte {
|
||
body := buildTownMoveRequest(townID, areaID, x, y, direction)
|
||
binary.LittleEndian.PutUint16(body[7:9], sourceTownID)
|
||
return body
|
||
}
|
||
|
||
func buildTownCrossTownPortalRequest(townID, areaID byte, x, y int16, direction byte, sourceTownID uint16) []byte {
|
||
return buildTownSeriaRoomPortalRequest(townID, areaID, x, y, direction, sourceTownID)
|
||
}
|
||
|
||
func TestCharacterListLoginSeriaOp36ExitReturnsToPreservedTown(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
character.Stats["town_id"] = 39
|
||
character.Stats["area_id"] = 0
|
||
character.Stats["pos_x"] = 856
|
||
character.Stats["pos_y"] = 341
|
||
character.Stats["direction"] = 8
|
||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, _, _, _, err := service.currentCharacterListLoginTransition(
|
||
context.Background(), session, 29, character,
|
||
); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
session.townMu.Lock()
|
||
setCurrentTownPositionSceneLocked(session, 29, newCharacterInitialTownID, newCharacterInitialAreaID)
|
||
session.townSceneReadyCharacterID = 29
|
||
session.townMu.Unlock()
|
||
|
||
connection := session.conn.(*bufferConn)
|
||
connection.write.Reset()
|
||
// Some bootstrap paths leave the Seria room with a literal op36 for the
|
||
// shared Elvengard map (town 38 area 0) instead of op1425.
|
||
if err := service.handleTownSetUserArea(
|
||
session,
|
||
buildTownMoveRequest(newCharacterInitialTownID, 0, 706, 182, newCharacterInitialDirection),
|
||
); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 39 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 856 || stored.Stats["pos_y"] != 341 || stored.Stats["direction"] != 8 {
|
||
t.Fatalf("op36 Seria exit did not restore preserved town found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if session.townPositionSnapshot.TownID != 39 || session.townPositionSnapshot.AreaID != 0 ||
|
||
session.townPrevVillageSnapshot.PositionValid || session.townTransportEnterSelectPending {
|
||
t.Fatalf("op36 Seria exit session state=%+v prev=%+v transport_pending=%t",
|
||
session.townPositionSnapshot,
|
||
session.townPrevVillageSnapshot,
|
||
session.townTransportEnterSelectPending,
|
||
)
|
||
}
|
||
wantScene, err := buildCurrentSceneTransitionBody(39, 0, []currentSceneTransitionRow{{
|
||
ObjectOrResourceKey: currentSceneActorObjectKey(29),
|
||
Value1: 856,
|
||
Value2: 341,
|
||
Value3: 8,
|
||
Value4: newCharacterInitialAreaState,
|
||
}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var gotScene []byte
|
||
for _, packet := range splitAllCurrentGameServerUpperPackets(t, connection.write.Bytes()) {
|
||
if packet.Header.MsgID == currentSceneTransitionMsgID {
|
||
gotScene = packet.Body
|
||
}
|
||
}
|
||
if !bytes.Equal(gotScene, wantScene) {
|
||
t.Fatalf("op36 Seria exit scene=%x, want=%x", gotScene, wantScene)
|
||
}
|
||
}
|
||
|
||
func TestHandleTownSetUserAreaSeriaOp36ExitWithoutPrevVillageStaysLiteral(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
// The default test character sits inside the Seria room with no preserved
|
||
// prev-village origin; the literal Elvengard exit must keep working.
|
||
connection := session.conn.(*bufferConn)
|
||
connection.write.Reset()
|
||
if err := service.handleTownSetUserArea(
|
||
session,
|
||
buildTownMoveRequest(newCharacterInitialTownID, 0, 706, 182, newCharacterInitialDirection),
|
||
); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 0 ||
|
||
stored.Stats["pos_x"] != 706 || stored.Stats["pos_y"] != 182 {
|
||
t.Fatalf("literal op36 Seria exit was redirected found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
if session.townPositionSnapshot.TownID != 38 || session.townPositionSnapshot.AreaID != 0 {
|
||
t.Fatalf("literal op36 Seria exit session snapshot=%+v", session.townPositionSnapshot)
|
||
}
|
||
}
|
||
|
||
// Migrated from the 最新inal line together with the PVP channel work: the PVP
|
||
// channel login must route through the temporary PVF gate (town
|
||
// currentPVPTownID / area currentPVPAreaID, Fair_PVP/Gate_Hendon.map) and must
|
||
// leave the character's ordinary town location untouched both in the returned
|
||
// record and in the repository, on the first login and on channel reconnect.
|
||
func TestPVPChannelLoginUsesTemporaryPVFGateWithoutMutatingOrdinaryLocation(t *testing.T) {
|
||
service, session, repositories := newTownMoveTest(t)
|
||
session.channel = channelcatalog.Channel{ServerID: 1, ID: 501, Type: 24, Group: "pvp", Name: "ch.501", Port: 10501}
|
||
session.residentChannel = session.channel
|
||
character, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
updated, townID, areaID, row, mapPath, err := service.currentCharacterListLoginTransition(
|
||
context.Background(), session, 29, character,
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if townID != byte(currentPVPTownID) || areaID != byte(currentPVPAreaID) ||
|
||
row.Value1 == 0 || row.Value2 == 0 || mapPath != "Fair_PVP/Gate_Hendon.map" {
|
||
t.Fatalf("PVP login transition town=%d area=%d row=%+v map=%q", townID, areaID, row, mapPath)
|
||
}
|
||
if updated.Stats["town_id"] != 38 || updated.Stats["area_id"] != 1 ||
|
||
updated.Stats["pos_x"] != 450 || updated.Stats["pos_y"] != 234 {
|
||
t.Fatalf("PVP transition rewrote returned ordinary location stats=%+v", updated.Stats)
|
||
}
|
||
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
||
if err != nil || !found || stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 ||
|
||
stored.Stats["pos_x"] != 450 || stored.Stats["pos_y"] != 234 {
|
||
t.Fatalf("PVP transition mutated ordinary location found=%t err=%v stats=%+v", found, err, stored.Stats)
|
||
}
|
||
_, reconnectTownID, reconnectAreaID, reconnectRow, reconnectMapPath, err := service.currentChannelReconnectTownTransition(
|
||
context.Background(), session, 29, character,
|
||
)
|
||
if err != nil || reconnectTownID != byte(currentPVPTownID) || reconnectAreaID != byte(currentPVPAreaID) ||
|
||
reconnectRow != row || reconnectMapPath != mapPath {
|
||
t.Fatalf("PVP reconnect transition town=%d area=%d row=%+v map=%q err=%v", reconnectTownID, reconnectAreaID, reconnectRow, reconnectMapPath, err)
|
||
}
|
||
}
|
||
|
||
// TestCurrentTownSetUserAreaSeriaRoomTeleportArrayIsNotTheExitDoor separates
|
||
// the two op36 bodies a player can send from inside a Seria room. The exit
|
||
// door always names town 38 no matter which town the room was entered from,
|
||
// so the server rewrites it to the remembered origin; the teleport array names
|
||
// a destination the player picked, and 埃尔文防线 is town 38 - classifying that
|
||
// one as the door sent the player back to their previous town instead.
|
||
//
|
||
// Every body below is a live capture from runtime/logs/packet_log.txt
|
||
// (2026-09-10). The array bodies all carry opaqueA = the town the array stands
|
||
// in, opaqueB = 1 and a nonzero tail; everything else carries tail 0.
|
||
func TestCurrentTownSetUserAreaSeriaRoomTeleportArrayIsNotTheExitDoor(t *testing.T) {
|
||
for name, probe := range map[string]struct {
|
||
body string
|
||
array bool
|
||
}{
|
||
"array_to_elvengard_town38": {"2600d402d10005260001000000000006", true},
|
||
"array_to_town40": {"28012904f80005260001000000000005", true},
|
||
"array_to_town39": {"2702a801060105260001000000000005", true},
|
||
"array_to_town39_again": {"27021702e10005260001000000000005", true},
|
||
"seria_room_exit_door": {"2600f2059f0005080000000000000000", false},
|
||
"cross_town_portal": {"080049003b0105260000000000000000", false},
|
||
"seria_room_entry_portal": {"2601b101370105160001000000000000", false},
|
||
"ordinary_route_click": {"16034d00000105160003000000000000", false},
|
||
} {
|
||
raw, err := hex.DecodeString(probe.body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
request, err := parseCurrentTownSetUserAreaRequest(raw)
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", name, err)
|
||
}
|
||
if got := request.LooksLikeTeleportArraySelection(); got != probe.array {
|
||
t.Fatalf("%s: teleport array=%t, want %t (request=%+v)", name, got, probe.array, request)
|
||
}
|
||
// The redirect only ever fires on the door shape, and only that shape
|
||
// may keep reaching it.
|
||
redirectCandidate := request.TownID == byte(newCharacterInitialTownID) &&
|
||
request.AreaID != byte(newCharacterInitialAreaID) &&
|
||
!request.LooksLikeTeleportArraySelection()
|
||
wantCandidate := name == "seria_room_exit_door"
|
||
if redirectCandidate != wantCandidate {
|
||
t.Fatalf("%s: seria-exit redirect candidate=%t, want %t", name, redirectCandidate, wantCandidate)
|
||
}
|
||
}
|
||
}
|