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

568 lines
25 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"testing"
"time"
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// The blacklist answer (class-1 opcode 120) is
//
// u8 flag | u8 count | count * ( u32 nameLength | name | u8 year | u8 month | u8 day )
//
// with the flag byte consumed as the reader's second argument before the payload.
// These tests pin that layout, the store round-trip behind it, and the two rejection
// statuses of the 1:1 gate that reads the same store.
func TestCurrentBlacklistValueRoundTripsNamesAndDates(t *testing.T) {
records := []currentBlacklistRecord{
{Name: "alpha", At: 1758499200},
{Name: "beta"},
{Name: "gamma", At: 1758585600},
}
encoded := formatCurrentBlacklistValue(records)
if encoded != "alpha\t1758499200\nbeta\ngamma\t1758585600" {
t.Fatalf("encoded value = %q", encoded)
}
decoded := parseCurrentBlacklistValue(encoded)
if len(decoded) != len(records) {
t.Fatalf("decoded = %+v", decoded)
}
for index, want := range records {
if decoded[index] != want {
t.Fatalf("decoded[%d] = %+v want %+v", index, decoded[index], want)
}
}
}
// Saves written before the block date was tracked hold bare names.
func TestCurrentBlacklistValueAcceptsDateLessLegacyLines(t *testing.T) {
decoded := parseCurrentBlacklistValue("alpha\n\n beta \n")
if len(decoded) != 2 {
t.Fatalf("decoded = %+v", decoded)
}
if decoded[0].Name != "alpha" || decoded[0].At != 0 {
t.Fatalf("decoded[0] = %+v", decoded[0])
}
if decoded[1].Name != "beta" || decoded[1].At != 0 {
t.Fatalf("decoded[1] = %+v", decoded[1])
}
}
func TestCurrentBlacklistDateFieldsProjectYearMonthDay(t *testing.T) {
at := time.Date(2026, time.September, 22, 13, 4, 5, 0, time.Local).Unix()
year, month, day := currentBlacklistDateFields(at)
if year != 26 || month != 9 || day != 22 {
t.Fatalf("date fields = %d/%d/%d want 26/9/22", year, month, day)
}
if year, month, day := currentBlacklistDateFields(0); year != 0 || month != 0 || day != 0 {
t.Fatalf("unknown date must project to zeros, got %d/%d/%d", year, month, day)
}
}
func TestSaveAndLoadCurrentBlacklistRecordsRoundTrip(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
ctx := context.Background()
want := []currentBlacklistRecord{
{Name: "alpha", At: 1758499200},
{Name: "beta", At: 1758585600},
}
if err := service.saveCurrentBlacklistRecords(ctx, 12, want); err != nil {
t.Fatalf("save: %v", err)
}
got := service.loadCurrentBlacklistRecords(ctx, 12)
sorted := currentBlacklistRecordsSorted(want)
if len(got) != len(sorted) {
t.Fatalf("loaded = %+v want %+v", got, sorted)
}
for index := range sorted {
if got[index] != sorted[index] {
t.Fatalf("loaded[%d] = %+v want %+v", index, got[index], sorted[index])
}
}
if names := service.loadCurrentBlacklist(ctx, 12); len(names) != 2 || names[0] != "alpha" || names[1] != "beta" {
t.Fatalf("names = %v", names)
}
if other := service.loadCurrentBlacklistRecords(ctx, 13); len(other) != 0 {
t.Fatalf("character 13 must have its own list, got %+v", other)
}
}
// The empty answer must stay byte for byte what this path sent before the layout
// was known: flag 0 makes the client's reader return without touching its list.
func TestBuildCurrentBlacklistListBodyEmptyKeepsLegacyBytes(t *testing.T) {
if body := buildCurrentBlacklistListBody(nil); !bytes.Equal(body, currentRequestBlacklistResponseBody) {
t.Fatalf("empty body = %x want legacy %x", body, currentRequestBlacklistResponseBody)
}
if body := buildCurrentBlacklistListBody([]currentBlacklistRecord{}); !bytes.Equal(body, []byte{0, 0}) {
t.Fatalf("empty body = %x want 0000", body)
}
if len(currentRequestBlacklistResponseBody) != 2 {
t.Fatalf("legacy empty body len = %d want 2", len(currentRequestBlacklistResponseBody))
}
}
func TestBuildCurrentBlacklistListBodyLaysOutFlagCountAndEntries(t *testing.T) {
at := time.Date(2026, time.September, 22, 13, 4, 5, 0, time.Local).Unix()
body := buildCurrentBlacklistListBody([]currentBlacklistRecord{{Name: "test", At: at}})
name := rosterNameBytes("test")
want := []byte{1, 1}
want = append(want, byte(len(name)), 0, 0, 0)
want = append(want, name...)
want = append(want, 26, 9, 22)
if !bytes.Equal(body, want) {
t.Fatalf("body = %x want %x", body, want)
}
// Two entries repeat the same record shape back to back, with no padding.
second := buildCurrentBlacklistListBody([]currentBlacklistRecord{
{Name: "test", At: at},
{Name: "beta"},
})
beta := rosterNameBytes("beta")
wantTwo := append([]byte{}, want...)
wantTwo[1] = 2
wantTwo = append(wantTwo, byte(len(beta)), 0, 0, 0)
wantTwo = append(wantTwo, beta...)
wantTwo = append(wantTwo, 0, 0, 0)
if !bytes.Equal(second, wantTwo) {
t.Fatalf("two-entry body = %x want %x", second, wantTwo)
}
// An entry with no stored date still carries its three bytes.
if len(second) != len(want)+4+len(beta)+3 {
t.Fatalf("two-entry body len = %d", len(second))
}
}
// The register ack is `flag(1) | u32 nameLength | name` -- 9 bytes for a
// four-character name. It used to write a second, redundant length field, which the
// client consumed as the name itself and rendered as a garbled row.
func TestHandleCurrentBlacklistRegisterAckWritesFlagThenSingleNameLength(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
request := append([]byte{4, 0, 0, 0}, []byte("test")...)
if err := service.handleGameCommand(source, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRegisiterToBlacklist), request); err != nil {
t.Fatalf("register: %v", err)
}
ack, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if ack.Header.Classification != 1 || ack.Header.MsgID != uint16(dnfenum.CmdPacketRegisiterToBlacklist) {
t.Fatalf("ack header = %+v", ack.Header)
}
want := append([]byte{1, 4, 0, 0, 0}, []byte("test")...)
if !bytes.Equal(ack.Body, want) {
t.Fatalf("ack body = %x want %x", ack.Body, want)
}
if len(rest) != 0 {
t.Fatalf("register trailing bytes = %x", rest)
}
if records := service.loadCurrentBlacklistRecords(context.Background(), source.selectedCharacterID); len(records) != 1 || records[0].Name != "test" {
t.Fatalf("stored records = %+v", records)
}
}
// The headline fix: a blocked name survives a relog because the answer the new
// session gets carries the stored list instead of the fixed empty body.
func TestRequestBlacklistAfterRelogReturnsTheStoredList(t *testing.T) {
service, source, _, _, _ := newCurrentSocialPeerTest(t)
register := append([]byte{4, 0, 0, 0}, []byte("test")...)
if err := service.handleGameCommand(source, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRegisiterToBlacklist), register); err != nil {
t.Fatalf("register: %v", err)
}
// A fresh session for the same character is what a relog looks like. The scene
// tail has committed by the time the client asks again, which is the moment the
// answer is allowed out (see sendRequestBlacklistState).
relogConn := &bufferConn{}
relog := &gameSession{conn: relogConn, channel: source.channel, accountID: "dnf:1", selectedCharacterID: 1,
sceneBootstrapTailSent: true}
if err := service.handleGameCommand(relog, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRequestBlacklist), make([]byte, 16)); err != nil {
t.Fatalf("request blacklist: %v", err)
}
packet, _ := splitGameServerUpperPacket(t, relogConn.write.Bytes())
if packet.Header.Classification != 1 || packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestBlacklist) {
t.Fatalf("answer header = %+v", packet.Header)
}
want := buildCurrentBlacklistListBody(service.loadCurrentBlacklistRecords(context.Background(), 1))
if !bytes.Equal(packet.Body, want) {
t.Fatalf("answer body = %x want %x", packet.Body, want)
}
if len(packet.Body) < 3 || packet.Body[0] != 1 || packet.Body[1] != 1 {
t.Fatalf("answer body = %x want flag 1 and count 1", packet.Body)
}
name := rosterNameBytes("test")
nameAt := 2
if got := packet.Body[nameAt : nameAt+4]; !bytes.Equal(got, []byte{byte(len(name)), 0, 0, 0}) {
t.Fatalf("name length field = %x want %x", got, []byte{byte(len(name)), 0, 0, 0})
}
if got := packet.Body[nameAt+4 : nameAt+4+len(name)]; !bytes.Equal(got, name) {
t.Fatalf("name field = %x want %x", got, name)
}
year, month, day := currentBlacklistDateFields(service.loadCurrentBlacklistRecords(context.Background(), 1)[0].At)
tail := packet.Body[nameAt+4+len(name):]
if !bytes.Equal(tail, []byte{year, month, day}) {
t.Fatalf("date tail = %x want %x", tail, []byte{year, month, day})
}
}
func TestRequestBlacklistWithoutBlocksKeepsLegacyBytes(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
source.sceneBootstrapTailSent = true
if err := service.handleGameCommand(source, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRequestBlacklist), make([]byte, 16)); err != nil {
t.Fatalf("request blacklist: %v", err)
}
packet, _ := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if !bytes.Equal(packet.Body, currentRequestBlacklistResponseBody) {
t.Fatalf("answer body = %x want %x", packet.Body, currentRequestBlacklistResponseBody)
}
}
// A request that arrives while the select-character scene tail is still pending must
// not be answered: a row the client builds in that window loses its 黑名单日期 (live
// 2026-09-22 23:46, see sendRequestBlacklistState). The answer is flushed when the
// tail commits instead.
func TestRequestBlacklistIsHeldUntilTheSceneTailCommits(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
ctx := context.Background()
if err := service.saveCurrentBlacklistRecords(ctx, source.selectedCharacterID,
[]currentBlacklistRecord{{Name: "test", At: 1790091427}}); err != nil {
t.Fatalf("store blacklist: %v", err)
}
if err := service.handleGameCommand(source, byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestBlacklist), make([]byte, 16)); err != nil {
t.Fatalf("request blacklist: %v", err)
}
if sent := sourceConn.write.Bytes(); len(sent) != 0 {
t.Fatalf("the answer must be held while the tail is pending, got %x", sent)
}
if !source.blacklistListAnswerPending {
t.Fatal("the held answer was not recorded")
}
// The tail commit flushes it with the stored list.
source.sceneBootstrapTailSent = true
if err := service.sendCurrentBlacklistListAnswer(source, "test_after_scene_tail", nil); err != nil {
t.Fatalf("flush: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
want := buildCurrentBlacklistListBody(service.loadCurrentBlacklistRecords(ctx, source.selectedCharacterID))
if packet.Header.Classification != 1 || packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestBlacklist) ||
!bytes.Equal(packet.Body, want) || len(rest) != 0 {
t.Fatalf("flushed answer header=%+v body=%x want=%x rest=%x", packet.Header, packet.Body, want, rest)
}
if source.blacklistListAnswerPending {
t.Fatal("the pending flag must clear once the answer is sent")
}
}
// Blocking is one-way: A blocking B writes A's list only, and B's scope must not even
// be created. (Operator note 2026-09-22: "拉黑是单向操作 A拉黑了B B的黑名单列表内不应该出现A".)
func TestBlacklistIsOneWay(t *testing.T) {
service, source, target, _, _ := newCurrentSocialPeerTest(t)
ctx := context.Background()
request := append([]byte{byte(len(currentSocialTestFriendName)), 0, 0, 0},
rosterNameBytes(currentSocialTestFriendName)...)
if err := service.handleGameCommand(source, byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRegisiterToBlacklist), request); err != nil {
t.Fatalf("register: %v", err)
}
blocked := service.loadCurrentBlacklistRecords(ctx, source.selectedCharacterID)
if len(blocked) != 1 || blocked[0].Name != currentSocialTestFriendName {
t.Fatalf("blocker's list = %+v", blocked)
}
if peer := service.loadCurrentBlacklistRecords(ctx, target.selectedCharacterID); len(peer) != 0 {
t.Fatalf("the blocked character's list must stay empty, got %+v", peer)
}
repos, ok := service.repositoryGroup()
if !ok {
t.Fatal("no repository")
}
if _, found, err := repos.Settings.Load(ctx, currentBlacklistScope(target.selectedCharacterID)); err != nil || found {
t.Fatalf("the blocked character's scope must not be created: found=%v err=%v", found, err)
}
// The gate is still symmetric: the blocked character cannot message the blocker
// either, and the blocker's own delete only clears the blocker's list.
if _, err := service.handleAlignedGameCommand(target, byte(dnfenum.GameCmdCommand),
currentPeerChatStateOpcode,
buildCurrentPrivateTownChatRequestForTest(currentTownChatPeerMenuMode, source.selectedCharacterID,
[]byte("hello"), rosterNameBytes("source1"))); err != nil {
t.Fatalf("blocked peer chat: %v", err)
}
if err := service.handleGameCommand(source, byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketDeleteToBlacklist), request); err != nil {
t.Fatalf("delete: %v", err)
}
if left := service.loadCurrentBlacklistRecords(ctx, source.selectedCharacterID); len(left) != 0 {
t.Fatalf("delete must clear the blocker's list, got %+v", left)
}
if peer := service.loadCurrentBlacklistRecords(ctx, target.selectedCharacterID); len(peer) != 0 {
t.Fatalf("delete must not touch the other character, got %+v", peer)
}
}
// Regression for the operator report 2026-09-22 23:25: both characters had blocked each
// other (packet_log: blocked_by_target=true blocked_by_source=true status=54), so the
// gate answers with the sender's own block (dstr 4771) inside the peer-menu window.
// Routing the peer's block first was tried and rolled back on the operator's call.
func TestCurrentOneToOneBlacklistGateReportsTheSendersOwnBlock(t *testing.T) {
service, source, target, sourceConn, _ := newCurrentSocialPeerTest(t)
ctx := context.Background()
for _, blocked := range []struct {
characterID uint16
name string
}{
{characterID: target.selectedCharacterID, name: "source1"},
{characterID: source.selectedCharacterID, name: currentSocialTestFriendName},
} {
if err := service.saveCurrentBlacklistRecords(ctx, blocked.characterID,
[]currentBlacklistRecord{{Name: blocked.name, At: 1758499200}}); err != nil {
t.Fatalf("store blacklist for %d: %v", blocked.characterID, err)
}
}
request := buildCurrentPrivateTownChatRequestForTest(currentTownChatPeerMenuMode,
target.selectedCharacterID, []byte("hello"), rosterNameBytes(currentSocialTestFriendName))
if _, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentPeerChatStateOpcode, request); err != nil {
t.Fatalf("peer chat: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
var want packetWriter
want.writeByte(currentTownChatPeerMenuMode)
want.writeByte(currentBlacklistStatusSenderBlockedTarget)
want.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
want.writeByte(currentChatIncomingLineFlag)
want.writeRawDstr([]byte("hello"))
want.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
if packet.Header.Classification != 0 || packet.Header.MsgID != currentTownChatOneToOneStateMsgID ||
!bytes.Equal(packet.Body, want.bytes()) || len(rest) != 0 {
t.Fatalf("blocked notice header=%+v body=%x want=%x rest=%x",
packet.Header, packet.Body, want.bytes(), rest)
}
}
// The 1:1 gate reports the two directions with the client's own status codes, and
// every other mode keeps the historical failure ack instead of a packet the client
// would not understand.
func TestCurrentChatRejectionNoticeCoversBothBlacklistStatuses(t *testing.T) {
for _, test := range []struct {
name string
status byte
}{
{name: "peer blocked the sender", status: currentBlacklistStatusTargetBlockedSender},
{name: "sender blocked the peer", status: currentBlacklistStatusSenderBlockedTarget},
} {
t.Run(test.name, func(t *testing.T) {
conn := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: conn, selectedCharacterID: 1}
if err := service.sendCurrentChatRejectionNotice(session, currentTownChatPeerMenuMode, test.status, "test", []byte("hello")); err != nil {
t.Fatalf("notice: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if packet.Header.Classification != 0 || packet.Header.MsgID != currentTownChatOneToOneStateMsgID {
t.Fatalf("notice header = %+v", packet.Header)
}
var want packetWriter
want.writeByte(currentTownChatPeerMenuMode)
want.writeByte(test.status)
want.writeRawDstr(rosterNameBytes("test"))
want.writeByte(currentChatIncomingLineFlag)
want.writeRawDstr([]byte("hello"))
want.writeRawDstr(rosterNameBytes("test"))
if !bytes.Equal(packet.Body, want.bytes()) {
t.Fatalf("notice body = %x want %x", packet.Body, want.bytes())
}
if len(rest) != 0 {
t.Fatalf("notice trailing bytes = %x", rest)
}
})
}
// A typed whisper has its own rejected branch in the client, whose body stops
// after the text dstr (the trailing "extra" one is read only in the peer-menu
// window).
for _, status := range []byte{currentBlacklistStatusTargetBlockedSender, currentBlacklistStatusSenderBlockedTarget} {
conn := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: conn, selectedCharacterID: 1}
if err := service.sendCurrentChatRejectionNotice(session, currentTownChatWhisperMode, status, "test", []byte("hello")); err != nil {
t.Fatalf("whisper notice: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if packet.Header.Classification != 0 || packet.Header.MsgID != currentTownChatOneToOneStateMsgID {
t.Fatalf("whisper notice header = %+v", packet.Header)
}
var want packetWriter
want.writeByte(currentTownChatWhisperMode)
want.writeByte(status)
want.writeRawDstr(rosterNameBytes("test"))
want.writeByte(currentChatIncomingLineFlag)
want.writeRawDstr([]byte("hello"))
if !bytes.Equal(packet.Body, want.bytes()) {
t.Fatalf("whisper notice body = %x want %x", packet.Body, want.bytes())
}
if len(rest) != 0 {
t.Fatalf("whisper notice trailing bytes = %x", rest)
}
}
// Modes whose client handler has no rejected branch keep the plain failure ack.
for _, mode := range []byte{currentTownChatOneToOneMode, currentTownChatPartyMode} {
conn := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: conn, selectedCharacterID: 1}
if err := service.sendCurrentChatRejectionNotice(session, mode, currentBlacklistStatusTargetBlockedSender, "test", []byte("hello")); err != nil {
t.Fatalf("notice mode %#x: %v", mode, err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if packet.Header.Classification != 1 || packet.Header.MsgID != currentPeerChatStateOpcode ||
!bytes.Equal(packet.Body, []byte{0, currentTownChatGenericError}) {
t.Fatalf("mode %#x ack header=%+v body=%x", mode, packet.Header, packet.Body)
}
if len(rest) != 0 {
t.Fatalf("mode %#x trailing bytes = %x", mode, rest)
}
}
}
// A typed whisper must be refused too, and must tell the sender why: only the 1:1
// window path carried the blacklist gate before (operator report 2026-09-22 23:5x:
// "悄悄话现在并不能拦截并提示").
func TestCurrentWhisperChatAppliesTheBlacklistGate(t *testing.T) {
for _, test := range []struct {
name string
blockedBy string // "target" = the recipient blocked the sender
wantPeer bool // whether the recipient receives the line
}{
{name: "recipient blocked the sender", blockedBy: "target", wantPeer: false},
{name: "sender blocked the recipient", blockedBy: "source", wantPeer: false},
} {
t.Run(test.name, func(t *testing.T) {
service, source, target, sourceConn, targetConn := newCurrentSocialPeerTest(t)
ctx := context.Background()
// blockedBy "target": the recipient (friend5) blocked the sender (source1).
// blockedBy "source": the sender blocked the recipient.
owner, blockedName := source.selectedCharacterID, currentSocialTestFriendName
if test.blockedBy == "target" {
owner, blockedName = target.selectedCharacterID, "source1"
}
if err := service.saveCurrentBlacklistRecords(ctx, owner,
[]currentBlacklistRecord{{Name: blockedName, At: 1758499200}}); err != nil {
t.Fatalf("store blacklist: %v", err)
}
request := buildCurrentPrivateTownChatRequestForTest(currentTownChatWhisperMode,
target.selectedCharacterID, []byte("hello"), rosterNameBytes(currentSocialTestFriendName))
if _, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentPeerChatStateOpcode, request); err != nil {
t.Fatalf("whisper: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
var want packetWriter
want.writeByte(currentTownChatWhisperMode)
if test.blockedBy == "target" {
want.writeByte(currentBlacklistStatusTargetBlockedSender)
} else {
want.writeByte(currentBlacklistStatusSenderBlockedTarget)
}
want.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
want.writeByte(currentChatIncomingLineFlag)
want.writeRawDstr([]byte("hello"))
if packet.Header.Classification != 0 || packet.Header.MsgID != currentTownChatOneToOneStateMsgID ||
!bytes.Equal(packet.Body, want.bytes()) || len(rest) != 0 {
t.Fatalf("whisper rejection header=%+v body=%x want=%x rest=%x",
packet.Header, packet.Body, want.bytes(), rest)
}
if delivered := targetConn.write.Bytes(); len(delivered) != 0 {
t.Fatalf("the refused whisper still reached the recipient: %x", delivered)
}
})
}
}
// The upper/class-1 transport reaches 118/119 through the aligned owner, which used
// to answer by echoing the name without storing anything -- so a block made over
// that transport was lost on relog. Both transports must now persist.
func TestUpperTransportBlacklistMutationReachesTheStore(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
request := append([]byte{4, 0, 0, 0}, []byte("test")...)
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRegisiterToBlacklist), request)
if err != nil {
t.Fatalf("aligned register: %v", err)
}
if !handled {
t.Fatal("aligned register was not handled")
}
records := service.loadCurrentBlacklistRecords(context.Background(), source.selectedCharacterID)
if len(records) != 1 || records[0].Name != "test" {
t.Fatalf("aligned register did not reach the store: %+v", records)
}
ack, _ := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
want := append([]byte{1, 4, 0, 0, 0}, []byte("test")...)
if !bytes.Equal(ack.Body, want) {
t.Fatalf("aligned ack body = %x want %x", ack.Body, want)
}
sourceConn.write.Reset()
if _, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketDeleteToBlacklist), request); err != nil {
t.Fatalf("aligned delete: %v", err)
}
if records := service.loadCurrentBlacklistRecords(context.Background(), source.selectedCharacterID); len(records) != 0 {
t.Fatalf("aligned delete did not reach the store: %+v", records)
}
}
// A session with no repository keeps the historic ack-only echo.
func TestAlignedBlacklistMutationWithoutStoreKeepsEchoAck(t *testing.T) {
conn := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: conn, selectedCharacterID: 1}
var request packetWriter
request.writeRawDstr(rosterNameBytes("friend5"))
handled, err := service.handleAlignedGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRegisiterToBlacklist), request.bytes())
if err != nil || !handled {
t.Fatalf("handled=%v err=%v", handled, err)
}
packet, _ := splitGameServerUpperPacket(t, conn.write.Bytes())
if want := append([]byte{1}, request.bytes()...); !bytes.Equal(packet.Body, want) {
t.Fatalf("echo ack = %x want %x", packet.Body, want)
}
}
// Client names arrive in the wire's GB18030 form (live 1:1 capture: c0 b4 c0 b4 c0 b4
// for 来来来). The stored name must be the decoded spelling so the repository compare
// matches, and the ack must re-encode to the very bytes the client sent.
func TestBlacklistStoresClientGB18030NamesVerbatim(t *testing.T) {
raw := []byte{0xc0, 0xb4, 0xc0, 0xb4, 0xc0, 0xb4}
body := append([]byte{byte(len(raw)), 0, 0, 0}, raw...)
name, ok := parseCurrentBlacklistName(body)
if !ok {
t.Fatal("GB18030 name was rejected")
}
if name != "来来来" {
t.Fatalf("decoded name = %q want 来来来", name)
}
if encoded := rosterNameBytes(name); !bytes.Equal(encoded, raw) {
t.Fatalf("re-encoded name = %x want %x", encoded, raw)
}
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
if err := service.handleGameCommand(source, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRegisiterToBlacklist), body); err != nil {
t.Fatalf("register: %v", err)
}
ack, _ := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
want := append([]byte{1, byte(len(raw)), 0, 0, 0}, raw...)
if !bytes.Equal(ack.Body, want) {
t.Fatalf("GB18030 ack = %x want %x", ack.Body, want)
}
listed := buildCurrentBlacklistListBody(service.loadCurrentBlacklistRecords(context.Background(), 1))
if !bytes.Contains(listed, raw) {
t.Fatalf("list answer lost the client's name bytes: %x", listed)
}
if !service.currentBlacklistHas(context.Background(), 1, "来来来") {
t.Fatal("stored GB18030 name is not comparable with the repository spelling")
}
}