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

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

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
}