DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,604 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"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"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
// TestAuxiliarySameCharacterSessionLeavesTheResidentParty keeps a second socket
|
||||
// for a character the resident session still holds from taking the resident's
|
||||
// town presence over -- and pins the operator rule for that same event
|
||||
// (2026-09-27): 「被顶掉以后 客户端的连接就断开了 此时也按退队处理」.
|
||||
//
|
||||
// The party is deliberately NOT handed to the newcomer any more. It used to be
|
||||
// inherited here, which made a takeover look like the same party continuing on
|
||||
// another socket; now the resident is out of it, the newcomer starts partyless,
|
||||
// and the survivors are told by the leave itself.
|
||||
func TestAuxiliarySameCharacterSessionLeavesTheResidentParty(t *testing.T) {
|
||||
service := &Service{
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
gameSessions: make(map[uint16]*gameSession),
|
||||
}
|
||||
partyState := alignedcmd.PartyState{
|
||||
PartyID: 7,
|
||||
UserID: 7,
|
||||
Members: []alignedcmd.PartyMemberState{
|
||||
{UserID: 7, UserState: 1, HPPercent: 100, MPPercent: 100},
|
||||
{UserID: 1, UserState: 1, HPPercent: 100, MPPercent: 100},
|
||||
},
|
||||
}
|
||||
resident := &gameSession{selectedCharacterID: 7, party: partySessionState{state: cloneRuntimePartyState(partyState)}}
|
||||
resident.spendTime.characterID = 7
|
||||
resident.spendTime.anchor = time.Unix(1, 0)
|
||||
resident.spendTime.generation = 3
|
||||
resident.spendTime.catalog = ¤tSpendTimeRuntimeCatalog{}
|
||||
auxiliary := &gameSession{}
|
||||
service.bindGameSessionCharacter(resident, 7)
|
||||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 7, TownID: 38, AreaID: 0, Session: resident})
|
||||
|
||||
service.bindGameSessionCharacter(auxiliary, 7)
|
||||
if got, ok := service.onlineGameSession(7); !ok || got != resident {
|
||||
t.Fatalf("auxiliary bind replaced resident session: got=%p ok=%t want=%p", got, ok, resident)
|
||||
}
|
||||
if got := runtimePartyStateSnapshot(auxiliary); got.PartyID != 0 {
|
||||
t.Fatalf("auxiliary session inherited the resident party: %+v", got)
|
||||
}
|
||||
if got := runtimePartyStateSnapshot(resident); got.PartyID != 0 {
|
||||
t.Fatalf("resident session kept a party the replacement left: %+v", got)
|
||||
}
|
||||
if resident.spendTime.characterID != 7 || resident.spendTime.anchor.IsZero() ||
|
||||
resident.spendTime.generation != 3 || resident.spendTime.catalog == nil {
|
||||
t.Fatalf("auxiliary bind stopped resident clock char=%d anchor=%s generation=%d catalog_nil=%t",
|
||||
resident.spendTime.characterID,
|
||||
resident.spendTime.anchor,
|
||||
resident.spendTime.generation,
|
||||
resident.spendTime.catalog == nil)
|
||||
}
|
||||
|
||||
service.cleanupOnlinePlayer(auxiliary)
|
||||
service.unbindGameSession(auxiliary)
|
||||
if got := service.onlinePlayers.SessionForCharacter(7); got != resident {
|
||||
t.Fatalf("auxiliary disconnect evicted resident town presence: got=%p want=%p", got, resident)
|
||||
}
|
||||
if got := runtimePartyStateSnapshot(resident); got.PartyID != 0 {
|
||||
t.Fatalf("auxiliary disconnect re-attached a party to the resident: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelReconnectBindStopsResidentSpendTimeBeforeReplacementBootstrap(t *testing.T) {
|
||||
service := &Service{
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
gameSessions: make(map[uint16]*gameSession),
|
||||
}
|
||||
resident := &gameSession{selectedCharacterID: 7}
|
||||
resident.spendTime.characterID = 7
|
||||
resident.spendTime.accountID = "account-1"
|
||||
resident.spendTime.anchor = time.Unix(1, 0)
|
||||
resident.spendTime.generation = 3
|
||||
resident.spendTime.catalog = ¤tSpendTimeRuntimeCatalog{}
|
||||
service.bindGameSessionCharacter(resident, 7)
|
||||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 7, TownID: 38, AreaID: 0, Session: resident})
|
||||
|
||||
reconnect := &gameSession{channelReconnect: true}
|
||||
service.bindGameSessionCharacter(reconnect, 7)
|
||||
if resident.spendTime.characterID != 0 || !resident.spendTime.anchor.IsZero() ||
|
||||
resident.spendTime.catalog != nil {
|
||||
t.Fatalf("reconnect bind did not stop resident clock char=%d anchor=%s catalog_nil=%t",
|
||||
resident.spendTime.characterID,
|
||||
resident.spendTime.anchor,
|
||||
resident.spendTime.catalog == nil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelReconnectBindArmsOp108ReplayFromTeamToOrdinaryChannel(t *testing.T) {
|
||||
for _, channelType := range []uint8{dnfenum.RaidChannelType, dnfenum.HiddenRaidType, 42} {
|
||||
t.Run(fmt.Sprintf("team_type_%d", channelType), func(t *testing.T) {
|
||||
service := &Service{
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
gameSessions: make(map[uint16]*gameSession),
|
||||
}
|
||||
resident := &gameSession{
|
||||
selectedCharacterID: 7,
|
||||
channel: channelcatalog.Channel{ID: 261, Type: channelType},
|
||||
}
|
||||
service.bindGameSessionCharacter(resident, 7)
|
||||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||||
CharacterID: 7,
|
||||
TownID: 38,
|
||||
AreaID: 0,
|
||||
Session: resident,
|
||||
})
|
||||
|
||||
reconnect := &gameSession{
|
||||
channelReconnect: true,
|
||||
channel: channelcatalog.Channel{ID: 19, Type: 11},
|
||||
}
|
||||
service.bindGameSessionCharacter(reconnect, 7)
|
||||
reconnect.spendTime.mu.Lock()
|
||||
armed := reconnect.spendTime.eventInfoReplayAfterTeamReturn
|
||||
reconnect.spendTime.mu.Unlock()
|
||||
if !armed {
|
||||
t.Fatalf("team type %d -> ordinary channel did not arm op108 replay", channelType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelReconnectBindArmsOp108ReplayAfterTeamResidentAlreadyLeftTown(t *testing.T) {
|
||||
service := &Service{
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
gameSessions: make(map[uint16]*gameSession),
|
||||
clientAccounts: newClientAccountRegistry(),
|
||||
}
|
||||
if err := service.RegisterClientAccount(77, "dnf:1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
team := &gameSession{
|
||||
clientPID: 77,
|
||||
channel: channelcatalog.Channel{ID: 28, Type: dnfenum.HiddenRaidType},
|
||||
}
|
||||
service.bindGameSessionCharacter(team, 7)
|
||||
if resident := service.onlinePlayers.SessionForCharacter(7); resident != nil {
|
||||
t.Fatalf("test precondition has an unexpected online resident: %p", resident)
|
||||
}
|
||||
|
||||
reconnect := &gameSession{
|
||||
clientPID: 77,
|
||||
channelReconnect: true,
|
||||
channel: channelcatalog.Channel{ID: 19, Type: 11},
|
||||
}
|
||||
service.bindGameSessionCharacter(reconnect, 7)
|
||||
reconnect.spendTime.mu.Lock()
|
||||
armed := reconnect.spendTime.eventInfoReplayAfterTeamReturn
|
||||
reconnect.spendTime.mu.Unlock()
|
||||
if !armed {
|
||||
t.Fatal("team-to-ordinary reconnect did not arm op108 after the old town resident disappeared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelReconnectBindDoesNotArmOp108ReplayOutsideTeamToOrdinaryTransition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
residentType uint8
|
||||
targetType uint8
|
||||
}{
|
||||
{name: "ordinary_to_ordinary", residentType: 11, targetType: 11},
|
||||
{name: "raid_to_raid", residentType: dnfenum.RaidChannelType, targetType: dnfenum.HiddenRaidType},
|
||||
{name: "ordinary_to_raid", residentType: 11, targetType: dnfenum.RaidChannelType},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service := &Service{
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
gameSessions: make(map[uint16]*gameSession),
|
||||
}
|
||||
resident := &gameSession{
|
||||
selectedCharacterID: 7,
|
||||
channel: channelcatalog.Channel{ID: 261, Type: test.residentType},
|
||||
}
|
||||
service.bindGameSessionCharacter(resident, 7)
|
||||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||||
CharacterID: 7,
|
||||
TownID: 38,
|
||||
AreaID: 0,
|
||||
Session: resident,
|
||||
})
|
||||
|
||||
reconnect := &gameSession{
|
||||
channelReconnect: true,
|
||||
channel: channelcatalog.Channel{ID: 19, Type: test.targetType},
|
||||
}
|
||||
service.bindGameSessionCharacter(reconnect, 7)
|
||||
reconnect.spendTime.mu.Lock()
|
||||
armed := reconnect.spendTime.eventInfoReplayAfterTeamReturn
|
||||
reconnect.spendTime.mu.Unlock()
|
||||
if armed {
|
||||
t.Fatalf("transition %d -> %d unexpectedly armed op108 replay", test.residentType, test.targetType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTownRemoteActorOwnerContextUsesCommittedTownChannel(t *testing.T) {
|
||||
target := &gameSession{townActorOwnerChannel: 42}
|
||||
for _, characterID := range []uint16{0, 1, 42, 256, 298, 0xffff} {
|
||||
owner := currentTownRemoteActorOwnerContext(target, characterID)
|
||||
if owner != target.townActorOwnerChannel {
|
||||
t.Fatalf(
|
||||
"character %d projected owner %d, want committed town channel %d",
|
||||
characterID,
|
||||
owner,
|
||||
target.townActorOwnerChannel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTownRemoteActorOwnerContextUsesConnectionChannelWhenLocalContextIsZero(t *testing.T) {
|
||||
target := &gameSession{
|
||||
channel: channelcatalog.Channel{ID: 121},
|
||||
townActorOwnerChannel: currentSceneObjectContext,
|
||||
}
|
||||
if owner := currentTownRemoteActorOwnerContext(target, 1); owner != 121 {
|
||||
t.Fatalf("remote actor owner=%d want connected channel 121", owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastTownPlayerEnterCreatesRepositoryBackedRemoteActorsBeforePeerState(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
for _, character := range []dnfrepo.CharacterRecord{
|
||||
{
|
||||
CharacterID: "17",
|
||||
AccountID: "account-existing",
|
||||
Name: "existing",
|
||||
Job: "2",
|
||||
Level: 35,
|
||||
Stats: map[string]int64{"grow_type": 1},
|
||||
},
|
||||
{
|
||||
CharacterID: "23",
|
||||
AccountID: "account-newcomer",
|
||||
Name: "newcomer",
|
||||
Job: "5",
|
||||
Level: 48,
|
||||
Stats: map[string]int64{"grow_type": 2},
|
||||
},
|
||||
} {
|
||||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
existingConn := &bufferConn{}
|
||||
newcomerConn := &bufferConn{}
|
||||
existingSession := &gameSession{
|
||||
conn: existingConn,
|
||||
connID: "existing",
|
||||
selectedCharacterID: 17,
|
||||
accountID: "account-existing",
|
||||
townActorOwnerChannel: 7,
|
||||
}
|
||||
newcomerSession := &gameSession{
|
||||
conn: newcomerConn,
|
||||
connID: "newcomer",
|
||||
selectedCharacterID: 23,
|
||||
accountID: "account-newcomer",
|
||||
townActorOwnerChannel: 8,
|
||||
}
|
||||
existing := onlinePlayerInfo{
|
||||
CharacterID: 17,
|
||||
AccountID: "account-existing",
|
||||
Name: "existing",
|
||||
Job: 2,
|
||||
GrowType: 1,
|
||||
Level: 35,
|
||||
TownID: 1,
|
||||
AreaID: 2,
|
||||
PositionX: 120,
|
||||
PositionY: 240,
|
||||
Direction: 1,
|
||||
AreaState: 3,
|
||||
Session: existingSession,
|
||||
}
|
||||
newcomer := &onlinePlayerInfo{
|
||||
CharacterID: 23,
|
||||
AccountID: "account-newcomer",
|
||||
Name: "newcomer",
|
||||
Job: 5,
|
||||
GrowType: 2,
|
||||
Level: 48,
|
||||
TownID: 1,
|
||||
AreaID: 2,
|
||||
PositionX: 360,
|
||||
PositionY: 480,
|
||||
Direction: 0,
|
||||
AreaState: 4,
|
||||
Session: newcomerSession,
|
||||
}
|
||||
service := &Service{
|
||||
options: options{
|
||||
gameUpperHeader: gameUpperHeaderServer16,
|
||||
gameUpperBodyCodec: gameUpperBodyCodecPlain,
|
||||
},
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) {
|
||||
return repositories, true
|
||||
},
|
||||
}
|
||||
service.bindGameSessionCharacter(existingSession, 17)
|
||||
service.bindGameSessionCharacter(newcomerSession, 23)
|
||||
|
||||
service.broadcastTownPlayerEnter(newcomer, []onlinePlayerInfo{existing})
|
||||
|
||||
assertTownRemoteActorSequence(
|
||||
t,
|
||||
existingConn.write.Bytes(),
|
||||
existingSession,
|
||||
*newcomer,
|
||||
)
|
||||
assertTownRemoteActorSequence(
|
||||
t,
|
||||
newcomerConn.write.Bytes(),
|
||||
newcomerSession,
|
||||
existing,
|
||||
)
|
||||
}
|
||||
|
||||
func TestPublishCurrentTownPresenceRegistersColdLoginAndSynchronizesExistingPeer(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
characters := []dnfrepo.CharacterRecord{
|
||||
{CharacterID: "17", AccountID: "account-existing", Name: "existing", Job: "2", Level: 35, Stats: map[string]int64{"grow_type": 1}},
|
||||
{CharacterID: "23", AccountID: "account-newcomer", Name: "newcomer", Job: "5", Level: 48, Stats: map[string]int64{"grow_type": 2}},
|
||||
}
|
||||
for _, character := range characters {
|
||||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
existingConn := &bufferConn{}
|
||||
newcomerConn := &bufferConn{}
|
||||
existingSession := &gameSession{conn: existingConn, connID: "existing", selectedCharacterID: 17, accountID: "account-existing", townActorOwnerChannel: 7}
|
||||
newcomerSession := &gameSession{conn: newcomerConn, connID: "newcomer", selectedCharacterID: 23, accountID: "account-newcomer", townActorOwnerChannel: 7}
|
||||
service := &Service{
|
||||
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
gameSessions: make(map[uint16]*gameSession),
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) {
|
||||
return repositories, true
|
||||
},
|
||||
}
|
||||
service.bindGameSessionCharacter(existingSession, 17)
|
||||
service.bindGameSessionCharacter(newcomerSession, 23)
|
||||
existingRow := currentSceneTransitionRow{ObjectOrResourceKey: currentSceneActorObjectKey(17), Value1: 120, Value2: 240, Value3: 1, Value4: 3}
|
||||
newcomerRow := currentSceneTransitionRow{ObjectOrResourceKey: currentSceneActorObjectKey(23), Value1: 360, Value2: 480, Value3: 0, Value4: 4}
|
||||
|
||||
service.publishCurrentTownPresence(existingSession, characters[0], 1, 2, existingRow, 0, "Town/Public/public.map", "test_first_cold_login")
|
||||
if presence, ok := service.onlinePlayers.PlayerForCharacter(17); !ok || presence.Session != existingSession {
|
||||
t.Fatalf("first cold-login presence missing: found=%t presence=%+v", ok, presence)
|
||||
}
|
||||
service.publishCurrentTownPresence(newcomerSession, characters[1], 1, 2, newcomerRow, 0, "Town/Public/public.map", "test_second_cold_login")
|
||||
if !service.onlinePlayers.PeerInSameArea(17, 23) {
|
||||
t.Fatal("cold-login players were not published into the same town scene")
|
||||
}
|
||||
|
||||
assertTownRemoteActorSequence(t, existingConn.write.Bytes(), existingSession, onlinePlayerInfo{
|
||||
CharacterID: 23, TownID: 1, AreaID: 2, PositionX: 360, PositionY: 480,
|
||||
Direction: 0, AreaState: 4, Session: newcomerSession,
|
||||
})
|
||||
assertTownRemoteActorSequence(t, newcomerConn.write.Bytes(), newcomerSession, onlinePlayerInfo{
|
||||
CharacterID: 17, TownID: 1, AreaID: 2, PositionX: 120, PositionY: 240,
|
||||
Direction: 1, AreaState: 3, Session: existingSession,
|
||||
})
|
||||
|
||||
existingBytes := existingConn.write.Len()
|
||||
newcomerBytes := newcomerConn.write.Len()
|
||||
service.publishCurrentTownPresence(newcomerSession, characters[1], 1, 2, newcomerRow, 0, "Town/Public/public.map", "test_duplicate_route_retry")
|
||||
if existingConn.write.Len() != existingBytes || newcomerConn.write.Len() != newcomerBytes {
|
||||
t.Fatalf("idempotent presence retry emitted duplicate actor packets: existing=%d/%d newcomer=%d/%d",
|
||||
existingConn.write.Len(), existingBytes, newcomerConn.write.Len(), newcomerBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCurrentTownPresenceSuppressesPrivateSeriaRoomAndRetiresPublicActor(t *testing.T) {
|
||||
privateConn := &bufferConn{}
|
||||
publicPeerConn := &bufferConn{}
|
||||
privateSession := &gameSession{
|
||||
conn: privateConn, connID: "private", selectedCharacterID: 17,
|
||||
townActorOwnerChannel: 7,
|
||||
}
|
||||
publicPeerSession := &gameSession{
|
||||
conn: publicPeerConn, connID: "public-peer", selectedCharacterID: 23,
|
||||
townActorOwnerChannel: 7,
|
||||
}
|
||||
service := &Service{
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
gameSessions: make(map[uint16]*gameSession),
|
||||
}
|
||||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||||
CharacterID: 17, TownID: 40, AreaID: 0, PositionX: 100, PositionY: 200,
|
||||
Direction: 5, AreaState: 3, Session: privateSession,
|
||||
})
|
||||
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||||
CharacterID: 23, TownID: 40, AreaID: 0, PositionX: 300, PositionY: 400,
|
||||
Direction: 5, AreaState: 3, Session: publicPeerSession,
|
||||
})
|
||||
privateRow := currentSceneTransitionRow{
|
||||
ObjectOrResourceKey: currentSceneActorObjectKey(17),
|
||||
Value1: 450,
|
||||
Value2: 234,
|
||||
Value3: 5,
|
||||
Value4: 3,
|
||||
}
|
||||
service.publishCurrentTownPresence(
|
||||
privateSession,
|
||||
dnfrepo.CharacterRecord{CharacterID: "17", AccountID: "account-private", Name: "private"},
|
||||
38,
|
||||
1,
|
||||
privateRow,
|
||||
0,
|
||||
"Cataclysm/Town/Elvengard/new_seria_room.map",
|
||||
"test_private_seria_entry",
|
||||
)
|
||||
if _, found := service.onlinePlayers.PlayerForCharacter(17); found {
|
||||
t.Fatal("private Seria actor remained in the shared town directory")
|
||||
}
|
||||
if peer, found := service.onlinePlayers.PlayerForCharacter(23); !found || peer.Session != publicPeerSession {
|
||||
t.Fatalf("public peer was retired with private actor: found=%t peer=%+v", found, peer)
|
||||
}
|
||||
if got := privateConn.write.Bytes(); len(got) != 0 {
|
||||
t.Fatalf("private Seria owner received a remote actor projection: %x", got)
|
||||
}
|
||||
leave, trailing := splitGameServerUpperPacketWithHeader(
|
||||
t,
|
||||
publicPeerConn.write.Bytes(),
|
||||
dnfproto.GameServerUpperHeaderSize16,
|
||||
)
|
||||
if leave.Header.Classification != 0 || leave.Header.MsgID != currentTownUserLeaveMsgID ||
|
||||
!bytes.Equal(leave.Body, buildTownUserLeaveBody(17)) || len(trailing) != 0 {
|
||||
t.Fatalf("public observer Seria retirement header=%+v body=%x trailing=%x",
|
||||
leave.Header, leave.Body, trailing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastTownPlayerEnterDoesNotReplaySoloSelfTownTransition(t *testing.T) {
|
||||
connection := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: connection,
|
||||
connID: "solo",
|
||||
selectedCharacterID: 29,
|
||||
}
|
||||
service := &Service{onlinePlayers: newOnlinePlayerManager()}
|
||||
|
||||
newcomer := &onlinePlayerInfo{
|
||||
CharacterID: 29,
|
||||
TownID: 38,
|
||||
AreaID: 0,
|
||||
PositionX: 768,
|
||||
PositionY: 238,
|
||||
Direction: 5,
|
||||
AreaState: 3,
|
||||
Session: session,
|
||||
}
|
||||
service.broadcastTownPlayerEnter(newcomer, nil)
|
||||
|
||||
if got := connection.write.Bytes(); len(got) != 0 {
|
||||
t.Fatalf("solo co-presence replayed town transition: %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
// assertTownRemoteActorSequence 断言"不发 mode1"的城镇共现线序。
|
||||
func assertTownRemoteActorSequence(
|
||||
t *testing.T,
|
||||
stream []byte,
|
||||
target *gameSession,
|
||||
actor onlinePlayerInfo,
|
||||
) {
|
||||
t.Helper()
|
||||
assertTownRemoteActorSequenceWithMode1(t, stream, target, actor, false)
|
||||
}
|
||||
|
||||
// assertTownRemoteActorSequenceWithMode1 断言 mode0 (+ 可选 mode1) -> op9 -> 0x0017 的线序。
|
||||
func assertTownRemoteActorSequenceWithMode1(
|
||||
t *testing.T,
|
||||
stream []byte,
|
||||
target *gameSession,
|
||||
actor onlinePlayerInfo,
|
||||
wantMode1 bool,
|
||||
) {
|
||||
t.Helper()
|
||||
wantOwner := currentTownRemoteActorOwnerContext(target, actor.CharacterID)
|
||||
mode0, rest := splitGameServerUpperPacketWithHeader(
|
||||
t,
|
||||
stream,
|
||||
dnfproto.GameServerUpperHeaderSize16,
|
||||
)
|
||||
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]) !=
|
||||
currentSceneActorObjectKey(actor.CharacterID) {
|
||||
t.Fatalf(
|
||||
"remote mode0 header=%+v body=%x owner=%d key=%d",
|
||||
mode0.Header,
|
||||
mode0.Body,
|
||||
wantOwner,
|
||||
currentSceneActorObjectKey(actor.CharacterID),
|
||||
)
|
||||
}
|
||||
|
||||
// 城镇共现只发 mode0(2026-09-26)。mode1 描述的是**接收者自己**的属性/装备快照,
|
||||
// 给 peer 发一份会被客户端原生处理器当成自己的数据套到本地受控角色上;90CN 以前
|
||||
// 靠一道 !currentActorMatch 守卫把它吞掉。客户端已停用战斗力,mode1 现在没有任何
|
||||
// 消费者,服务端也就不再发。参考端同样只发最小 USERINFO(subtype0/occ0)。
|
||||
//
|
||||
// wantMode1:攻坚回城(assault.go)那条路本次**未改**,仍然发 mode1;其余城镇共现
|
||||
// 路径(broadcastTownPlayerEnter / sendChannelWidePeerActors)已不发。两者共用本助手,
|
||||
// 所以用参数区分,而不是把 mode1 那组断言删掉。
|
||||
if wantMode1 {
|
||||
mode1, mode1Rest := splitGameServerUpperPacketWithHeader(
|
||||
t,
|
||||
rest,
|
||||
dnfproto.GameServerUpperHeaderSize16,
|
||||
)
|
||||
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]) !=
|
||||
currentSceneActorObjectKey(actor.CharacterID) {
|
||||
t.Fatalf(
|
||||
"remote mode1 header=%+v body=%x owner=%d key=%d",
|
||||
mode1.Header,
|
||||
mode1.Body,
|
||||
wantOwner,
|
||||
currentSceneActorObjectKey(actor.CharacterID),
|
||||
)
|
||||
}
|
||||
rest = mode1Rest
|
||||
}
|
||||
|
||||
display, rest := splitGameServerUpperPacketWithHeader(
|
||||
t,
|
||||
rest,
|
||||
dnfproto.GameServerUpperHeaderSize16,
|
||||
)
|
||||
wantPartyClear := buildCurrentSceneOp9ActorRemovalBodyInContext(
|
||||
currentSceneActorObjectKey(actor.CharacterID),
|
||||
wantOwner,
|
||||
)
|
||||
if display.Header.Classification != 0 ||
|
||||
display.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
|
||||
!bytes.Equal(display.Body, wantPartyClear) {
|
||||
t.Fatalf(
|
||||
"remote same-owner op9 party clear header=%+v body=%x want=%x owner=%d key=%d",
|
||||
display.Header,
|
||||
display.Body,
|
||||
wantPartyClear,
|
||||
wantOwner,
|
||||
currentSceneActorObjectKey(actor.CharacterID),
|
||||
)
|
||||
}
|
||||
|
||||
state, trailing := splitGameServerUpperPacketWithHeader(
|
||||
t,
|
||||
rest,
|
||||
dnfproto.GameServerUpperHeaderSize16,
|
||||
)
|
||||
wantState := buildCurrentTownUserAreaNotificationBody(
|
||||
currentSceneActorObjectKey(actor.CharacterID),
|
||||
actor.TownID,
|
||||
actor.AreaID,
|
||||
actor.PositionX,
|
||||
actor.PositionY,
|
||||
actor.Direction,
|
||||
actor.AreaState,
|
||||
)
|
||||
if state.Header.Classification != 0 ||
|
||||
state.Header.MsgID != currentTownUserAreaNotificationMsgID ||
|
||||
!bytes.Equal(state.Body, wantState) {
|
||||
t.Fatalf(
|
||||
"remote area state header=%+v body=%x want=%x",
|
||||
state.Header,
|
||||
state.Body,
|
||||
wantState,
|
||||
)
|
||||
}
|
||||
if len(trailing) != 0 {
|
||||
t.Fatalf("remote actor sequence replayed a town transition: %x", trailing)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user