DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,567 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user