Files
90CNServer/server/internal/services/dnfbridge/wedding_room_exit_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

416 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"reflect"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnftown "longheng.io/server/internal/modules/dnf/town"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func newWeddingRoomExitTest(t *testing.T) (*Service, *gameSession, dnfrepo.Group) {
t.Helper()
s, session, repos := newTownMoveTest(t)
table, err := dnftown.Load(context.Background(), townMoveTestSource{
"town/town.lst": "2 `old.twn` 38 `seria.twn` 42 `underfoot.twn` 101 `couple.twn`",
"town/old.twn": "[area]\n5 `Town/Underfoot/Underfoot_DT_Or_Icewall.map` `[normal]`\n[/area]",
"town/seria.twn": "[area]\n0 `Cataclysm/Town/Elvengard/new_Elvengard.map` `[normal]`\n[/area]\n[area]\n1 `Cataclysm/Town/Elvengard/new_seria_room.map` `[gate]` 450 234\n[/area]",
"town/underfoot.twn": "[area]\n0 `Cataclysm/Town/Underfoot/Underfoot_north.map` `[normal]`\n[/area]",
"town/couple.twn": "[exclude user pretown info]\n[area]\n0 `CoupleRoom/CoupleRoom.map` `[couple room]` 474 234\n[/area]",
"map/Cataclysm/Town/Elvengard/new_seria_room.map": "[town movable area]\n858 183 30 100 38 0 49 224 34 52 101 0\n[/town movable area]",
"map/Cataclysm/Town/Elvengard/new_Elvengard.map": "[town movable area]\n10 196 90 142 38 2 622 196 0 0 38 1 571 129 50 50 38 1\n[/town movable area]",
}, dnftown.Options{})
if err != nil {
t.Fatal(err)
}
s.townCatalog = table
c := weddingExitLoad(t, repos)
c.Stats["wedding_state"], c.Stats["wedding_id"], c.Stats["wedding_partner_id"] = 4, 1900574, 30
weddingExitSave(t, repos, c)
peer := dnfrepo.CloneCharacter(c)
peer.CharacterID, peer.AccountID = "30", "dnf:2"
peer.Stats["wedding_partner_id"] = 29
weddingExitSave(t, repos, peer)
return s, session, repos
}
func weddingExitLoad(t *testing.T, repos dnfrepo.Group) dnfrepo.CharacterRecord {
t.Helper()
c, found, err := repos.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load character: found=%v err=%v", found, err)
}
return c
}
func weddingExitSave(t *testing.T, repos dnfrepo.Group, c dnfrepo.CharacterRecord) {
t.Helper()
if err := repos.Character.Save(context.Background(), c); err != nil {
t.Fatal(err)
}
}
func weddingNativeExitPacket(t *testing.T) []byte {
t.Helper()
// Captured 2026-09-12 02:36:51: old Underfoot 2/5, 300/200, source 101.
body, err := hex.DecodeString("02052c01c80005650000000000000000")
if err != nil {
t.Fatal(err)
}
return body
}
func TestWeddingRoomExitNativeRoundTrip(t *testing.T) {
for _, remembered := range []bool{false, true} {
t.Run(fmt.Sprintf("native_remembered_entry_%v", remembered), func(t *testing.T) {
s, session, repos := newWeddingRoomExitTest(t)
c := weddingExitLoad(t, repos)
c.Stats["town_id"], c.Stats["area_id"], c.Stats["pos_x"], c.Stats["pos_y"] = 42, 0, 497, 289
weddingExitSave(t, repos, c)
entry := buildTownCrossTownPortalRequest(101, 0, 900, 250, 5, 38)
entry[9] = 1 // Live 02:57 entry carries source area 1 in opaque B.
for _, body := range [][]byte{
buildTownSeriaRoomPortalRequest(38, 1, 433, 311, 5, 42),
entry,
} {
if err := s.handleTownSetUserArea(session, body); err != nil {
t.Fatal(err)
}
}
if got := weddingExitLoad(t, repos); got.Stats["town_id"] != 101 || got.Stats[currentTownPrevVillageTownStat] != 42 {
t.Fatalf("entry/previous town: %+v", got.Stats)
}
body := weddingNativeExitPacket(t)
if remembered {
body[0], body[1] = 38, 1
}
session.conn.(*bufferConn).write.Reset()
if err := s.handleTownSetUserArea(session, body); err != nil {
t.Fatal(err)
}
_, rest := splitTownAreaTransitionSequence(t, session.conn.(*bufferConn).write.Bytes(), 29, 38, 1, 100, 250, 5, 3)
if len(rest) != 0 {
t.Fatalf("extra exit packets: %x", rest)
}
c = weddingExitLoad(t, repos)
if c.Stats["town_id"] != 38 || c.Stats["area_id"] != 1 || c.Stats["pos_x"] != 100 || c.Stats["pos_y"] != 250 ||
c.Stats[currentTownPrevVillageTownStat] != 42 || c.Stats[currentTownPrevVillagePosXStat] != 497 ||
session.townPrevVillageSnapshot.TownID != 42 || c.Stats["wedding_state"] != 4 {
t.Fatalf("exit location/previous town/wedding state: %+v %+v", c.Stats, session.townPrevVillageSnapshot)
}
// A replay after leaving 101 must not teleport to legacy Underfoot.
session.conn.(*bufferConn).write.Reset()
if err := s.handleTownSetUserArea(session, weddingNativeExitPacket(t)); err != nil {
t.Fatal(err)
}
if session.conn.(*bufferConn).write.Len() != 0 {
t.Fatal("replayed exit emitted transition")
}
if err := s.handleCurrentPrevVillage(session, nil); err != nil {
t.Fatal(err)
}
c = weddingExitLoad(t, repos)
if c.Stats["town_id"] != 42 || c.Stats["area_id"] != 0 || c.Stats["pos_x"] != 497 || c.Stats["pos_y"] != 289 {
t.Fatalf("Seria door lost public origin: %+v", c.Stats)
}
})
}
}
func TestWeddingRoomExitSendFailureRollsBack(t *testing.T) {
for failAt := 1; failAt <= 3; failAt++ {
t.Run(fmt.Sprint(failAt), func(t *testing.T) {
s, session, repos := newWeddingRoomExitTest(t)
c := weddingExitLoad(t, repos)
c.Stats["town_id"], c.Stats["area_id"], c.Stats["pos_x"], c.Stats["pos_y"] = 101, 0, 1000, 250
weddingExitSave(t, repos, c)
want := errors.New("wedding exit transport failure")
session.conn = &failNthDungeonWriteConn{failAt: failAt, err: want}
if err := s.handleTownSetUserArea(session, weddingNativeExitPacket(t)); !errors.Is(err, want) {
t.Fatalf("error=%v", err)
}
if got := weddingExitLoad(t, repos); !reflect.DeepEqual(got.Stats, c.Stats) {
t.Fatalf("failed exit persisted location: %+v", got.Stats)
}
if session.townPrevVillageSnapshot.PositionValid {
t.Fatal("failed exit committed return snapshot")
}
session.conn = &bufferConn{}
if err := s.handleTownSetUserArea(session, weddingNativeExitPacket(t)); err != nil {
t.Fatal(err)
}
if got := weddingExitLoad(t, repos); got.Stats["town_id"] != 38 {
t.Fatalf("retry: %+v", got.Stats)
}
})
}
}
// 工作区与上游在这一点上的锚点约定不同,这里断言**不变量**而不是上游的坐标:
//
// - 上游的 currentWeddingPublicReturnOrigin 读的是当前城镇那扇"零尺寸反向传送门"
// (width==0 && height==0),并另外在登录路径上擦洗被污染的 prev_village。
// - 工作区的 town.seriaRoomPortalTrigger 只认正尺寸传送门(零尺寸行是纯触发器,
// 不构成"门"),且回城点修复收敛在 handleCurrentPrevVillage 一处。
//
// 两边都必须保证的不变量是同一个:**污染的(101)回城点绝不能被当成目的地**,
// 而且 op1425 必须给出一个真正位于城镇居民区(38/0)、不在任何传送门触发矩形里的
// 落点。下面按这个不变量断言。
func TestWeddingRoomExitRelogAndStaleOrigin(t *testing.T) {
for _, currentTown := range []int64{38, 101} {
for _, oldOrigin := range []byte{42, 101} {
t.Run(fmt.Sprintf("current_%d_origin_%d", currentTown, oldOrigin), func(t *testing.T) {
s, session, repos := newWeddingRoomExitTest(t)
c := weddingExitLoad(t, repos)
c.Stats["town_id"], c.Stats["area_id"] = currentTown, 0
if currentTown == 38 {
c.Stats["area_id"] = 1
}
currentTownPrevVillageApplyOriginStats(c.Stats, currentTownPositionSnapshot{CharacterID: 29, TownID: oldOrigin, AreaID: 0, PositionX: 497, PositionY: 289, MovementCode: 5, PositionValid: true})
weddingExitSave(t, repos, c)
// 登录仍然进赛丽亚房间,且被污染的 101 回城点不能留到会话里。
_, town, area, row, _, err := s.currentCharacterListLoginTransition(context.Background(), session, 29, c)
if err != nil {
t.Fatal(err)
}
if town != 38 || area != 1 || row.Value1 != 450 || row.Value2 != 234 {
t.Fatalf("login transition: town=%d area=%d row=%+v", town, area, row)
}
if err := s.handleCurrentPrevVillage(session, nil); err != nil {
t.Fatal(err)
}
got := weddingExitLoad(t, repos)
if got.Stats[currentTownPrevVillageTownStat] == 101 {
t.Fatalf("wedding room survived as the previous town: %+v", got.Stats)
}
if oldOrigin == 42 {
// 记忆里是一个正常的城镇 -> 就回那个城镇,不要被婚房场景干扰。
if got.Stats["town_id"] != 42 || got.Stats["area_id"] != 0 ||
got.Stats["pos_x"] != 497 || got.Stats["pos_y"] != 289 {
t.Fatalf("legitimate previous town lost: %+v", got.Stats)
}
return
}
// 记忆本身就是婚房 -> 必须换成城镇居民区里的安全落点。
if got.Stats["town_id"] != 38 || got.Stats["area_id"] != 0 {
t.Fatalf("door landed outside the public town: %+v", got.Stats)
}
// 落点必须是安全站立点:不落在 38/0 的任何传送门触发矩形里。
area38, ok := s.townArea(38, 0)
if !ok {
t.Fatal("town 38 area 0 missing from the fixture")
}
for _, portal := range area38.MovableAreas {
if portal.Width <= 0 || portal.Height <= 0 {
continue
}
if got.Stats["pos_x"] >= portal.X && got.Stats["pos_x"] <= portal.X+portal.Width &&
got.Stats["pos_y"] >= portal.Y && got.Stats["pos_y"] <= portal.Y+portal.Height {
t.Fatalf("landing retriggers portal %+v: %+v", portal, got.Stats)
}
}
})
}
}
}
// 会话里残留的婚房快照同样不能被当成目的地。
func TestWeddingRoomExitStaleOnlineOrigin(t *testing.T) {
s, session, repos := newWeddingRoomExitTest(t)
c := weddingExitLoad(t, repos)
stale := currentTownPositionSnapshot{CharacterID: 29, TownID: 101, AreaID: 0, PositionX: 871, PositionY: 250, MovementCode: 6, PositionValid: true}
currentTownPrevVillageApplyOriginStats(c.Stats, stale)
weddingExitSave(t, repos, c)
session.townPrevVillageSnapshot = stale
if err := s.handleCurrentPrevVillage(session, nil); err != nil {
t.Fatal(err)
}
got := weddingExitLoad(t, repos)
if got.Stats["town_id"] != 38 || got.Stats["area_id"] != 0 {
t.Fatalf("stale online door left the public town: %+v", got.Stats)
}
if got.Stats[currentTownPrevVillageTownStat] == 101 {
t.Fatalf("stale wedding-room origin was committed: %+v", got.Stats)
}
if got.Stats["pos_x"] == 871 && got.Stats["pos_y"] == 250 {
t.Fatalf("stale wedding-room position was reused verbatim: %+v", got.Stats)
}
}
func TestWeddingRoomExitRoutingBoundaries(t *testing.T) {
for _, variant := range []string{"normal_underfoot", "teleport_from_room", "source_mismatch", "scene_mismatch", "missing_gate", "unmarried_entry"} {
t.Run(variant, func(t *testing.T) {
s, session, repos := newWeddingRoomExitTest(t)
c := weddingExitLoad(t, repos)
c.Stats["town_id"], c.Stats["area_id"] = 101, 0
body := weddingNativeExitPacket(t)
wantTown, wantArea, wantX, blocked := int64(101), int64(0), int64(450), true
switch variant {
case "normal_underfoot":
c.Stats["town_id"] = 42
body[7] = 42
wantTown, wantArea, wantX, blocked = 2, 5, 300, false
case "teleport_from_room":
body = buildTownTeleportArrayRequest(42, 0, 497, 289, 5)
wantTown, wantArea, wantX, blocked = 42, 0, 497, false
case "source_mismatch":
body[7] = 38
case "scene_mismatch":
// 场景快照对不上时**放行客户端原请求**,不再中止移动。
// 教训:早先这里硬拒,一旦装载期推导不出落点,玩家会被永久困在
// 婚房里出不去(2026-10-04 真机)。困住玩家远比落点不精确糟糕。
session.townPositionSnapshot = currentTownPositionSnapshot{CharacterID: 29, TownID: 42, AreaID: 0}
wantTown, wantArea, wantX, blocked = 2, 5, 300, false
case "missing_gate":
// 目录里根本没有 38/1(推导不出落点)→ 同样放行客户端原请求。
s.townCatalog, _ = dnftown.Load(context.Background(), townMoveTestSource{"town/town.lst": "2 `old.twn`", "town/old.twn": "[area]\n5 `old.map` `[normal]`\n[/area]"}, dnftown.Options{})
wantTown, wantArea, wantX, blocked = 2, 5, 300, false
case "unmarried_entry":
c.Stats["town_id"], c.Stats["area_id"], c.Stats["wedding_state"] = 38, 1, 0
body = buildTownCrossTownPortalRequest(101, 0, 900, 250, 5, 38)
wantTown, wantArea = 38, 1
}
weddingExitSave(t, repos, c)
if err := s.handleTownSetUserArea(session, body); err != nil {
t.Fatal(err)
}
got := weddingExitLoad(t, repos)
if got.Stats["town_id"] != wantTown || got.Stats["area_id"] != wantArea || got.Stats["pos_x"] != wantX {
t.Fatalf("routing: %+v", got.Stats)
}
if blocked && session.conn.(*bufferConn).write.Len() != 0 {
t.Fatal("blocked route emitted packets")
}
})
}
}
// 落点由装载期从 PVF 推导(town/wedding_room_return.go),其等式本身在
// town/wedding_room_return_test.go 里断言。这里证明 bridge 取到的就是目录里那个
// 点,且确实被送到了 38/1 的婚房门旁(而不是城镇居民区)。
func TestWeddingRoomExitDoorCoordinatesFollowPVF(t *testing.T) {
s, session, repos := newWeddingRoomExitTest(t)
seria, found := s.townArea(38, 1)
if !found || seria.WeddingRoomReturn == nil {
t.Fatalf("fixture couple-room doorway missing: found=%v area=%+v", found, seria)
}
if got := *seria.WeddingRoomReturn; got.X != 100 || got.Y != 250 {
t.Fatalf("couple-room doorway = %+v, want (100,250)", got)
}
c := weddingExitLoad(t, repos)
c.Stats["town_id"], c.Stats["area_id"] = 101, 0
weddingExitSave(t, repos, c)
session.townMu.Lock()
session.townPositionSnapshot = currentTownPositionSnapshot{
CharacterID: session.selectedCharacterID, TownID: 101, AreaID: 0, PositionValid: true,
}
session.townMu.Unlock()
normalized, exit, err := s.normalizeCurrentWeddingRoomExit(session, c, currentTownSetUserAreaRequest{
TownID: 1, AreaID: 1, PositionX: 300, PositionY: 200, Direction: 5,
OpaqueU16A: 101, OpaqueU16B: 0, OpaqueU32: 0, OpaqueTailU8: 0,
})
if err != nil || !exit {
t.Fatalf("normalize: exit=%v err=%v", exit, err)
}
if normalized.TownID != 38 || normalized.AreaID != 1 ||
normalized.PositionX != 100 || normalized.PositionY != 250 ||
normalized.Direction != newCharacterInitialDirection {
t.Fatalf("normalized request = %+v", normalized)
}
}
// 地图里没有这道门时**必须放行**,而不是拒绝 —— 绝不把玩家困在婚房里。
// 落点优先级:WeddingRoomReturn → SeriaRoomReturn → 地图自己的 [gate]。
func TestWeddingRoomExitWithoutDoorFallsBackInsteadOfBlocking(t *testing.T) {
s, session, repos := newWeddingRoomExitTest(t)
table, err := dnftown.Load(context.Background(), townMoveTestSource{
"town/town.lst": "38 `seria.twn`",
"town/seria.twn": "[area]\n1 `Cataclysm/Town/Elvengard/new_seria_room.map` `[gate]` 450 234\n[/area]",
"map/Cataclysm/Town/Elvengard/new_seria_room.map": "[town movable area]\n858 183 30 100 38 0\n[/town movable area]",
}, dnftown.Options{})
if err != nil {
t.Fatal(err)
}
s.townCatalog = table
c := weddingExitLoad(t, repos)
c.Stats["town_id"], c.Stats["area_id"] = 101, 0
weddingExitSave(t, repos, c)
normalized, exit, err := s.normalizeCurrentWeddingRoomExit(session, c, currentTownSetUserAreaRequest{
TownID: 1, AreaID: 1, PositionX: 300, PositionY: 200, Direction: 5,
OpaqueU16A: 101, OpaqueU16B: 0, OpaqueU32: 0, OpaqueTailU8: 0,
})
if err != nil {
t.Fatalf("缺少门时不应报错(会把玩家困住): %v", err)
}
if !exit {
t.Fatalf("应当按回退链归一化到地图 [gate]: %+v", normalized)
}
// 回退链第三级:地图自己的 [gate] 450 234(该图没有 101 传送门,
// 所以 WeddingRoomReturn/SeriaRoomReturn 都是 nil)。
if normalized.TownID != 38 || normalized.AreaID != 1 || normalized.PositionX != 450 || normalized.PositionY != 234 {
t.Fatalf("回退落点不正确: %+v", normalized)
}
}
func TestWeddingRoomExitRealPVF(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: path})
if err != nil {
t.Fatal(err)
}
table, err := dnftown.Load(context.Background(), archive, dnftown.Options{})
if err != nil {
t.Fatal(err)
}
s, session, repos := newWeddingRoomExitTest(t)
s.townCatalog = table
seria, found := table.FindArea(38, 1)
if !found || seria.WeddingRoomReturn == nil {
t.Fatalf("real PVF couple-room doorway missing: found=%v area=%+v", found, seria)
}
arrival := *seria.WeddingRoomReturn
// 落点必须在这张图的每一处传送门触发矩形之外,否则客户端一落地就被送回婚房。
for _, portal := range seria.MovableAreas {
if portal.Width <= 0 || portal.Height <= 0 {
continue
}
if arrival.X >= portal.X && arrival.X <= portal.X+portal.Width &&
arrival.Y >= portal.Y && arrival.Y <= portal.Y+portal.Height {
t.Fatalf("arrival would retrigger portal: %+v", portal)
}
}
c := weddingExitLoad(t, repos)
c.Stats["town_id"], c.Stats["area_id"] = 101, 0
weddingExitSave(t, repos, c)
if err := s.handleTownSetUserArea(session, weddingNativeExitPacket(t)); err != nil {
t.Fatal(err)
}
splitTownAreaTransitionSequence(t, session.conn.(*bufferConn).write.Bytes(),
29, 38, 1, uint16(arrival.X), uint16(arrival.Y), 5, 3)
if err := s.handleCurrentPrevVillage(session, nil); err != nil {
t.Fatal(err)
}
// op1425 的恢复锚点必须落在城镇居民区(38/0),不是赛丽亚房间——否则"回到上个
// 城镇"会把玩家塞回房间里。
origin, ok := s.currentWeddingPublicReturnOrigin(29)
if !ok || origin.TownID != 38 || origin.AreaID != 0 || !origin.PositionValid {
t.Fatalf("real PVF public arrival: %+v found=%v", origin, ok)
}
// 目录不能对外暴露可变的落点别名。
area, _ := table.FindArea(38, 0)
area.SeriaRoomReturn.X = 999
if again, ok := s.currentWeddingPublicReturnOrigin(29); !ok || again.PositionX == 999 {
t.Fatalf("mutable catalog alias: %+v", again)
}
}