服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
759 lines
36 KiB
Go
759 lines
36 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
)
|
|
|
|
func TestCurrentTownAreaChatBroadcastsOnlyToMatchingScene(t *testing.T) {
|
|
channel := channelcatalog.Channel{ID: 16}
|
|
newSession := func(character uint16, town, area byte) (*gameSession, *bufferConn) {
|
|
conn := &bufferConn{}
|
|
return &gameSession{
|
|
conn: conn,
|
|
channel: channel,
|
|
selectedCharacterID: character,
|
|
townSceneReadyCharacterID: character,
|
|
townPositionSnapshot: currentTownPositionSnapshot{
|
|
CharacterID: character,
|
|
TownID: town,
|
|
AreaID: area,
|
|
PositionValid: true,
|
|
},
|
|
}, conn
|
|
}
|
|
sender, senderConn := newSession(19, 38, 1)
|
|
recipient, recipientConn := newSession(20, 38, 1)
|
|
otherArea, otherAreaConn := newSession(21, 38, 2)
|
|
otherChannel, otherChannelConn := newSession(22, 38, 1)
|
|
otherChannel.channel.ID = 17
|
|
otherInstance, otherInstanceConn := newSession(23, 38, 1)
|
|
service := &Service{gameSessions: map[uint16]*gameSession{
|
|
19: sender, 20: recipient, 21: otherArea, 22: otherChannel, 23: otherInstance,
|
|
}, onlinePlayers: newOnlinePlayerManager()}
|
|
for _, presence := range []onlinePlayerInfo{
|
|
{CharacterID: 19, TownID: 38, AreaID: 1, Session: sender},
|
|
{CharacterID: 20, TownID: 38, AreaID: 1, Session: recipient},
|
|
{CharacterID: 21, TownID: 38, AreaID: 2, Session: otherArea},
|
|
// Channel 17 still shares the same visible public-town scene.
|
|
{CharacterID: 22, TownID: 38, AreaID: 1, Session: otherChannel},
|
|
// A private scene with the same PVF town/area must remain isolated.
|
|
{CharacterID: 23, TownID: 38, AreaID: 1, InstanceKey: 99, Session: otherInstance},
|
|
} {
|
|
copy := presence
|
|
service.onlinePlayers.EnterArea(©)
|
|
}
|
|
|
|
request := buildCurrentTownChatRequestForTest(currentTownChatAreaMode, []byte("hello-90cn"))
|
|
if err := service.handleGameCommand(sender, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketSendMessage), request); err != nil {
|
|
t.Fatalf("chat: %v", err)
|
|
}
|
|
want := buildCurrentTownChatNotification(currentTownChatAreaMode, sender.selectedCharacterID, []byte("hello-90cn"))
|
|
for name, conn := range map[string]*bufferConn{"sender": senderConn, "recipient": recipientConn, "other_channel": otherChannelConn} {
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID != currentTownChatNotificationMsgID || packet.Header.Classification != 0 || !bytes.Equal(packet.Body, want) || len(rest) != 0 {
|
|
t.Fatalf("%s packet header=%+v body=%x rest=%x", name, packet.Header, packet.Body, rest)
|
|
}
|
|
}
|
|
if otherAreaConn.write.Len() != 0 || otherInstanceConn.write.Len() != 0 {
|
|
t.Fatalf("chat crossed scene: area=%x instance=%x", otherAreaConn.write.Bytes(), otherInstanceConn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
// A character in its private Seria room is absent from the public-town
|
|
// registry by design, which is where a freshly logged-in player stands. Area
|
|
// chat there used to answer the failure code and print nothing; the speaker is
|
|
// the whole audience of a private scene, so the line echoes back.
|
|
func TestCurrentAreaChatInPrivateSceneEchoesToTheSpeaker(t *testing.T) {
|
|
conn := &bufferConn{}
|
|
sender := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 16},
|
|
selectedCharacterID: 19,
|
|
}
|
|
service := &Service{
|
|
gameSessions: map[uint16]*gameSession{19: sender},
|
|
onlinePlayers: newOnlinePlayerManager(),
|
|
}
|
|
recipients, scope, ok := service.currentScopedChatRecipients(sender, currentTownChatAreaMode)
|
|
if !ok || scope != "private_scene" || len(recipients) != 1 || recipients[0] != sender {
|
|
t.Fatalf("private-scene area scope ok=%t scope=%q recipients=%d", ok, scope, len(recipients))
|
|
}
|
|
|
|
request := buildCurrentTownChatRequestForTest(currentTownChatAreaMode, []byte("alone"))
|
|
parsed, parsedOK := parseCurrentTownChatRequest(request)
|
|
if !parsedOK {
|
|
t.Fatalf("area request rejected: %x", request)
|
|
}
|
|
if err := service.handleCurrentScopedTownChat(sender, parsed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
want := buildCurrentTownChatNotification(currentTownChatAreaMode, 19, []byte("alone"))
|
|
if packet.Header.MsgID != currentTownChatNotificationMsgID || packet.Header.Classification != 0 ||
|
|
!bytes.Equal(packet.Body, want) || len(rest) != 0 {
|
|
t.Fatalf("private-scene area line header=%+v body=%x want=%x rest=%x",
|
|
packet.Header, packet.Body, want, rest)
|
|
}
|
|
}
|
|
|
|
func TestCurrentTownChatRejectsMalformedAndUnsupportedModes(t *testing.T) {
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 16},
|
|
selectedCharacterID: 19,
|
|
townSceneReadyCharacterID: 19,
|
|
townPositionSnapshot: currentTownPositionSnapshot{
|
|
CharacterID: 19, TownID: 38, AreaID: 1, PositionValid: true,
|
|
},
|
|
}
|
|
service := &Service{gameSessions: map[uint16]*gameSession{19: session}}
|
|
malformed := buildCurrentTownChatRequestForTest(currentTownChatAreaMode, []byte("x"))
|
|
binary.LittleEndian.PutUint32(malformed[7:11], 2)
|
|
for name, body := range map[string][]byte{
|
|
"bad_message_length": malformed,
|
|
"private_without_identity": buildCurrentTownChatRequestForTest(currentTownChatWhisperMode, []byte("private")),
|
|
"short_body": make([]byte, 10),
|
|
"unsupported_mode": buildCurrentTownChatRequestForTest(0x7f, []byte("unsupported")),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
conn.write.Reset()
|
|
if err := service.handleCurrentTownChat(session, body); err != nil {
|
|
t.Fatalf("reject chat: %v", err)
|
|
}
|
|
assertCurrentTownChatFailurePacket(t, conn.write.Bytes())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCurrentRobotMegaphoneUsesNonInteractiveIdentityForOtherClients(t *testing.T) {
|
|
robotConn := &bufferConn{}
|
|
playerConn := &bufferConn{}
|
|
robot := &gameSession{
|
|
conn: robotConn,
|
|
channel: channelcatalog.Channel{ID: 19},
|
|
selectedCharacterID: 348,
|
|
robotSessionBound: true,
|
|
}
|
|
player := &gameSession{
|
|
conn: playerConn,
|
|
channel: channelcatalog.Channel{ID: 19},
|
|
selectedCharacterID: 1,
|
|
}
|
|
service := &Service{}
|
|
name := []byte{0xbb, 0xb6, 0xcf, 0xeb, 0xd0, 0xc7, 0xc7, 0xf2, '3', '3', '0'}
|
|
message := []byte{0xd0, 0xdd, 0xcf, 0xa2, 0xd2, 0xbb, 0xbb, 0xe1, 0xd4, 0xd9, 0xb3, 0xf6, 0xb7, 0xa2}
|
|
native := buildCurrentPeerChatNoticeBody(currentTownChatMegaphoneMode, 19, robot.selectedCharacterID, name, message)
|
|
if got := service.sendCurrentMegaphoneToRecipients(robot, []*gameSession{robot, player}, currentTownChatMegaphoneMode, native, name, message); got != 2 {
|
|
t.Fatalf("delivered=%d want 2", got)
|
|
}
|
|
|
|
robotPacket, robotRest := splitGameServerUpperPacket(t, robotConn.write.Bytes())
|
|
if robotPacket.Header.MsgID != currentPeerChatNotifyOpcode || robotPacket.Header.Classification != 0 ||
|
|
!bytes.Equal(robotPacket.Body, native) || len(robotRest) != 0 {
|
|
t.Fatalf("robot packet header=%+v body=%x rest=%x", robotPacket.Header, robotPacket.Body, robotRest)
|
|
}
|
|
|
|
playerPacket, playerRest := splitGameServerUpperPacket(t, playerConn.write.Bytes())
|
|
wantPlayer := buildCurrentPassiveRobotMegaphoneNoticeBody(currentTownChatMegaphoneMode, 19, name, message)
|
|
if playerPacket.Header.MsgID != currentPeerChatNotifyOpcode || playerPacket.Header.Classification != 0 ||
|
|
!bytes.Equal(playerPacket.Body, wantPlayer) || len(playerRest) != 0 {
|
|
t.Fatalf("player packet header=%+v body=%x want=%x rest=%x", playerPacket.Header, playerPacket.Body, wantPlayer, playerRest)
|
|
}
|
|
if len(playerPacket.Body) < 14 || playerPacket.Body[0] != currentTownChatMegaphoneMode ||
|
|
binary.LittleEndian.Uint16(playerPacket.Body[4:6]) != 0 || binary.LittleEndian.Uint32(playerPacket.Body[6:10]) != 0 {
|
|
t.Fatalf("passive identity was not cleared: %x", playerPacket.Body)
|
|
}
|
|
wantVisible := append(append(append([]byte(nil), name...), []byte(": ")...), message...)
|
|
messageLength := int(binary.LittleEndian.Uint32(playerPacket.Body[10:14]))
|
|
if 14+messageLength != len(playerPacket.Body) || !bytes.Equal(playerPacket.Body[14:], wantVisible) {
|
|
t.Fatalf("passive visible message=%x want=%x body=%x", playerPacket.Body[14:], wantVisible, playerPacket.Body)
|
|
}
|
|
}
|
|
|
|
func TestCurrentHumanMegaphoneKeepsNativeInteractiveIdentity(t *testing.T) {
|
|
senderConn := &bufferConn{}
|
|
recipientConn := &bufferConn{}
|
|
sender := &gameSession{conn: senderConn, channel: channelcatalog.Channel{ID: 253}, selectedCharacterID: 1}
|
|
recipient := &gameSession{conn: recipientConn, channel: channelcatalog.Channel{ID: 19}, selectedCharacterID: 2}
|
|
service := &Service{}
|
|
name := []byte("human")
|
|
message := []byte("hello")
|
|
native := buildCurrentPeerChatNoticeBody(currentTownChatMegaphoneMode, 253, sender.selectedCharacterID, name, message)
|
|
if got := service.sendCurrentMegaphoneToRecipients(sender, []*gameSession{sender, recipient}, currentTownChatMegaphoneMode, native, name, message); got != 2 {
|
|
t.Fatalf("delivered=%d want 2", got)
|
|
}
|
|
for label, connection := range map[string]*bufferConn{"sender": senderConn, "recipient": recipientConn} {
|
|
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if packet.Header.MsgID != currentPeerChatNotifyOpcode || !bytes.Equal(packet.Body, native) || len(rest) != 0 {
|
|
t.Fatalf("%s body=%x want=%x rest=%x", label, packet.Body, native, rest)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrentPassiveRobotMegaphoneKeepsNativeMessageBoundary(t *testing.T) {
|
|
message := bytes.Repeat([]byte{'x'}, currentTownChatMaxMessageBytes)
|
|
body := buildCurrentPassiveRobotMegaphoneNoticeBody(currentTownChatMegaphoneMode, 19, []byte("robot-name"), message)
|
|
if len(body) != 14+currentTownChatMaxMessageBytes {
|
|
t.Fatalf("body len=%d want %d", len(body), 14+currentTownChatMaxMessageBytes)
|
|
}
|
|
if binary.LittleEndian.Uint16(body[4:6]) != 0 || binary.LittleEndian.Uint32(body[6:10]) != 0 {
|
|
t.Fatalf("passive identity was not cleared: %x", body[:10])
|
|
}
|
|
if binary.LittleEndian.Uint32(body[10:14]) != currentTownChatMaxMessageBytes || !bytes.Equal(body[14:], message) {
|
|
t.Fatal("maximum-length message was altered")
|
|
}
|
|
}
|
|
|
|
func TestParseCurrentTownChatAcceptsCapturedModeShapes(t *testing.T) {
|
|
decode := func(value string) []byte {
|
|
t.Helper()
|
|
body, err := hex.DecodeString(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body
|
|
}
|
|
whisper := decode("01020000000000010000003f04000000cab7caab00")
|
|
whisperTrailingOne := decode("01020000000000010000003f04000000cab7caab01")
|
|
peerMenu := decode("24050000000000060000006e6968616f2006000000c0b4c0b4c0b400")
|
|
oneToOne := buildCurrentPrivateTownChatRequestForTest(currentTownChatOneToOneMode, 2, []byte("compat"), []byte("target90"))
|
|
|
|
megaphone := buildCurrentTownChatRequestForTest(currentTownChatMegaphoneMode, []byte("123"))
|
|
binary.LittleEndian.PutUint32(megaphone[3:7], 7)
|
|
|
|
tests := []struct {
|
|
name string
|
|
body []byte
|
|
mode byte
|
|
target uint16
|
|
argument uint32
|
|
message []byte
|
|
targetName []byte
|
|
}{
|
|
{name: "area", body: buildCurrentTownChatRequestForTest(currentTownChatAreaMode, []byte("...")), mode: currentTownChatAreaMode},
|
|
{name: "party", body: buildCurrentTownChatRequestForTest(currentTownChatPartyMode, []byte("...")), mode: currentTownChatPartyMode},
|
|
{name: "guild", body: buildCurrentTownChatRequestForTest(currentTownChatGuildMode, []byte("...")), mode: currentTownChatGuildMode},
|
|
{name: "raid", body: buildCurrentTownChatRequestForTest(currentTownChatRaidMode, []byte("....")), mode: currentTownChatRaidMode},
|
|
{name: "megaphone", body: megaphone, mode: currentTownChatMegaphoneMode, argument: 7},
|
|
{name: "whisper-captured", body: whisper, mode: currentTownChatWhisperMode, target: 2, message: []byte("?"), targetName: []byte{0xca, 0xb7, 0xca, 0xab}},
|
|
{name: "whisper-captured-trailing-one", body: whisperTrailingOne, mode: currentTownChatWhisperMode, target: 2, message: []byte("?"), targetName: []byte{0xca, 0xb7, 0xca, 0xab}},
|
|
{name: "one-to-one-compat", body: oneToOne, mode: currentTownChatOneToOneMode, target: 2, message: []byte("compat"), targetName: []byte("target90")},
|
|
{name: "peer-menu-captured", body: peerMenu, mode: currentTownChatPeerMenuMode, target: 5, message: []byte("nihao "), targetName: []byte{0xc0, 0xb4, 0xc0, 0xb4, 0xc0, 0xb4}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
request, ok := parseCurrentTownChatRequest(test.body)
|
|
if !ok {
|
|
t.Fatalf("request rejected: %x", test.body)
|
|
}
|
|
if request.Mode != test.mode || request.TargetUniqueID != test.target ||
|
|
request.Argument != test.argument || (test.message != nil && !bytes.Equal(request.Message, test.message)) ||
|
|
!bytes.Equal(request.TargetName, test.targetName) {
|
|
t.Fatalf("request=%+v", request)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCurrentPrivateTownChatModesRouteAcrossChannelsAligned(t *testing.T) {
|
|
for _, mode := range []byte{currentTownChatWhisperMode, currentTownChatOneToOneMode, currentTownChatPeerMenuMode} {
|
|
t.Run(hex.EncodeToString([]byte{mode}), func(t *testing.T) {
|
|
service, source, target, sourceConn, targetConn := newCurrentSocialPeerTest(t)
|
|
target.channel.ID = 77
|
|
service.onlinePlayers.LeaveAreaSession(target.selectedCharacterID, target)
|
|
request := buildCurrentPrivateTownChatRequestForTest(mode, target.selectedCharacterID, []byte("hello"), rosterNameBytes(currentSocialTestFriendName))
|
|
if mode == currentTownChatWhisperMode {
|
|
request[len(request)-1] = 1 // Captured whisper variant.
|
|
}
|
|
|
|
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentPeerChatStateOpcode, request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !handled {
|
|
t.Fatal("private chat was not handled")
|
|
}
|
|
assertCurrentPrivateTownChatSuccess(t, sourceConn.write.Bytes(), targetConn.write.Bytes(), mode, []byte("hello"), source, target)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCurrentPrivateTownChatPeerMenuRoutesThroughLegacyDispatcher(t *testing.T) {
|
|
service, source, target, sourceConn, targetConn := newCurrentSocialPeerTest(t)
|
|
request := buildCurrentPrivateTownChatRequestForTest(currentTownChatPeerMenuMode, target.selectedCharacterID, []byte("legacy"), rosterNameBytes(currentSocialTestFriendName))
|
|
frame := buildLegacyGamePacketForBridgeTest(byte(dnfenum.GameCmdCommand), currentPeerChatStateOpcode, 1, request)
|
|
if err := service.handleLegacyGamePacket(source, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertCurrentPrivateTownChatSuccess(t, sourceConn.write.Bytes(), targetConn.write.Bytes(), currentTownChatPeerMenuMode, []byte("legacy"), source, target)
|
|
}
|
|
|
|
func TestCurrentPrivateTownChatFailuresReleasePendingGate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(*Service, *gameSession)
|
|
body func(*gameSession) []byte
|
|
}{
|
|
{
|
|
name: "malformed_missing_trailing_zero",
|
|
body: func(target *gameSession) []byte {
|
|
body := buildCurrentPrivateTownChatRequestForTest(currentTownChatWhisperMode, target.selectedCharacterID, []byte("hello"), rosterNameBytes(currentSocialTestFriendName))
|
|
return body[:len(body)-1]
|
|
},
|
|
},
|
|
{
|
|
name: "malformed_unknown_trailing_flag",
|
|
body: func(target *gameSession) []byte {
|
|
body := buildCurrentPrivateTownChatRequestForTest(currentTownChatPeerMenuMode, target.selectedCharacterID, []byte("hello"), rosterNameBytes(currentSocialTestFriendName))
|
|
body[len(body)-1] = 2
|
|
return body
|
|
},
|
|
},
|
|
{
|
|
name: "offline",
|
|
setup: func(service *Service, target *gameSession) {
|
|
service.mu.Lock()
|
|
delete(service.gameSessions, target.selectedCharacterID)
|
|
service.mu.Unlock()
|
|
},
|
|
body: func(target *gameSession) []byte {
|
|
return buildCurrentPrivateTownChatRequestForTest(currentTownChatOneToOneMode, target.selectedCharacterID, []byte("hello"), rosterNameBytes(currentSocialTestFriendName))
|
|
},
|
|
},
|
|
{
|
|
name: "target_name_mismatch",
|
|
body: func(target *gameSession) []byte {
|
|
return buildCurrentPrivateTownChatRequestForTest(currentTownChatPeerMenuMode, target.selectedCharacterID, []byte("hello"), []byte("wrong"))
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
service, source, target, sourceConn, targetConn := newCurrentSocialPeerTest(t)
|
|
if test.setup != nil {
|
|
test.setup(service, target)
|
|
}
|
|
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentPeerChatStateOpcode, test.body(target))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !handled {
|
|
t.Fatal("private chat failure was not handled")
|
|
}
|
|
assertCurrentTownChatFailurePacket(t, sourceConn.write.Bytes())
|
|
if targetConn.write.Len() != 0 {
|
|
t.Fatalf("failed private chat reached target: %x", targetConn.write.Bytes())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// wantCurrentPrivateChatStatePacket is the state packet a private line's reader
|
|
// must receive. A typed whisper keeps the class-0 op17 peer-id state; the 1:1 and
|
|
// peer-menu windows are fed by op65 instead, which carries the line and the name of
|
|
// the slot it belongs to: flag 0 is the other party (an incoming line, named after
|
|
// the speaker) and flag 1 is the player (the sender's own copy, named after itself).
|
|
func wantCurrentPrivateChatStatePacket(mode byte, peerID uint16, message []byte, speaker, peer string, flag byte) (uint16, []byte) {
|
|
if isCurrentOneToOneTownChatMode(mode) {
|
|
return currentTownChatOneToOneStateMsgID,
|
|
buildCurrentOneToOneChatStateBody(mode, flag, rosterNameBytes(speaker), rosterNameBytes(peer), message)
|
|
}
|
|
return currentPeerChatStateOpcode, buildCurrentPeerChatStateBody(peerID, mode)
|
|
}
|
|
|
|
func assertCurrentPrivateTownChatSuccess(t *testing.T, sourceData, targetData []byte, mode byte, message []byte, source, target *gameSession) {
|
|
t.Helper()
|
|
ack, sourceRest := splitGameServerUpperPacket(t, sourceData)
|
|
if ack.Header.Classification != 1 || ack.Header.MsgID != currentPeerChatStateOpcode || !bytes.Equal(ack.Body, []byte{1}) {
|
|
t.Fatalf("source ack header=%+v body=%x", ack.Header, ack.Body)
|
|
}
|
|
if isCurrentOneToOneTownChatMode(mode) {
|
|
// One line per reader, both naming the speaker: the sender's copy comes back
|
|
// because this client does not draw what the player typed, and the peer gets
|
|
// the same line as an incoming one.
|
|
wantSourceMsgID, wantSourceBody := wantCurrentPrivateChatStatePacket(
|
|
mode, target.selectedCharacterID, message, "source1", currentSocialTestFriendName, currentChatIncomingLineFlag)
|
|
sourceState, sourceRest := splitGameServerUpperPacket(t, sourceRest)
|
|
if sourceState.Header.Classification != 0 || sourceState.Header.MsgID != wantSourceMsgID ||
|
|
!bytes.Equal(sourceState.Body, wantSourceBody) || len(sourceRest) != 0 {
|
|
t.Fatalf("1:1 sender copy header=%+v body=%x want=%x rest=%x",
|
|
sourceState.Header, sourceState.Body, wantSourceBody, sourceRest)
|
|
}
|
|
wantTargetMsgID, wantTargetBody := wantCurrentPrivateChatStatePacket(
|
|
mode, source.selectedCharacterID, message, "source1", "source1", currentChatIncomingLineFlag)
|
|
targetState, targetRest := splitGameServerUpperPacket(t, targetData)
|
|
if targetState.Header.Classification != 0 || targetState.Header.MsgID != wantTargetMsgID ||
|
|
!bytes.Equal(targetState.Body, wantTargetBody) || len(targetRest) != 0 {
|
|
t.Fatalf("1:1 peer copy header=%+v body=%x want=%x rest=%x",
|
|
targetState.Header, targetState.Body, wantTargetBody, targetRest)
|
|
}
|
|
return
|
|
}
|
|
wantSourceStateMsgID, wantSourceStateBody := wantCurrentPrivateChatStatePacket(
|
|
mode, target.selectedCharacterID, message, currentSocialTestFriendName, currentSocialTestFriendName, 0)
|
|
sourceState, sourceRest := splitGameServerUpperPacket(t, sourceRest)
|
|
if sourceState.Header.Classification != 0 || sourceState.Header.MsgID != wantSourceStateMsgID ||
|
|
!bytes.Equal(sourceState.Body, wantSourceStateBody) {
|
|
t.Fatalf("source state header=%+v body=%x want=%x rest=%x",
|
|
sourceState.Header, sourceState.Body, wantSourceStateBody, sourceRest)
|
|
}
|
|
sourceNotice, sourceRest := splitGameServerUpperPacket(t, sourceRest)
|
|
// Each side is named its peer, matching the state packet above: the sender's
|
|
// echo describes the target, not itself. An echo that named the sender
|
|
// described a conversation with itself and the client dropped it.
|
|
// The echo also carries the outgoing twin of the mode so it reads as a line
|
|
// this character sent rather than one it received.
|
|
wantSourceNotice := buildCurrentGuildChatNoticeBody(currentOutgoingWhisperMode(mode), currentChatServerGroup, byte(source.channel.ID), 0, rosterNameBytes(currentSocialTestFriendName), message)
|
|
if sourceNotice.Header.Classification != 0 || sourceNotice.Header.MsgID != currentPeerChatNotifyOpcode ||
|
|
!bytes.Equal(sourceNotice.Body, wantSourceNotice) || len(sourceRest) != 0 {
|
|
t.Fatalf("source notice header=%+v body=%x want=%x rest=%x", sourceNotice.Header, sourceNotice.Body, wantSourceNotice, sourceRest)
|
|
}
|
|
wantTargetStateMsgID, wantTargetStateBody := wantCurrentPrivateChatStatePacket(
|
|
mode, source.selectedCharacterID, message, "source1", "source1", 0)
|
|
targetState, targetRest := splitGameServerUpperPacket(t, targetData)
|
|
if targetState.Header.Classification != 0 || targetState.Header.MsgID != wantTargetStateMsgID ||
|
|
!bytes.Equal(targetState.Body, wantTargetStateBody) {
|
|
t.Fatalf("target state header=%+v body=%x want=%x", targetState.Header, targetState.Body, wantTargetStateBody)
|
|
}
|
|
targetNotice, targetRest := splitGameServerUpperPacket(t, targetRest)
|
|
wantNotice := buildCurrentGuildChatNoticeBody(mode, currentChatServerGroup, byte(target.channel.ID), 0, rosterNameBytes("source1"), message)
|
|
if targetNotice.Header.Classification != 0 || targetNotice.Header.MsgID != currentPeerChatNotifyOpcode ||
|
|
!bytes.Equal(targetNotice.Body, wantNotice) || len(targetRest) != 0 {
|
|
t.Fatalf("target notice header=%+v body=%x want=%x rest=%x", targetNotice.Header, targetNotice.Body, wantNotice, targetRest)
|
|
}
|
|
}
|
|
|
|
// TestCurrentOneToOneChatStateBodyMatchesClientReader pins op65 byte for byte.
|
|
//
|
|
// The client's reader (sub_1D691F0) is
|
|
//
|
|
// u8 mode | u8 status | dstr name(31) | u8 flag | dstr text(256)
|
|
// | dstr extra(31) -- consumed only when mode == 0x24
|
|
//
|
|
// and status must be the accept value: every other value is a rejection reason
|
|
// the client renders *instead of* the line (0x36 / 0x4D / 0x5A / default).
|
|
func TestCurrentOneToOneChatStateBodyMatchesClientReader(t *testing.T) {
|
|
speaker := []byte("target1")
|
|
message := []byte("hello")
|
|
|
|
peerMenu := buildCurrentOneToOneChatStateBody(currentTownChatPeerMenuMode, currentChatIncomingLineFlag, speaker, speaker, message)
|
|
wantPeerMenu := append([]byte{currentTownChatPeerMenuMode, currentTownChatOneToOneAcceptStatus, 7, 0, 0, 0}, speaker...)
|
|
wantPeerMenu = append(wantPeerMenu, currentChatIncomingLineFlag, 5, 0, 0, 0)
|
|
wantPeerMenu = append(wantPeerMenu, message...)
|
|
wantPeerMenu = append(wantPeerMenu, 7, 0, 0, 0)
|
|
wantPeerMenu = append(wantPeerMenu, speaker...)
|
|
if !bytes.Equal(peerMenu, wantPeerMenu) {
|
|
t.Fatalf("peer-menu state body=%x want=%x", peerMenu, wantPeerMenu)
|
|
}
|
|
|
|
// The one-to-one mode reaches the same handler but the handler only consumes
|
|
// the trailing dstr for 0x24, so that mode must not carry it.
|
|
oneToOne := buildCurrentOneToOneChatStateBody(currentTownChatOneToOneMode, 0, speaker, speaker, message)
|
|
wantOneToOne := append([]byte{currentTownChatOneToOneMode, currentTownChatOneToOneAcceptStatus, 7, 0, 0, 0}, speaker...)
|
|
wantOneToOne = append(wantOneToOne, 0, 5, 0, 0, 0)
|
|
wantOneToOne = append(wantOneToOne, message...)
|
|
if !bytes.Equal(oneToOne, wantOneToOne) {
|
|
t.Fatalf("one-to-one state body=%x want=%x", oneToOne, wantOneToOne)
|
|
}
|
|
if len(peerMenu) != len(oneToOne)+4+len(speaker) {
|
|
t.Fatalf("0x24 body must carry the trailing peer dstr: %d vs %d", len(peerMenu), len(oneToOne))
|
|
}
|
|
}
|
|
|
|
func assertCurrentTownChatFailurePacket(t *testing.T, data []byte) {
|
|
t.Helper()
|
|
packet, rest := splitGameServerUpperPacket(t, data)
|
|
if packet.Header.Classification != 1 || packet.Header.MsgID != currentPeerChatStateOpcode ||
|
|
!bytes.Equal(packet.Body, []byte{0, currentTownChatGenericError}) || len(rest) != 0 {
|
|
t.Fatalf("failure header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
}
|
|
|
|
func TestCurrentTownGuildChatWithoutMembershipReturnsNativeFailure(t *testing.T) {
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, selectedCharacterID: 19}
|
|
service := &Service{gameSessions: map[uint16]*gameSession{19: session}}
|
|
if err := service.handleCurrentTownChat(session, buildCurrentTownChatRequestForTest(currentTownChatGuildMode, []byte("guild"))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.Classification != 1 || packet.Header.MsgID != currentPeerChatStateOpcode ||
|
|
!bytes.Equal(packet.Body, []byte{0, currentTownChatGenericError}) || len(rest) != 0 {
|
|
t.Fatalf("failure header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
}
|
|
|
|
// The captured 鍏細 request the live EXE sends. It is pinned as raw bytes
|
|
// because the release snapshot assumed mode 4 and answered mode 6 from the
|
|
// unsupported-mode branch, which the chat window renders as silence.
|
|
func TestCurrentTownGuildChatCapturedRequestUsesGuildScope(t *testing.T) {
|
|
body, err := hex.DecodeString("0601000000000008000000b9a4bbe1cffbcfa2")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request, ok := parseCurrentTownChatRequest(body)
|
|
if !ok {
|
|
t.Fatalf("captured guild request rejected: %x", body)
|
|
}
|
|
if request.Mode != 6 {
|
|
t.Fatalf("captured guild mode=%d want 6", request.Mode)
|
|
}
|
|
if currentTownChatGuildMode != 6 {
|
|
t.Fatalf("currentTownChatGuildMode=%d want the captured 6", currentTownChatGuildMode)
|
|
}
|
|
if !bytes.Equal(request.Message, []byte{0xb9, 0xa4, 0xbb, 0xe1, 0xcf, 0xfb, 0xcf, 0xa2}) {
|
|
t.Fatalf("captured guild message=%x", request.Message)
|
|
}
|
|
|
|
service := &Service{}
|
|
session := &gameSession{selectedCharacterID: 1}
|
|
for _, mode := range []byte{currentTownChatGuildMode, currentTownChatGuildLegacyMode} {
|
|
if _, scope, _ := service.currentScopedChatRecipients(session, mode); scope != "guild" {
|
|
t.Fatalf("mode %d scope=%q want guild", mode, scope)
|
|
}
|
|
if !isCurrentGuildTownChatMode(mode) {
|
|
t.Fatalf("mode %d is not routed to the named-speaker notification", mode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guild delivers on op12, the same carrier as area/party/raid. That carrier
|
|
// cannot name its speaker, so the line arrives unattributed 鈥?but op65 reached
|
|
// the speaker alone and op118 reached nobody, both live, so op12 is the only
|
|
// carrier observed to put a mode-6 line on screen. Pinning it here keeps the
|
|
// next attempt from silently swapping in another guessed opcode: replacing it
|
|
// needs a handler identified in the client's class-0 registry first.
|
|
// The op118 body that the client's own display gates accept, pinned field by
|
|
// field. Byte 2 must be the server group and byte 4 the reader's own channel:
|
|
// the client-side probes at 0x1D5B4E3 and 0x1D5B5B7 rejected byte(channel.ID) in
|
|
// slot 2 from readers on channels 508 and 121 (both answered 1), then accepted
|
|
// server group 1 with each reader's own channel in slot 4 (121 against 121, 251
|
|
// against 251). Putting the channel in slot 2 is what silently dropped every
|
|
// guild line.
|
|
func TestCurrentGuildChatNoticeCarriesServerGroupThenReaderChannel(t *testing.T) {
|
|
if currentChatServerGroup != 1 {
|
|
t.Fatalf("server group=%d want 1", currentChatServerGroup)
|
|
}
|
|
name := []byte{0xb9, 0xed, 0xbd, 0xa3, 0xca, 0xbf} // 鬼剑士
|
|
message := []byte{0xb9, 0xa4, 0xbb, 0xe1, 0xcf, 0xfb, 0xcf, 0xa2} // 工会消息
|
|
for _, readerChannel := range []byte{121, 251} {
|
|
body := buildCurrentGuildChatNoticeBody(currentTownChatGuildMode,
|
|
currentChatServerGroup, readerChannel, 1, name, message)
|
|
if body[0] != currentTownChatGuildMode {
|
|
t.Fatalf("mode=%d", body[0])
|
|
}
|
|
if body[1] != currentChatServerGroup {
|
|
t.Fatalf("byte2=%d want the server group %d", body[1], currentChatServerGroup)
|
|
}
|
|
if body[2] != 0 {
|
|
t.Fatalf("byte3=%d want 0", body[2])
|
|
}
|
|
if body[3] != readerChannel {
|
|
t.Fatalf("byte4=%d want the reader channel %d", body[3], readerChannel)
|
|
}
|
|
if binary.LittleEndian.Uint16(body[4:6]) != 1 {
|
|
t.Fatalf("sender id=%x", body[4:6])
|
|
}
|
|
want := append(append([]byte(nil), name...), message...)
|
|
if !bytes.Contains(body[6:], want[:len(name)]) || !bytes.Contains(body[6:], message) {
|
|
t.Fatalf("notice dropped name or message: %x", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A whisper typed by name arrives with 0xFFFF in the unique-id slot, not the
|
|
// recipient's character id. The captured request is
|
|
// `01 FF FF 00000000 dstr(鎮勬倓璇? dstr(楝煎墤澹? 01`; looking 0xFFFF up as a
|
|
// character id missed and answered the failure code, so neither side saw a line.
|
|
func TestCurrentWhisperResolvesPlaceholderTargetByName(t *testing.T) {
|
|
body, err := hex.DecodeString("01ffff000000000600000063c4c7c4bbb00600000067756573740100")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Rebuild the captured shape with names this harness can compare.
|
|
request := buildCurrentPrivateTownChatRequestForTest(currentTownChatWhisperMode,
|
|
currentWhisperTargetUnknownID, []byte("hi"), rosterNameBytes("target1"))
|
|
request[len(request)-1] = 1
|
|
parsed, ok := parseCurrentTownChatRequest(request)
|
|
if !ok {
|
|
t.Fatalf("captured-shape whisper rejected: %x (reference capture %x)", request, body)
|
|
}
|
|
if parsed.TargetUniqueID != currentWhisperTargetUnknownID {
|
|
t.Fatalf("target id=%d want the 0x%X placeholder", parsed.TargetUniqueID, currentWhisperTargetUnknownID)
|
|
}
|
|
|
|
service := &Service{gameSessions: map[uint16]*gameSession{}}
|
|
source := &gameSession{selectedCharacterID: 7}
|
|
if _, _, resolved := service.resolveCurrentWhisperTarget(source, parsed); resolved {
|
|
t.Fatal("placeholder target resolved with nobody online")
|
|
}
|
|
if _, _, resolved := service.resolveCurrentWhisperTarget(source, currentTownChatRequest{
|
|
TargetUniqueID: currentWhisperTargetUnknownID,
|
|
}); resolved {
|
|
t.Fatal("placeholder target resolved without a name to match")
|
|
}
|
|
}
|
|
|
|
// Guild delivers on the named-speaker notice, never on op12: op12 identifies its
|
|
// speaker by a scene actor key no guild member can resolve, which is what left
|
|
// the line unattributed and made the filter treat it as ordinary chat.
|
|
func TestCurrentGuildChatDoesNotUseTheAreaChatCarrier(t *testing.T) {
|
|
conn := &bufferConn{}
|
|
sender := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 16}, selectedCharacterID: 1}
|
|
service := &Service{gameSessions: map[uint16]*gameSession{1: sender}}
|
|
request := buildCurrentTownChatRequestForTest(currentTownChatGuildMode, []byte("guild"))
|
|
parsed, ok := parseCurrentTownChatRequest(request)
|
|
if !ok {
|
|
t.Fatalf("guild request rejected: %x", request)
|
|
}
|
|
if err := service.handleCurrentScopedTownChat(sender, parsed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// No repository, so the guild scope is unavailable and the native failure is
|
|
// the expected answer. What matters is that no op12 line was emitted.
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID == currentTownChatNotificationMsgID {
|
|
t.Fatal("guild chat fell back to the op12 scene-actor carrier")
|
|
}
|
|
if packet.Header.MsgID != currentPeerChatStateOpcode || len(rest) != 0 {
|
|
t.Fatalf("guild scope failure header=%+v rest=%x", packet.Header, rest)
|
|
}
|
|
}
|
|
|
|
// The captured 鏅€?channel 瑁呭寮曠敤 request (one linked 濞佸+閲嶅墤). op424 was
|
|
// unhandled, so the message never left the client.
|
|
const currentCapturedItemHyperlinkChatRequestHex = "030100000000000d0000007f7f5bcec0cabfd6d8bda35d7f01" +
|
|
"b36bffffcf6a00007fab2f1700002d0000000000000000" +
|
|
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
|
|
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
|
|
"0000000000000000000000000000000000000000000000000000000000"
|
|
|
|
func TestCurrentCapturedItemHyperlinkChatKeepsOpaqueEntries(t *testing.T) {
|
|
body, err := hex.DecodeString(currentCapturedItemHyperlinkChatRequestHex)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(body) != 173 {
|
|
t.Fatalf("captured op424 body len=%d want 173", len(body))
|
|
}
|
|
request, ok := parseCurrentTownChatHyperlinkRequest(body)
|
|
if !ok {
|
|
t.Fatalf("captured op424 request rejected: %x", body)
|
|
}
|
|
if request.Mode != currentTownChatAreaMode {
|
|
t.Fatalf("captured op424 mode=%d want %d", request.Mode, currentTownChatAreaMode)
|
|
}
|
|
if request.ItemCount != 1 || len(request.ItemData) != 148 {
|
|
t.Fatalf("captured op424 links=%d bytes=%d want 1 link of 148 bytes", request.ItemCount, len(request.ItemData))
|
|
}
|
|
if !request.hasItemHyperlinks() {
|
|
t.Fatal("captured op424 request does not report its links")
|
|
}
|
|
if !bytes.Equal(request.ItemData, body[25:]) {
|
|
t.Fatalf("entries were rewritten: %x", request.ItemData)
|
|
}
|
|
|
|
// The plain op17 decoder must keep rejecting it: its notification has no
|
|
// room for the entries, so accepting it would drop the linked items.
|
|
if _, plainOK := parseCurrentTownChatRequest(body); plainOK {
|
|
t.Fatal("op17 decoder accepted an op424 body")
|
|
}
|
|
|
|
// The message keeps the client's own 0x7F-bracketed item name.
|
|
if !bytes.Contains(request.Message, []byte{0x7f}) {
|
|
t.Fatalf("captured op424 message lost its link markers: %x", request.Message)
|
|
}
|
|
|
|
// Each 瑁呭寮曠敤 notification is its plain twin's body followed by the count
|
|
// and the entries, which is the shape sub_2717A70 reads. The ids sit one
|
|
// slot below the 86JP reference's 371/372/373 鈥?the same shift its cmd table
|
|
// has at ITEM_HYPERLINK_MESSAGE 鈥?and sending 86JP's numbering displayed
|
|
// nothing live.
|
|
if currentTownChatHyperlinkMsgID != 370 {
|
|
t.Fatalf("op12 hyperlink twin=%d want 370", currentTownChatHyperlinkMsgID)
|
|
}
|
|
if currentPeerChatHyperlinkNotifyOpcode != 372 {
|
|
t.Fatalf("op118 hyperlink twin=%d want 372", currentPeerChatHyperlinkNotifyOpcode)
|
|
}
|
|
for _, base := range [][]byte{
|
|
buildCurrentTownChatNotification(request.Mode, 1, request.Message),
|
|
buildCurrentHyperlinkChatNoticeBody(request.Mode, currentChatServerGroup, 121, 1,
|
|
rosterNameBytes("speaker"), request.Message),
|
|
} {
|
|
notice := appendCurrentChatItemHyperlinks(base, request)
|
|
if !bytes.Equal(notice[:len(base)], base) || notice[len(base)] != 1 ||
|
|
!bytes.Equal(notice[len(base)+1:], request.ItemData) {
|
|
t.Fatalf("hyperlink notice=%x base=%x", notice, base)
|
|
}
|
|
if len(notice) != len(base)+1+148 {
|
|
t.Fatalf("hyperlink notice len=%d want base %d + 1 + 148", len(notice), len(base))
|
|
}
|
|
}
|
|
|
|
// op372 puts the reader's channel in byte 3, where op118 puts it in byte 4.
|
|
// Reusing op118's order left a zero in the slot gate 0x272936C reads and the
|
|
// guild 瑁呭寮曠敤 line rendered nowhere.
|
|
const readerChannel = byte(251)
|
|
plain := buildCurrentGuildChatNoticeBody(request.Mode, currentChatServerGroup, readerChannel, 1,
|
|
rosterNameBytes("speaker"), request.Message)
|
|
linked := buildCurrentHyperlinkChatNoticeBody(request.Mode, currentChatServerGroup, readerChannel, 1,
|
|
rosterNameBytes("speaker"), request.Message)
|
|
if plain[1] != currentChatServerGroup || linked[1] != currentChatServerGroup {
|
|
t.Fatalf("server group slot moved: plain=%x linked=%x", plain[:4], linked[:4])
|
|
}
|
|
// Both bodies put the channel in byte 4: sub_1D5B370 gates on [ebp-0xCCE]
|
|
// and sub_2729060 on [ebp-0xCCF], each the fourth byte read.
|
|
if plain[3] != readerChannel || plain[2] != 0 {
|
|
t.Fatalf("op118 body should carry the channel in byte 4: %x", plain[:4])
|
|
}
|
|
if linked[3] != readerChannel || linked[2] != 0 {
|
|
t.Fatalf("op372 body should carry the channel in byte 4: %x", linked[:4])
|
|
}
|
|
}
|
|
|
|
func TestCurrentItemHyperlinkChatRejectsBrokenEntryPayloads(t *testing.T) {
|
|
base := buildCurrentTownChatRequestForTest(currentTownChatAreaMode, []byte("link"))
|
|
for name, body := range map[string][]byte{
|
|
"missing_count": base,
|
|
"zero_count": append(append([]byte(nil), base...), 0x00),
|
|
"count_without_entry": append(append([]byte(nil), base...), 0x01),
|
|
"entries_not_divisble": append(append(append([]byte(nil), base...), 0x02), make([]byte, 9)...),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, ok := parseCurrentTownChatHyperlinkRequest(body); ok {
|
|
t.Fatalf("accepted broken op424 body: %x", body)
|
|
}
|
|
})
|
|
}
|
|
good := append(append(append([]byte(nil), base...), 0x02), make([]byte, 8)...)
|
|
request, ok := parseCurrentTownChatHyperlinkRequest(good)
|
|
if !ok || request.ItemCount != 2 || len(request.ItemData) != 8 {
|
|
t.Fatalf("two-link request ok=%t request=%+v", ok, request)
|
|
}
|
|
}
|
|
|
|
func buildCurrentTownChatRequestForTest(mode byte, message []byte) []byte {
|
|
body := make([]byte, currentTownChatRequestPrefixLen+len(message))
|
|
body[0] = mode
|
|
binary.LittleEndian.PutUint32(body[7:11], uint32(len(message)))
|
|
copy(body[currentTownChatRequestPrefixLen:], message)
|
|
return body
|
|
}
|
|
|
|
func buildCurrentPrivateTownChatRequestForTest(mode byte, targetID uint16, message, targetName []byte) []byte {
|
|
body := buildCurrentTownChatRequestForTest(mode, message)
|
|
binary.LittleEndian.PutUint16(body[1:3], targetID)
|
|
nameLength := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(nameLength, uint32(len(targetName)))
|
|
body = append(body, nameLength...)
|
|
body = append(body, targetName...)
|
|
return append(body, 0)
|
|
}
|