服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
610 lines
24 KiB
Go
610 lines
24 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"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"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
func TestParseCurrentTownSetUserPositionExactCurrentWriterBody(t *testing.T) {
|
|
// Exact pre-TerSafe live sample captured from the current EXE:
|
|
// x=828, y=211, movement=8, scaled opaque value=100.
|
|
body := []byte{0x3c, 0x03, 0xd3, 0x00, 0x08, 0x64, 0x00}
|
|
request, err := parseCurrentTownSetUserPositionRequest(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if request.PositionX != 828 || request.PositionY != 211 ||
|
|
request.MovementCode != 8 || request.OpaqueScaledU16 != 100 {
|
|
t.Fatalf("request=%+v", request)
|
|
}
|
|
for _, malformed := range [][]byte{
|
|
make([]byte, currentTownSetUserPositionBodySize-1),
|
|
make([]byte, currentTownSetUserPositionBodySize+1),
|
|
} {
|
|
if _, err := parseCurrentTownSetUserPositionRequest(malformed); err == nil {
|
|
t.Fatalf("malformed body length %d unexpectedly accepted", len(malformed))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTownMovementBroadcastPreservesReportedRateAndSceneIsolation pins the
|
|
// reason a boosted town move speed is visible to the mover but not to peers:
|
|
// op35's final u16 is the mover's own town movement rate (actor+0x748 scaled
|
|
// by 100) and the op16 broadcast has to carry it verbatim. Sending the fixed
|
|
// 100 initialization value instead animates the remote actor at ordinary
|
|
// speed on every other screen while the mover still moves correctly locally.
|
|
func TestTownMovementBroadcastPreservesReportedRateAndSceneIsolation(t *testing.T) {
|
|
service, session, _ := newTownMoveTest(t)
|
|
service.options.gameUpperBodyCodec = gameUpperBodyCodecPlain
|
|
service.onlinePlayers = newOnlinePlayerManager()
|
|
session.townSceneReadyCharacterID = session.selectedCharacterID
|
|
setCurrentTownPositionSceneLocked(session, session.selectedCharacterID, 38, 0)
|
|
peerConn, privateConn := &bufferConn{}, &bufferConn{}
|
|
channelID := townPresenceChannelID(session)
|
|
for _, player := range []*onlinePlayerInfo{
|
|
{CharacterID: session.selectedCharacterID, ChannelID: channelID, TownID: 38, AreaID: 0, Session: session},
|
|
{CharacterID: 30, ChannelID: channelID, TownID: 38, AreaID: 0, Session: &gameSession{conn: peerConn, channel: session.channel}},
|
|
{CharacterID: 31, ChannelID: channelID, TownID: 38, AreaID: 0, InstanceKey: 99, Session: &gameSession{conn: privateConn, channel: session.channel}},
|
|
} {
|
|
service.onlinePlayers.EnterArea(player)
|
|
}
|
|
for _, rate := range []uint16{100, 170, 185, 987, 0, 100} {
|
|
peerConn.write.Reset()
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(640, 244, 6, rate)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitCurrentGameServerUpperPacketAuto(t, peerConn.write.Bytes())
|
|
if packet.Header.MsgID != 22 || packet.Header.Classification != 0 || len(packet.Body) != 9 || len(rest) != 0 {
|
|
t.Fatalf("movement notification header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(packet.Body[7:9]); got != rate {
|
|
t.Fatalf("peer rate=%d, reported=%d", got, rate)
|
|
}
|
|
if binary.LittleEndian.Uint16(packet.Body[0:2]) != currentSceneActorObjectKey(session.selectedCharacterID) ||
|
|
binary.LittleEndian.Uint16(packet.Body[2:4]) != 640 || packet.Body[6] != 6 {
|
|
t.Fatalf("movement actor/position changed: %x", packet.Body)
|
|
}
|
|
}
|
|
if privateConn.write.Len() != 0 {
|
|
t.Fatal("movement leaked into a private town instance")
|
|
}
|
|
peerConn.write.Reset()
|
|
session.townSceneReadyCharacterID = 0
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(640, 244, 6, 185)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if peerConn.write.Len() != 0 {
|
|
t.Fatal("unready town scene broadcast movement")
|
|
}
|
|
}
|
|
|
|
func TestHandleTownSetUserPositionCapturesSessionSnapshotWithoutPersistenceOrAck(t *testing.T) {
|
|
service, session, repositories := newTownMoveTest(t)
|
|
if err := service.handleTownSetUserArea(session, buildTownMoveRequest(38, 0, 900, 250, 5)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := session.conn.(*bufferConn)
|
|
connection.write.Reset()
|
|
|
|
body := buildTownPositionRequest(640, 244, 6, 987)
|
|
if err := service.handleGameCommand(
|
|
session,
|
|
byte(dnfenum.GameCmdCommand),
|
|
uint16(dnfenum.CmdPacketSetUserPosition),
|
|
body,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("passive op35 emitted an ACK=%x", connection.write.Bytes())
|
|
}
|
|
session.townMu.Lock()
|
|
snapshot := session.townPositionSnapshot
|
|
session.townMu.Unlock()
|
|
if !snapshot.PositionValid || snapshot.CharacterID != 29 ||
|
|
snapshot.TownID != 38 || snapshot.AreaID != 0 ||
|
|
snapshot.PositionX != 640 || snapshot.PositionY != 244 ||
|
|
snapshot.MovementCode != 6 || snapshot.OpaqueScaledU16 != 987 {
|
|
t.Fatalf("snapshot=%+v", snapshot)
|
|
}
|
|
stored, found, err := repositories.Character.Load(context.Background(), "29")
|
|
if err != nil || !found {
|
|
t.Fatalf("load character found=%t err=%v", found, err)
|
|
}
|
|
if stored.Stats["pos_x"] != 900 || stored.Stats["pos_y"] != 250 {
|
|
t.Fatalf("op35 unexpectedly persisted high-frequency position: stats=%+v", stored.Stats)
|
|
}
|
|
}
|
|
|
|
func TestTownSetUserPositionCompletesPVFMeetingNPCChannelWithoutClientOp33(t *testing.T) {
|
|
service, session, repositories := newTownMoveTest(t)
|
|
service.options.gameUpperBodyCodec = gameUpperBodyCodecPlain
|
|
service.questCatalog = questTriggerBridgeCatalog(t)
|
|
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
|
CharacterID: "29",
|
|
States: map[int64]dnfrepo.QuestState{
|
|
3622: {Status: "active", ProgressValue: 1012},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
townTable, err := dnftown.Load(context.Background(), townMoveTestSource{
|
|
"town/town.lst": "22 `Jelva.twn`",
|
|
"town/Jelva.twn": "[area]\n3 `Town/Jelva/3_Jelva_AG.map` `[normal]`\n[/area]\n[name]\n`Jelva`",
|
|
"map/Town/Jelva/3_Jelva_AG.map": "[map name]\n`Jelva`\n[NPC]\n126 `[left]` 494 223 0\n[/NPC]\n",
|
|
}, dnftown.Options{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.townCatalog = townTable
|
|
session.townMu.Lock()
|
|
session.townSceneReadyCharacterID = session.selectedCharacterID
|
|
setCurrentTownPositionSceneLocked(session, session.selectedCharacterID, 22, 3)
|
|
session.townMu.Unlock()
|
|
|
|
connection := session.conn.(*bufferConn)
|
|
connection.write.Reset()
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(494, 223, 5, 100)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stored, found, err := repositories.Quest.Load(context.Background(), "29")
|
|
if err != nil || !found {
|
|
t.Fatalf("load quest found=%t err=%v", found, err)
|
|
}
|
|
if got := stored.States[3622].ProgressValue; got != 500 {
|
|
t.Fatalf("meeting progress=%d want item-channel remainder 500", got)
|
|
}
|
|
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if ack.Header.MsgID != uint16(dnfenum.CmdPacketSetQuestTrigger) ||
|
|
binary.LittleEndian.Uint16(ack.Body[1:3]) != 3622 ||
|
|
binary.LittleEndian.Uint32(ack.Body[3:7]) != 500 {
|
|
t.Fatalf("meeting ACK header=%+v body=%x", ack.Header, ack.Body)
|
|
}
|
|
active, trailing := splitGameServerUpperPacket(t, rest)
|
|
if active.Header.MsgID != currentActiveQuestSnapshotMsgID || len(trailing) != 0 {
|
|
t.Fatalf("meeting active snapshot header=%+v trailing=%x", active.Header, trailing)
|
|
}
|
|
|
|
connection.write.Reset()
|
|
if err := service.handleCurrentSetQuestTrigger(session, []byte{0x21, 0x00, 0x26, 0x0e, 0x20, 0x00}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("client meeting replay wrote packets=%x", connection.write.Bytes())
|
|
}
|
|
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(800, 223, 5, 100)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.AccountInventory.Save(context.Background(), dnfrepo.AccountInventoryRecord{
|
|
AccountID: "dnf:1",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:358": {ItemID: 3037, Count: 500},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(494, 223, 5, 100)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stored, found, err = repositories.Quest.Load(context.Background(), "29")
|
|
if err != nil || !found || stored.States[3622].ProgressValue != 0 {
|
|
t.Fatalf("re-enter terminal quest found=%t err=%v state=%+v", found, err, stored.States[3622])
|
|
}
|
|
terminal, terminalTrailing := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if terminal.Header.MsgID != uint16(dnfenum.CmdPacketSetQuestTrigger) ||
|
|
binary.LittleEndian.Uint32(terminal.Body[3:7]) != 0 || len(terminalTrailing) != 0 {
|
|
t.Fatalf("terminal meeting ACK header=%+v body=%x trailing=%x", terminal.Header, terminal.Body, terminalTrailing)
|
|
}
|
|
}
|
|
|
|
func TestTownSetUserPositionRequiresExactTownSceneOwnerAndClass(t *testing.T) {
|
|
body := buildTownPositionRequest(640, 244, 6, 987)
|
|
|
|
t.Run("town scene not ready", func(t *testing.T) {
|
|
service, session, _ := newTownMoveTest(t)
|
|
session.townMu.Lock()
|
|
session.townSceneReadyCharacterID = 0
|
|
setCurrentTownPositionSceneLocked(session, 29, 38, 1)
|
|
session.townMu.Unlock()
|
|
if err := service.handleTownSetUserPosition(session, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.townPositionSnapshot.PositionValid {
|
|
t.Fatalf("unready scene captured snapshot=%+v", session.townPositionSnapshot)
|
|
}
|
|
})
|
|
|
|
t.Run("wrong command class", func(t *testing.T) {
|
|
service, session, _ := newTownMoveTest(t)
|
|
service.markCurrentTownSceneReady(session)
|
|
session.townMu.Lock()
|
|
setCurrentTownPositionSceneLocked(session, 29, 38, 1)
|
|
session.townMu.Unlock()
|
|
packet, err := dnfproto.BuildChannelPacket(
|
|
uint16(dnfenum.CmdPacketSetUserPosition),
|
|
body,
|
|
1,
|
|
2,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, packet); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.townPositionSnapshot.PositionValid {
|
|
t.Fatalf("wrong-class op35 captured snapshot=%+v", session.townPositionSnapshot)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDungeonTownTransitionUsesMatchingCurrentOp35Snapshot(t *testing.T) {
|
|
service, session, _ := newBackToVillageRuntimeAtOrigin(t, 640, 240)
|
|
|
|
transition, err := service.prepareCurrentDungeonTownTransitionForSession(
|
|
context.Background(),
|
|
session,
|
|
99,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []byte{7, 3, 1, 0, 99, 0, 0x80, 0x02, 0xf0, 0x00, 5, 3}
|
|
if transition.PositionX != 640 || transition.PositionY != 240 ||
|
|
transition.PositionSource != "current_exe_op35_runtime_origin_snapshot" ||
|
|
!bytes.Equal(transition.Body, want) {
|
|
t.Fatalf("transition=%+v body=%x want=%x", transition, transition.Body, want)
|
|
}
|
|
}
|
|
|
|
func TestDungeonTownTransitionRejectsStaleSceneSnapshot(t *testing.T) {
|
|
service, session, _ := newBackToVillageRuntime(t)
|
|
session.townMu.Lock()
|
|
setCurrentTownPositionSceneLocked(session, 99, 7, 2)
|
|
session.townPositionSnapshot.PositionX = 640
|
|
session.townPositionSnapshot.PositionY = 240
|
|
session.townPositionSnapshot.PositionValid = true
|
|
session.townMu.Unlock()
|
|
|
|
transition, err := service.prepareCurrentDungeonTownTransitionForSession(
|
|
context.Background(),
|
|
session,
|
|
99,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if transition.PositionX != 474 || transition.PositionY != 234 ||
|
|
transition.PositionSource != "current_exe_op35_runtime_origin_snapshot" ||
|
|
!bytes.Equal(transition.Body, wantDungeonTownTransitionBody()) {
|
|
t.Fatalf("stale snapshot changed transition=%+v body=%x", transition, transition.Body)
|
|
}
|
|
}
|
|
|
|
func TestDungeonTownTransitionRejectsMismatchedBoundSelectorOrigin(t *testing.T) {
|
|
service, session, _ := newBackToVillageRuntime(t)
|
|
session.dungeon.runtime = nil
|
|
session.townMu.Lock()
|
|
setCurrentTownPositionSceneLocked(session, 99, 7, 2)
|
|
session.townPositionSnapshot.PositionX = 640
|
|
session.townPositionSnapshot.PositionY = 240
|
|
session.townPositionSnapshot.PositionValid = true
|
|
session.townMu.Unlock()
|
|
service.markCurrentTownSceneReady(session)
|
|
if _, ok := bindCurrentTownSelectorOrigin(session); !ok {
|
|
t.Fatal("failed to bind mismatched selector origin")
|
|
}
|
|
if _, err := service.prepareCurrentDungeonTownTransitionForSession(context.Background(), session, 99); err == nil {
|
|
t.Fatal("mismatched bound selector origin unexpectedly fell back to repository position")
|
|
}
|
|
}
|
|
|
|
func TestSelectorPageBackToVillageUsesLatestCurrentOp35Snapshot(t *testing.T) {
|
|
service, session, _ := newBackToVillageRuntime(t)
|
|
session.dungeon.runtime = nil
|
|
session.townMu.Lock()
|
|
setCurrentTownPositionSceneLocked(session, 99, 7, 3)
|
|
session.townMu.Unlock()
|
|
service.markCurrentTownSceneReady(session)
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(640, 240, 6, 987)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := session.conn.(*bufferConn)
|
|
connection.write.Reset()
|
|
if err := service.sendEnterSelectDungeonState(session, "test_selector_origin", false, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !session.townSelectorOriginBound ||
|
|
session.townSelectorOriginSnapshot.PositionX != 640 ||
|
|
session.townSelectorOriginSnapshot.PositionY != 240 {
|
|
t.Fatalf("selector origin=%+v bound=%t", session.townSelectorOriginSnapshot, session.townSelectorOriginBound)
|
|
}
|
|
connection.write.Reset()
|
|
|
|
// Even if a later report arrives while the selector is visible, op132 must
|
|
// return to the origin frozen by the successful op15/op27 context.
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(700, 260, 7, 100)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := service.handleDungeonBackToVillage(session, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The selector return writes the same documented prefix as every other dungeon
|
|
// return before op24 installs the destination scene: op22 (controlled actor
|
|
// position), op23 (active area) and the completed-quest bitmap. Those packets
|
|
// use the 16-byte header, so step over them by message id instead of assuming
|
|
// the transition is the first packet of the stream.
|
|
frozenOrigin := []byte{0x80, 0x02, 0xf0, 0x00}
|
|
laterReport := []byte{0xbc, 0x02, 0x04, 0x01}
|
|
data := connection.write.Bytes()
|
|
for _, wantMsgID := range []uint16{
|
|
currentTownUserPositionNotificationMsgID,
|
|
currentTownUserAreaNotificationMsgID,
|
|
currentClearQuestListMsgID,
|
|
} {
|
|
prefix, prefixRest := splitCurrentGameServerUpperPacketAuto(t, data)
|
|
if prefix.Header.Classification != 0 || prefix.Header.MsgID != wantMsgID {
|
|
t.Fatalf("selector prefix header=%+v want msg=%d", prefix.Header, wantMsgID)
|
|
}
|
|
// Every prefixed packet is built from the same frozen origin, so the later
|
|
// op35 report must not have leaked into any of them either.
|
|
if wantMsgID != currentClearQuestListMsgID && !bytes.Contains(prefix.Body, frozenOrigin) {
|
|
t.Fatalf("selector prefix msg=%d body=%x lacks the frozen origin", wantMsgID, prefix.Body)
|
|
}
|
|
if bytes.Contains(prefix.Body, laterReport) {
|
|
t.Fatalf("selector prefix msg=%d body=%x leaked the later op35 report", wantMsgID, prefix.Body)
|
|
}
|
|
data = prefixRest
|
|
}
|
|
packet, rest := splitCurrentGameServerUpperPacketAuto(t, data)
|
|
want := []byte{7, 3, 1, 0, 99, 0, 0x80, 0x02, 0xf0, 0x00, 5, 3}
|
|
if packet.Header.MsgID != currentSceneTransitionMsgID ||
|
|
packet.Header.Classification != 0 || !bytes.Equal(packet.Body, want) || len(rest) != 0 {
|
|
t.Fatalf("selector op132 header=%+v body=%x rest=%x want=%x", packet.Header, packet.Body, rest, want)
|
|
}
|
|
if session.townSelectorOriginBound || session.townSelectorOriginSnapshot.CharacterID != 0 {
|
|
t.Fatalf("successful selector return retained origin=%+v bound=%t", session.townSelectorOriginSnapshot, session.townSelectorOriginBound)
|
|
}
|
|
}
|
|
|
|
func TestActiveDungeonPreservesFrozenSelectorOriginAndCapturesDungeonOp35(t *testing.T) {
|
|
service, session, runtime := newBackToVillageRuntimeAtOrigin(t, 640, 240)
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(700, 260, 7, 100)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !runtime.actorPositionValid || runtime.actorPositionX != 700 || runtime.actorPositionY != 260 {
|
|
t.Fatalf("dungeon actor position valid=%t x=%d y=%d", runtime.actorPositionValid, runtime.actorPositionX, runtime.actorPositionY)
|
|
}
|
|
transition, err := service.prepareCurrentDungeonTownTransitionForSession(context.Background(), session, 99)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if transition.PositionX != 640 || transition.PositionY != 240 ||
|
|
transition.PositionSource != "current_exe_op35_runtime_origin_snapshot" {
|
|
t.Fatalf("active return transition=%+v", transition)
|
|
}
|
|
session.townMu.Lock()
|
|
live := session.townPositionSnapshot
|
|
origin := session.townSelectorOriginSnapshot
|
|
originBound := session.townSelectorOriginBound
|
|
session.townMu.Unlock()
|
|
if live.PositionX != 640 || live.PositionY != 240 ||
|
|
!originBound || origin.PositionX != 640 || origin.PositionY != 240 {
|
|
t.Fatalf("dungeon op35 changed snapshots live=%+v origin=%+v bound=%t", live, origin, originBound)
|
|
}
|
|
}
|
|
|
|
func TestTownSetUserPositionAfterFullDungeonReturnDoesNotRebuildAgain(t *testing.T) {
|
|
service, session, runtime := newBackToVillageRuntime(t)
|
|
if err := service.handleDungeonBackToVillage(session, nil); err != nil {
|
|
t.Fatalf("full return: %v", err)
|
|
}
|
|
if session.dungeon.runtime != nil || !runtime.townReturnPending || !runtime.townReturnOp24Sent {
|
|
t.Fatalf("return did not retire old state owner=%p runtime=%+v", session.dungeon.runtime, runtime)
|
|
}
|
|
connection := session.conn.(*bufferConn)
|
|
connection.write.Reset()
|
|
|
|
if err := service.handleTownSetUserPosition(session, buildTownPositionRequest(85, 331, 5, 100)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("post-return op35 rebuilt town route=%x", connection.write.Bytes())
|
|
}
|
|
if session.dungeon.runtime != nil {
|
|
t.Fatalf("town op35 retained pending dungeon runtime=%+v", session.dungeon.runtime)
|
|
}
|
|
if status := runtime.Session.Snapshot().Run.Status; status != worldmap.DungeonRunAbandoned {
|
|
t.Fatalf("town op35 committed status=%s want abandoned", status)
|
|
}
|
|
session.townMu.Lock()
|
|
snapshot := session.townPositionSnapshot
|
|
readyCharacterID := session.townSceneReadyCharacterID
|
|
session.townMu.Unlock()
|
|
if readyCharacterID != session.selectedCharacterID ||
|
|
!snapshot.PositionValid ||
|
|
snapshot.CharacterID != session.selectedCharacterID ||
|
|
snapshot.TownID != runtime.townReturnTransition.TownID ||
|
|
snapshot.AreaID != runtime.townReturnTransition.AreaID ||
|
|
snapshot.PositionX != 85 ||
|
|
snapshot.PositionY != 331 ||
|
|
snapshot.MovementCode != 5 ||
|
|
snapshot.OpaqueScaledU16 != 100 {
|
|
t.Fatalf("ready=%d snapshot=%+v transition=%+v", readyCharacterID, snapshot, runtime.townReturnTransition)
|
|
}
|
|
}
|
|
|
|
func TestTownSetUserPositionResumesInterruptedFullReturnSuffix(t *testing.T) {
|
|
service, session, runtime := newBackToVillageRuntime(t)
|
|
wantErr := errors.New("full return op559 write failed")
|
|
// The interrupted return writes op42, op376, mode0, mode1, op205, then the op22
|
|
// and op23 location pair, then op24, and only then op559. Fail the ninth write
|
|
// so the injected error still lands on the buff registration, which is the one
|
|
// packet the town-side op35 is expected to resume.
|
|
failing := &failNthDungeonWriteConn{failAt: 9, err: wantErr}
|
|
session.conn = failing
|
|
if err := service.handleDungeonBackToVillage(session, nil); !errors.Is(err, wantErr) {
|
|
t.Fatalf("full return prefix failure=%v want=%v", err, wantErr)
|
|
}
|
|
if got := townPostTransitionPacketSignatures(t, failing.write.Bytes()); !reflect.DeepEqual(
|
|
got,
|
|
[]string{"op42", "op376", "mode0", "mode1", "op205", "op24"},
|
|
) {
|
|
t.Fatalf("full return prefix=%v", got)
|
|
}
|
|
if session.dungeon.runtime != runtime ||
|
|
runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunAbandoned ||
|
|
!runtime.townReturnPending ||
|
|
!runtime.townReturnOp24Sent ||
|
|
session.initialTownRouteStage != currentInitialTownRouteTransitionSent {
|
|
t.Fatalf(
|
|
"detached return owner=%p run=%s pending=%t stage=%d",
|
|
session.dungeon.runtime,
|
|
runtime.Session.Snapshot().Run.Status,
|
|
runtime.townReturnPending,
|
|
session.initialTownRouteStage,
|
|
)
|
|
}
|
|
|
|
resume := &bufferConn{}
|
|
session.conn = resume
|
|
if err := service.handleTownSetUserPosition(
|
|
session,
|
|
buildTownPositionRequest(85, 331, 5, 100),
|
|
); err != nil {
|
|
t.Fatalf("op35 resume confirmed return suffix: %v", err)
|
|
}
|
|
// The resume replays only what the interrupted return still owed: the BUFF
|
|
// registration (op559) and, right behind it, the forced-exit weakness cap. The
|
|
// give-up applied currentWeaknessForcedReturnLevel to the character, so the
|
|
// health cap has to be republished before the client keeps the town actor it
|
|
// just rebuilt -- and the aborted return never got to publish it.
|
|
if got := townPostTransitionPacketSignatures(t, resume.write.Bytes()); !reflect.DeepEqual(
|
|
got,
|
|
[]string{"op559", "op33weakness"},
|
|
) {
|
|
t.Fatalf("op35 resumed suffix=%v want=[op559 op33weakness]", got)
|
|
}
|
|
if session.dungeon.runtime != nil || session.initialTownRouteStage != currentInitialTownRoutePlayerStateSent {
|
|
t.Fatalf(
|
|
"op35 resume owner=%p stage=%d",
|
|
session.dungeon.runtime,
|
|
session.initialTownRouteStage,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestDungeonSelectReturnWithoutFrozenOp35OriginFailsClosed(t *testing.T) {
|
|
service, session, _ := newBackToVillageRuntime(t)
|
|
session.dungeon.mu.Lock()
|
|
session.dungeon.runtime = nil
|
|
session.dungeon.mu.Unlock()
|
|
clearCurrentTownSelectorOrigin(session)
|
|
|
|
if _, err := service.prepareCurrentDungeonTownTransitionForSession(
|
|
context.Background(),
|
|
session,
|
|
99,
|
|
); !errors.Is(err, errCurrentDungeonTownReturnOriginUnavailable) {
|
|
t.Fatalf("selector return error=%v, want unavailable frozen origin", err)
|
|
}
|
|
}
|
|
|
|
func TestFreezeAntonRaidDungeonReturnOriginUsesSessionScopedWaitingArea(t *testing.T) {
|
|
service, session, runtime := newBackToVillageRuntime(t)
|
|
session.channel.Type = dnfenum.RaidChannelType
|
|
runtime.Dungeon.ID = 5314
|
|
bindDungeonSelectorOriginForTestAt(
|
|
t,
|
|
service,
|
|
session,
|
|
byte(currentAntonRaidTownID),
|
|
currentAntonRaidWaitingAreaID,
|
|
987,
|
|
380,
|
|
)
|
|
|
|
if err := freezeCurrentDungeonTownReturnOrigin(session, runtime); err != nil {
|
|
t.Fatalf("freeze session-scoped Anton origin: %v", err)
|
|
}
|
|
if origin := runtime.townReturnOrigin; !origin.PositionValid ||
|
|
origin.CharacterID != session.selectedCharacterID ||
|
|
origin.TownID != byte(currentAntonRaidTownID) ||
|
|
origin.AreaID != currentAntonRaidWaitingAreaID ||
|
|
origin.PositionX != 987 || origin.PositionY != 380 {
|
|
t.Fatalf("Anton raid return origin=%+v", origin)
|
|
}
|
|
if err := validateCurrentDungeonRuntimeTownReturnOrigin(session, runtime); err != nil {
|
|
t.Fatalf("commit validation rejected session-scoped Anton origin: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestActiveAntonRaidDungeonTownTransitionUsesFrozenSessionScopedOrigin(t *testing.T) {
|
|
service, session, runtime := newBackToVillageRuntime(t)
|
|
session.channel.Type = dnfenum.RaidChannelType
|
|
runtime.Dungeon.ID = 5314
|
|
runtime.Request.DungeonID = 5314
|
|
runtime.townReturnOrigin = currentTownPositionSnapshot{
|
|
CharacterID: session.selectedCharacterID,
|
|
TownID: byte(currentAntonRaidTownID),
|
|
AreaID: currentAntonRaidWaitingAreaID,
|
|
PositionX: 987,
|
|
PositionY: 380,
|
|
PositionValid: true,
|
|
}
|
|
|
|
transition, err := service.prepareCurrentDungeonTownTransitionForSession(
|
|
context.Background(),
|
|
session,
|
|
session.selectedCharacterID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("prepare active Anton return: %v", err)
|
|
}
|
|
want := []byte{
|
|
byte(currentAntonRaidTownID), currentAntonRaidWaitingAreaID, 1, 0,
|
|
99, 0,
|
|
0xdb, 0x03,
|
|
0x7c, 0x01,
|
|
5, 3,
|
|
}
|
|
if transition.TownID != byte(currentAntonRaidTownID) ||
|
|
transition.AreaID != currentAntonRaidWaitingAreaID ||
|
|
transition.PositionX != 987 || transition.PositionY != 380 ||
|
|
transition.PositionSource != "current_exe_op35_session_scoped_anton_runtime_origin_snapshot" ||
|
|
!bytes.Equal(transition.Body, want) {
|
|
t.Fatalf("active Anton transition=%+v body=%x want=%x", transition, transition.Body, want)
|
|
}
|
|
}
|
|
|
|
func TestDungeonRuntimeTownReturnOriginStillRejectsOrdinaryLocationMismatch(t *testing.T) {
|
|
_, session, runtime := newBackToVillageRuntime(t)
|
|
runtime.townReturnOrigin.TownID++
|
|
|
|
if err := validateCurrentDungeonRuntimeTownReturnOrigin(session, runtime); !errors.Is(err, errCurrentDungeonTownReturnOriginUnavailable) {
|
|
t.Fatalf("ordinary mismatched origin error=%v, want town-return origin unavailable", err)
|
|
}
|
|
}
|
|
|
|
func buildTownPositionRequest(x, y uint16, movementCode byte, opaqueScaledU16 uint16) []byte {
|
|
body := make([]byte, currentTownSetUserPositionBodySize)
|
|
binary.LittleEndian.PutUint16(body[0:2], x)
|
|
binary.LittleEndian.PutUint16(body[2:4], y)
|
|
body[4] = movementCode
|
|
binary.LittleEndian.PutUint16(body[5:7], opaqueScaledU16)
|
|
return body
|
|
}
|