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

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(&copy)
}
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)
}