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

2064 lines
86 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func currentGuildTestDSTR(value string) []byte {
var writer packetWriter
writeClientNameDstr(&writer, value)
return writer.bytes()
}
// isCurrentGuildAuraBody reports whether body is the 公会徽章 (guild aura)
// appearance block that writeCurrentGuildAuraState emits.
//
// It matters because that block rides opcode CmdPacketSetUDPIPPort -- the very
// same opcode the actor creation/binding packets use -- so a test that walks a
// wire capture looking for the actor packet now meets the aura first. The block
// is a fixed 32 bytes opening with the mode-6 scene-object route header, while
// every mode0/mode1 body is far longer, so this discriminates cleanly.
func isCurrentGuildAuraBody(body []byte) bool {
return len(body) == 32 && body[0] == 6 && body[1] == 1 && body[2] == 0 && body[3] == currentSceneObjectRoute
}
func assertCurrentGuildActorState(t *testing.T, body []byte, guildID, mark uint32, name string) {
t.Helper()
_, tailStart, ok := currentSceneObjectLevelForLog(body)
if !ok {
t.Fatalf("missing actor tail: %x", body)
}
tail := body[tailStart:]
equipEnd, ok := currentSceneObjectEquipSummaryEnd(tail, 6)
if !ok {
t.Fatalf("invalid actor equipment: %x", tail)
}
guildIDOffset := equipEnd + 4 + 4 + 8 + 1
if got := binary.LittleEndian.Uint32(tail[guildIDOffset:]); got != guildID {
t.Fatalf("actor guild ID=%d want=%d", got, guildID)
}
nameOffset := currentMode0OrganizationDSTRStartForTest(t, tail)
gotName, next, ok := readCurrentRawDstr(tail, nameOffset)
if !ok || !bytes.Equal(gotName, rosterNameBytes(name)) {
t.Fatalf("actor guild name=%x want=%q", gotName, name)
}
if got := binary.LittleEndian.Uint32(tail[next:]); got != mark {
t.Fatalf("actor guild mark=%d want=%d", got, mark)
}
}
func TestCurrentGuildActorMembershipRefreshLifecycle(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
member := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:2", Name: "member", Level: 80}
for _, character := range []dnfrepo.CharacterRecord{master, member} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild", serviceTestNow())
if err != nil {
t.Fatal(err)
}
guild.Level = 3
guild.Mark = 4
// 审批流用例:公会必须是「接受后加入」(JoinMode 0),否则默认的立即加入会直接进会。
guild.JoinMode = 0
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
masterSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1, accountID: master.AccountID}
memberSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 2, accountID: member.AccountID}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{1: masterSession, 2: memberSession},
}
// Login and peer appearance must resolve the actor's membership, not the viewer's.
for _, owner := range []byte{currentSceneObjectContext, 1} {
body := service.buildCurrentSceneObjectListBodyForSessionInContext(ctx, memberSession, 1, master.Name, master, true, owner)
assertCurrentGuildActorState(t, body, guild.ID, 4, guild.Name)
member.Stats = map[string]int64{"__scene_guild_id": 99, "__scene_guild_mark": 9}
body = service.buildCurrentSceneObjectListBodyForSessionInContext(ctx, masterSession, 2, member.Name, member, true, owner)
assertCurrentGuildActorState(t, body, 0, 0, "")
if member.Stats["__scene_guild_id"] != 99 || master.Stats != nil {
t.Fatal("actor projection mutated source character stats")
}
}
assertRefresh := func(session *gameSession, wantID, wantLevel uint32, wantName string) {
t.Helper()
seen := false
for wire := session.conn.(*bufferConn).write.Bytes(); len(wire) != 0; {
packet, rest := splitGameServerUpperPacket(t, wire)
wire = rest
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) {
assertCurrentGuildActorState(t, packet.Body, wantID, wantLevel, wantName)
seen = true
}
if packet.Header.MsgID == currentGuildMembershipNotificationMsgID && !seen {
t.Fatal("guild board updated before actor membership")
}
}
if !seen {
t.Fatal("missing actor membership refresh")
}
session.conn.(*bufferConn).write.Reset()
}
applyBody := append(currentGuildTestDSTR(guild.Name), currentGuildTestDSTR("please")...)
if err := service.handleCurrentGuildApply(memberSession, applyBody); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildApprove(masterSession, []byte{2, 0, 0, 0, currentGuildMemberRegion}); err != nil {
t.Fatal(err)
}
assertRefresh(memberSession, guild.ID, 4, guild.Name)
if err := service.handleCurrentGuildLeave(memberSession, nil); err != nil {
t.Fatal(err)
}
assertRefresh(memberSession, 0, 0, "")
if err := service.handleCurrentGuildInvite(masterSession, currentGuildTestDSTR(member.Name)); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildInviteReply(memberSession, []byte{1}); err != nil {
t.Fatal(err)
}
assertRefresh(memberSession, guild.ID, 4, guild.Name)
if err := service.handleCurrentGuildDisband(masterSession, nil); err != nil {
t.Fatal(err)
}
assertRefresh(memberSession, 0, 0, "")
assertRefresh(masterSession, 0, 0, "")
}
func currentGuildTestInvite(t *testing.T, service *Service, creator *gameSession) *gameSession {
t.Helper()
target := &gameSession{conn: &bufferConn{}, selectedCharacterID: 2, accountID: "dnf:2", characterGeneration: 2}
creator.characterGeneration = 1
service.gameSessions = map[uint16]*gameSession{1: creator, 2: target}
if err := service.handleCurrentGuildCreatePermit(creator, append([]byte{0}, currentGuildTestDSTR("helper")...)); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, target.conn.(*bufferConn).write.Bytes())
var want packetWriter
writeClientNameDstr(&want, "master")
want.writeByte(0)
writeClientNameDstr(&want, creator.guild.pendingName)
writeClientNameDstr(&want, "welcome")
if packet.Header.Classification != 0 || packet.Header.MsgID != currentGuildCreateInviteOpcode ||
!bytes.Equal(packet.Body, want.bytes()) || len(rest) != 0 {
t.Fatalf("invite=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
}
t.Cleanup(func() { service.closeCurrentGuildCreateSession(creator) })
return target
}
func TestCurrentGuildSearchDecoderMatchesObservedOpenWindowRequest(t *testing.T) {
request, ok := parseCurrentGuildSearchRequest([]byte{2, 0, 0, 0, 0, 0})
if !ok || request.Mode != 2 || request.Page != 0 || request.Category != 0 {
t.Fatalf("request=%+v ok=%t", request, ok)
}
nameBody := append([]byte{0}, currentGuildTestDSTR("guild")...)
request, ok = parseCurrentGuildSearchRequest(nameBody)
if !ok || request.Query != "guild" {
t.Fatalf("name request=%+v ok=%t", request, ok)
}
}
func TestCurrentGuildCreateCommitsGoldRegistryAndNativeProjection(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90, Stats: map[string]int64{"gold": 500000}},
{CharacterID: "2", AccountID: "dnf:2", Name: "helper", Level: 90, Stats: map[string]int64{"gold": 0}},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
session.initialTownRouteCharacterID = 1
session.initialTownRouteStage = currentInitialTownRoutePlayerStateSent
if err := service.handleCurrentGuildNameCheck(session, currentGuildTestDSTR("guild-one")); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildPromoterCheck(session, currentGuildTestDSTR("welcome")); err != nil {
t.Fatal(err)
}
target := currentGuildTestInvite(t, service, session)
before, _, err := repositories.Character.Load(ctx, "1")
if err != nil || before.Stats["gold"] != 500000 {
t.Fatalf("invitation charged gold: %+v err=%v", before, err)
}
if err := service.handleCurrentGuildCreatePermitReply(target, []byte{1}); err != nil {
t.Fatal(err)
}
before, _, err = repositories.Character.Load(ctx, "1")
if err != nil || before.Stats["gold"] != 500000 {
t.Fatalf("certification charged gold: %+v err=%v", before, err)
}
certifiedWireLength := connection.write.Len()
if err := service.handleCurrentGuildCreatePermitReply(target, []byte{1}); err != nil {
t.Fatal(err)
}
service.expireCurrentGuildCreateInvitation(session.guild.createInvitation)
if connection.write.Len() != certifiedWireLength || !session.guild.createInvitation.accepted {
t.Fatal("duplicate reply or stale expiry changed certification")
}
if err := service.handleCurrentGuildCreate(session, []byte{1}); err != nil {
t.Fatal(err)
}
character, found, err := repositories.Character.Load(ctx, "1")
if err != nil || !found || character.Stats["gold"] != 200000 {
t.Fatalf("character=%+v found=%t error=%v wire=%x", character, found, err, connection.write.Bytes())
}
registry, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
guild, member, found := registry.GuildForCharacter("1")
if !found || guild.Name != "guild-one" || guild.Promotion != "welcome" || member.Grade != dnfguild.DefaultMasterGrade {
t.Fatalf("guild=%+v member=%+v found=%t", guild, member, found)
}
if helperGuild, helper, found := registry.GuildForCharacter("2"); !found || helperGuild.ID != guild.ID || helper.Grade != dnfguild.DefaultMemberGrade {
t.Fatalf("certifying founder missing: guild=%+v member=%+v", helperGuild, helper)
}
wire := connection.write.Bytes()
want := []struct {
opcode uint16
body []byte
}{
{uint16(dnfenum.CmdPacketCheckGuildNameDouble), []byte{1}},
{currentGuildCreateAuthStateOpcode, []byte{currentGuildCreateAuthResetValue}},
{uint16(dnfenum.CmdPacketCheckGuildCreatePromoteMsg), []byte{1}},
{uint16(dnfenum.CmdPacketRequestGuildCreatePermit), []byte{1}},
{currentGuildCreateAuthStateOpcode, []byte{1}},
{uint16(dnfenum.CmdPacketCallGuildCreateRight), []byte{1}},
}
for index, expected := range want {
packet, rest := splitGameServerUpperPacket(t, wire)
if packet.Header.MsgID != expected.opcode || !bytes.Equal(packet.Body, expected.body) {
t.Fatalf("packet[%d] opcode=%d body=%x want_opcode=%d want_body=%x", index, packet.Header.MsgID, packet.Body, expected.opcode, expected.body)
}
wantClass := byte(1)
if expected.opcode == currentGuildCreateAuthStateOpcode {
wantClass = 0
}
if packet.Header.Classification != wantClass {
t.Fatalf("packet[%d] class=%d want=%d", index, packet.Header.Classification, wantClass)
}
wire = rest
}
goldPacket, rest := splitGameServerUpperPacket(t, wire)
if goldPacket.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("gold opcode=%d", goldPacket.Header.MsgID)
}
actorPacket, rest := splitGameServerUpperPacket(t, rest)
if actorPacket.Header.Classification != 0 || actorPacket.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) {
t.Fatalf("actor refresh header=%+v", actorPacket.Header)
}
assertCurrentGuildActorState(t, actorPacket.Body, guild.ID, uint32(guild.Mark), guild.Name)
managementPacket, rest := splitGameServerUpperPacket(t, rest)
gradePacket, rest := splitGameServerUpperPacket(t, rest)
if managementPacket.Header.Classification != 0 || managementPacket.Header.MsgID != 70 ||
gradePacket.Header.Classification != 0 || gradePacket.Header.MsgID != 127 || !bytes.Equal(gradePacket.Body, []byte{1}) {
t.Fatal("missing created guild management and master grade")
}
// writeCurrentGuildManagementState now ends with the 公会徽章 aura block and the
// DLL-private 公会经验排名 -- upstream places them in exactly this position --
// so they land between op127 and the membership notification. The new guild
// owns no Guild_Status benefit, so no 公会 BUFF row appears here.
auraPacket, rest := splitGameServerUpperPacket(t, rest)
if auraPacket.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) || !isCurrentGuildAuraBody(auraPacket.Body) {
t.Fatalf("created guild aura header=%+v body=%x", auraPacket.Header, auraPacket.Body)
}
rankingPacket, rest := splitGameServerUpperPacket(t, rest)
if rankingPacket.Header.MsgID != 413 || len(rankingPacket.Body) != 12 {
t.Fatalf("created guild ranking header=%+v body=%x", rankingPacket.Header, rankingPacket.Body)
}
statePacket, rest := splitGameServerUpperPacket(t, rest)
if statePacket.Header.Classification != 0 ||
statePacket.Header.MsgID != currentGuildMembershipNotificationMsgID ||
!bytes.Equal(statePacket.Body, buildCurrentGuildMembershipNotificationBody(guild)) {
t.Fatalf("state opcode=%d body=%x trailing=%x", statePacket.Header.MsgID, statePacket.Body, rest)
}
accountIndexPacket, rest := splitGameServerUpperPacket(t, rest)
if accountIndexPacket.Header.MsgID != uint16(dnfenum.CmdPacketRequestAccountGuildList) {
t.Fatalf("account index opcode=%d body=%x", accountIndexPacket.Header.MsgID, accountIndexPacket.Body)
}
assertCurrentGuildAccountIndexPacket(t, accountIndexPacket.Body, "welcome", 2, currentGuildTagsMask(guild))
accountListPacket, rest := splitGameServerUpperPacket(t, rest)
if accountListPacket.Header.MsgID != uint16(dnfenum.CmdPacketReqGuildInfoOfMyChars) {
t.Fatalf("account list opcode=%d body=%x", accountListPacket.Header.MsgID, accountListPacket.Body)
}
if !bytes.Equal(accountListPacket.Body, []byte{1, 0}) {
t.Fatalf("new guild master was projected as an applicant: body=%x", accountListPacket.Body)
}
if len(rest) != 0 {
t.Fatalf("trailing create packets=%x", rest)
}
connection.write.Reset()
stalePermit := append([]byte{0}, currentGuildTestDSTR("helper")...)
if err := service.handleCurrentGuildCreatePermit(session, stalePermit); err != nil {
t.Fatal(err)
}
permitPacket, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if permitPacket.Header.MsgID != uint16(dnfenum.CmdPacketRequestGuildCreatePermit) || !bytes.Equal(permitPacket.Body, []byte{0, 0}) || len(trailing) != 0 {
t.Fatalf("stale permit opcode=%d body=%x trailing=%x", permitPacket.Header.MsgID, permitPacket.Body, trailing)
}
if err := service.handleCurrentGuildCreate(session, []byte{1}); err != nil {
t.Fatal(err)
}
character, found, err = repositories.Character.Load(ctx, "1")
if err != nil || !found || character.Stats["gold"] != 200000 {
t.Fatalf("stale permit changed character=%+v found=%t error=%v", character, found, err)
}
registry, err = dnfguild.Load(ctx, repositories.Settings)
if err != nil || len(registry.Guilds) != 1 {
t.Fatalf("stale permit changed registry=%+v error=%v", registry.Guilds, err)
}
helper, _, err := repositories.Character.Load(ctx, "2")
if err != nil || helper.Stats["gold"] != 0 {
t.Fatalf("founder gold changed: %+v err=%v", helper, err)
}
membershipNotifications := 0
for wire := target.conn.(*bufferConn).write.Bytes(); len(wire) > 0; {
packet, rest := splitGameServerUpperPacket(t, wire)
wire = rest
if packet.Header.MsgID == currentGuildMembershipNotificationMsgID {
membershipNotifications++
if packet.Header.Classification != 0 || !bytes.Equal(packet.Body, buildCurrentGuildMembershipNotificationBody(guild)) {
t.Fatalf("founder membership=%+v", packet)
}
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) {
assertCurrentGuildActorState(t, packet.Body, guild.ID, uint32(guild.Mark), guild.Name)
}
}
if membershipNotifications != 1 {
t.Fatalf("founder membership notifications=%d", membershipNotifications)
}
}
func TestCurrentGuildCreateRejectsInsufficientGoldWithoutPersistingGuild(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90, Stats: map[string]int64{"gold": 299999}},
{CharacterID: "2", AccountID: "dnf:2", Name: "helper", Level: 90},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildNameCheck(session, currentGuildTestDSTR("guild-poor")); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildPromoterCheck(session, currentGuildTestDSTR("welcome")); err != nil {
t.Fatal(err)
}
target := currentGuildTestInvite(t, service, session)
if err := service.handleCurrentGuildCreatePermitReply(target, []byte{1}); err != nil {
t.Fatal(err)
}
connection.write.Reset()
if err := service.handleCurrentGuildCreate(session, []byte{1}); err != nil {
t.Fatal(err)
}
character, found, err := repositories.Character.Load(ctx, "1")
if err != nil || !found || character.Stats["gold"] != 299999 {
t.Fatalf("character=%+v found=%t error=%v", character, found, err)
}
registry, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
if len(registry.Guilds) != 0 {
t.Fatalf("guild registry changed on rejected create: %+v", registry.Guilds)
}
wire := connection.write.Bytes()
packet, rest := splitGameServerUpperPacket(t, wire)
if packet.Header.MsgID != uint16(dnfenum.CmdPacketCallGuildCreateRight) || !bytes.Equal(packet.Body, []byte{0, currentGuildCreateGoldError}) || len(rest) != 0 {
t.Fatalf("failure opcode=%d body=%x trailing=%x", packet.Header.MsgID, packet.Body, rest)
}
}
func TestCurrentGuildCertificationLifecycle(t *testing.T) {
for _, scenario := range []string{"reject", "cancel", "disconnect", "character-change", "replacement", "expire", "malformed", "forged-creator", "member-changed", "name-changed"} {
t.Run(scenario, func(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, c := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "master", Stats: map[string]int64{"gold": 500000}},
{CharacterID: "2", AccountID: "dnf:2", Name: "helper"},
} {
if err := repositories.Character.Save(ctx, c); err != nil {
t.Fatal(err)
}
}
service := &Service{options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
creator := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildNameCheck(creator, currentGuildTestDSTR("guild-flow")); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildPromoterCheck(creator, currentGuildTestDSTR("welcome")); err != nil {
t.Fatal(err)
}
target := currentGuildTestInvite(t, service, creator)
invite := creator.guild.createInvitation
creator.conn.(*bufferConn).write.Reset()
target.conn.(*bufferConn).write.Reset()
var err error
cleared := true
switch scenario {
case "reject":
err = service.handleCurrentGuildCreatePermitReply(target, []byte{0})
case "cancel":
err = service.handleCurrentGuildCreateCancel(creator, nil)
case "disconnect":
service.unbindGameSession(target)
case "character-change":
service.bindGameSessionCharacter(target, 3)
case "replacement":
service.bindGameSessionCharacter(&gameSession{conn: &bufferConn{}, accountID: "dnf:2"}, 2)
case "expire":
service.expireCurrentGuildCreateInvitation(invite)
case "malformed":
for _, body := range [][]byte{nil, {2}, {1, 0}} {
if err := service.handleCurrentGuildCreatePermitReply(target, body); err != nil {
t.Fatal(err)
}
}
cleared = false
case "forged-creator":
err = service.handleCurrentGuildCreatePermitReply(creator, []byte{1})
cleared = false
case "member-changed", "name-changed":
if err := service.handleCurrentGuildCreatePermitReply(target, []byte{1}); err != nil {
t.Fatal(err)
}
if scenario == "member-changed" {
registry := dnfguild.NewRegistry()
member, _, _ := repositories.Character.Load(ctx, "2")
if _, err := registry.Create(member, "other-guild", serviceTestNow()); err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
cleared = false
} else {
err = service.handleCurrentGuildNameCheck(creator, currentGuildTestDSTR("new-name"))
}
}
if err != nil {
t.Fatal(err)
}
if cleared && (creator.guild.createInvitation != nil || target.guild.createInvitation != nil) {
t.Fatal("invitation survived cleanup")
}
if scenario == "reject" || scenario == "cancel" || scenario == "expire" {
packet, _ := splitGameServerUpperPacket(t, creator.conn.(*bufferConn).write.Bytes())
state := currentGuildCreateAuthResetValue
if scenario == "reject" {
state = 0
}
if packet.Header.Classification != 0 || packet.Header.MsgID != currentGuildCreateAuthStateOpcode || !bytes.Equal(packet.Body, []byte{state}) {
t.Fatalf("reset class/op/body=%d/%d/%x", packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
if scenario != "reject" {
packet, _ = splitGameServerUpperPacket(t, target.conn.(*bufferConn).write.Bytes())
if packet.Header.Classification != 0 || packet.Header.MsgID != currentGuildCreateCancelOpcode || len(packet.Body) != 0 {
t.Fatalf("cancel=%+v", packet)
}
}
}
// A late reply from the retired dialog must not authorize creation.
if cleared {
if err := service.handleCurrentGuildCreatePermitReply(target, []byte{1}); err != nil {
t.Fatal(err)
}
}
if err := service.handleCurrentGuildCreate(creator, []byte{1}); err != nil {
t.Fatal(err)
}
character, _, err := repositories.Character.Load(ctx, "1")
if err != nil || character.Stats["gold"] != 500000 {
t.Fatalf("gold changed: %+v err=%v", character, err)
}
registry, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
if _, _, found := registry.GuildForCharacter("1"); found {
t.Fatal("unexpected guild creation")
}
})
}
}
func TestCurrentGuildPermitReplyCannotBypassOwnedPromoterValidation(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90, Stats: map[string]int64{"gold": 500000},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildNameCheck(session, currentGuildTestDSTR("no-bypass")); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildCreatePermitReply(session, []byte{1}); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildCreate(session, []byte{1}); err != nil {
t.Fatal(err)
}
character, found, err := repositories.Character.Load(ctx, "1")
if err != nil || !found || character.Stats["gold"] != 500000 {
t.Fatalf("character=%+v found=%t error=%v", character, found, err)
}
registry, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
if len(registry.Guilds) != 0 {
t.Fatalf("guild registry changed after forged permit reply: %+v", registry.Guilds)
}
wire := connection.write.Bytes()
for index := 0; index < 3; index++ {
_, wire = splitGameServerUpperPacket(t, wire)
}
packet, rest := splitGameServerUpperPacket(t, wire)
if packet.Header.MsgID != uint16(dnfenum.CmdPacketCallGuildCreateRight) || !bytes.Equal(packet.Body, []byte{0, currentGuildCreateStateError}) || len(rest) != 0 {
t.Fatalf("failure opcode=%d body=%x trailing=%x", packet.Header.MsgID, packet.Body, rest)
}
}
func TestCurrentGuildCreatePermitRejectsOfflinePromoter(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90},
{CharacterID: "2", AccountID: "dnf:2", Name: "outsider", Level: 90},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildNameCheck(session, currentGuildTestDSTR("guild-owned")); err != nil {
t.Fatal(err)
}
session.guild.promotion = "welcome"
service.gameSessions = map[uint16]*gameSession{1: session}
if err := service.handleCurrentGuildCreatePermit(session, append([]byte{0}, currentGuildTestDSTR("outsider")...)); err != nil {
t.Fatal(err)
}
wire := connection.write.Bytes()
for index := 0; index < 2; index++ {
_, wire = splitGameServerUpperPacket(t, wire)
}
packet, rest := splitGameServerUpperPacket(t, wire)
// op741 must answer the shared guild-failure code, never 104: the current
// EXE renders 104 as the "wait for the 06:00 daily reset" text (dstr
// 40150), a rule this server does not implement.
if packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestGuildCreatePermit) || !bytes.Equal(packet.Body, []byte{0, currentGuildCreatePermitReject}) || len(rest) != 0 {
t.Fatalf("permit opcode=%d body=%x trailing=%x", packet.Header.MsgID, packet.Body, rest)
}
if currentGuildCreatePermitReject == currentGuildCreateStateError {
t.Fatal("op741 rejection must not reuse the 06:00 daily-reset code")
}
}
func TestCurrentGuildApplicationApprovalPersistsMembership(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90},
{CharacterID: "2", AccountID: "dnf:1", Name: "member", Level: 80},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}, "guild-join", serviceTestNow())
if err != nil {
t.Fatal(err)
}
// 审批流用例:显式使用「接受后加入」。
guild.JoinMode = 0
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
memberSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 2, accountID: "dnf:1"}
applyBody := append(currentGuildTestDSTR(guild.Name), currentGuildTestDSTR("please")...)
if err := service.handleCurrentGuildApply(memberSession, applyBody); err != nil {
t.Fatal(err)
}
masterSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1, accountID: "dnf:1"}
var approveBody packetWriter
approveBody.writeUint32(2)
// Region byte echoed from the applicant row; 0 is rejected.
approveBody.writeByte(currentGuildMemberRegion)
if err := service.handleCurrentGuildApprove(masterSession, approveBody.bytes()); err != nil {
t.Fatal(err)
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
joinedGuild, joined, found := reloaded.GuildForCharacter("2")
if !found || joinedGuild.ID != guild.ID || joined.Grade != dnfguild.DefaultMemberGrade {
t.Fatalf("guild=%+v member=%+v found=%t", joinedGuild, joined, found)
}
if len(joinedGuild.Applications) != 0 {
t.Fatalf("applications were not cleared: %+v", joinedGuild.Applications)
}
}
// The client has no 签到 button and signs in automatically on the first login of
// the day, so the grant has to happen server-side: before this hook nothing but
// the op355 handler called Registry.Attend, the client never sends op355, and
// 公会积分 therefore listed nothing while the window still showed 已签到.
func TestCurrentGuildAttendanceGrantsOneMileageRowPerLocalDay(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
if err := repositories.Character.Save(ctx, master); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-sign", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
service.applyCurrentGuildAttendance("1", "test")
service.applyCurrentGuildAttendance("1", "test")
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, found := reloaded.GuildByID(guild.ID)
if !found {
t.Fatal("guild vanished")
}
if saved.Mileage != dnfguild.MileageSignInPoints || len(saved.MileageHistory) != 1 {
t.Fatalf("mileage=%d history=%d want one daily grant of %d",
saved.Mileage, len(saved.MileageHistory), dnfguild.MileageSignInPoints)
}
if entry := saved.MileageHistory[0]; entry.CharacterID != "1" || entry.Name != "master" || entry.Amount != dnfguild.MileageSignInPoints {
t.Fatalf("mileage entry=%+v", entry)
}
// The window reads the granted row from op747 and the names from op746.
rows := currentGuildMileageRows(saved)
if len(rows) != 1 || len(rows[0]) != currentGuildMileageRowSize {
t.Fatalf("mileage rows=%d", len(rows))
}
if got := rows[0][0x22]; got != currentGuildMileageRowKind {
t.Fatalf("master reason byte=%#x want %#x (公会会长每日签到)", got, currentGuildMileageRowKind)
}
// A non-master row must carry the member wording: the client renders the row
// byte as 21000-byte, so 0x0B printed 公会会长每日签到 (20989) for every row
// until the member rows started sending 2 (20998).
saved.Members["2"] = dnfguild.Member{CharacterID: "2", Name: "member", Grade: dnfguild.DefaultMemberGrade}
saved.MileageHistory = append(saved.MileageHistory, dnfguild.MileageHistory{
CharacterID: "2", Name: "member", Amount: 1, CreatedAt: service.gameplayNow(),
})
rows = currentGuildMileageRows(saved)
if len(rows) != 2 {
t.Fatalf("mileage rows=%d", len(rows))
}
if got := rows[1][0x22]; got != currentGuildMileageMemberReason {
t.Fatalf("member reason byte=%#x want %#x (公会成员每日签到)",
got, currentGuildMileageMemberReason)
}
if got := 21000 - int(currentGuildMileageMemberReason); got != 20998 {
t.Fatalf("member reason maps to id %d want 20998", got)
}
if got := 21000 - int(currentGuildMileageRowKind); got != 20989 {
t.Fatalf("master reason maps to id %d want 20989", got)
}
detail := buildCurrentGuildAttendanceDetailBody(saved, service.gameplayNow())
if name, _, ok := parseCurrentGuildDSTR(detail, 2); !ok || name != "master" {
t.Fatalf("attendance detail=%x", detail)
}
}
// The first version of the online attendance hook called mutateCurrentGuild
// directly. guildMu is a plain mutex and the online publish can already run
// inside a guild critical section, so a login deadlocked its own session
// goroutine: the client hung on the loading screen and both sessions stopped
// answering (live 2026-09-12 01:33). The grant must therefore never wait for the
// lock.
func TestCurrentGuildAttendanceNeverWaitsForTheGuildLock(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1, accountID: "dnf:1"}
service.guildMu.Lock()
done := make(chan struct{})
go func() {
service.grantCurrentGuildAttendance(session, "test")
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
service.guildMu.Unlock()
t.Fatal("grant blocked on guildMu: the town entry path would hang")
}
service.guildMu.Unlock()
}
// 公会贡献 rules beyond 签到, from the client's own help texts 76513/76515:
// 公会聊天 pays 2 once per local day, 同公会成员组队通关地下城 pays 1 per clear when
// the party holds at least two members of the actor's guild. Each grant writes its
// own row reason byte, because the client renders the row label as 21000-byte and
// a zero byte would print the 签到 wording instead.
func TestCurrentGuildMileageChatAndPartyRules(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
if err := repositories.Character.Save(ctx, master); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "mileageguild", serviceTestNow())
if err != nil {
t.Fatal(err)
}
guild.Members["2"] = dnfguild.Member{CharacterID: "2", Name: "guildmate", Grade: dnfguild.DefaultMemberGrade}
registry.Guilds[guild.ID] = guild
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
now := service.gameplayNow()
service.applyCurrentGuildMileageGrant("1", 0, "test_chat", func(registry *dnfguild.Registry, id string, at time.Time) (bool, uint32, error) {
applied, err := registry.GrantChatMileage(id, at)
return applied, dnfguild.MileageChatPoints, err
})
// A second chat on the same local day must not pay again.
service.applyCurrentGuildMileageGrant("1", 0, "test_chat", func(registry *dnfguild.Registry, id string, at time.Time) (bool, uint32, error) {
applied, err := registry.GrantChatMileage(id, at)
return applied, dnfguild.MileageChatPoints, err
})
// A solo clear pays nothing, a clear with one guildmate pays 1.
service.applyCurrentGuildMileageGrant("1", 1, "test_solo", func(registry *dnfguild.Registry, id string, at time.Time) (bool, uint32, error) {
applied, err := registry.GrantPartyMileage(id, []string{"1"}, at)
return applied, dnfguild.MileagePartyPoints, err
})
service.applyCurrentGuildMileageGrant("1", 2, "test_party", func(registry *dnfguild.Registry, id string, at time.Time) (bool, uint32, error) {
applied, err := registry.GrantPartyMileage(id, []string{"1", "2"}, at)
return applied, dnfguild.MileagePartyPoints, err
})
// A guildmate from another guild must not count towards the two.
service.applyCurrentGuildMileageGrant("1", 2, "test_stranger", func(registry *dnfguild.Registry, id string, at time.Time) (bool, uint32, error) {
applied, err := registry.GrantPartyMileage(id, []string{"1", "9"}, at)
return applied, dnfguild.MileagePartyPoints, err
})
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, found := reloaded.GuildByID(guild.ID)
if !found {
t.Fatal("guild vanished")
}
want := dnfguild.MileageChatPoints + dnfguild.MileagePartyPoints
if saved.Mileage != want || len(saved.MileageHistory) != 2 {
t.Fatalf("mileage=%d history=%d want %d over two rows",
saved.Mileage, len(saved.MileageHistory), want)
}
chat, party := saved.MileageHistory[0], saved.MileageHistory[1]
if chat.Amount != dnfguild.MileageChatPoints || chat.Reason != dnfguild.MileageReasonChat {
t.Fatalf("chat row=%+v", chat)
}
if party.Amount != dnfguild.MileagePartyPoints || party.Reason != dnfguild.MileageReasonParty {
t.Fatalf("party row=%+v", party)
}
if saved.Members["1"].ChatAt.IsZero() {
t.Fatal("chat day marker not recorded")
}
rows := currentGuildMileageRows(saved)
if len(rows) != 2 {
t.Fatalf("mileage rows=%d", len(rows))
}
if got := rows[0][0x22]; got != dnfguild.MileageReasonChat {
t.Fatalf("chat row byte=%#x want %#x", got, dnfguild.MileageReasonChat)
}
if got := rows[1][0x22]; got != dnfguild.MileageReasonParty {
t.Fatalf("party row byte=%#x want %#x", got, dnfguild.MileageReasonParty)
}
// The party row is repeatable: a second qualifying clear pays again, while the
// chat rule stays closed for the rest of the local day.
if _, err := reloaded.GrantPartyMileage("1", []string{"1", "2"}, now); err != nil {
t.Fatal(err)
}
if applied, err := reloaded.GrantChatMileage("1", now); err != nil || applied {
t.Fatalf("second chat applied=%t err=%v", applied, err)
}
saved, found = reloaded.GuildByID(guild.ID)
if !found || saved.Mileage != want+dnfguild.MileagePartyPoints {
t.Fatalf("mileage=%d want %d after the second clear", saved.Mileage, want+dnfguild.MileagePartyPoints)
}
}
// probe= must sweep exactly the listed reason bytes, which is how the newer
// rules' labels get read off the live window without a rebuild.
func TestCurrentGuildMileageProbeRowsSweepListedBytes(t *testing.T) {
rows := currentGuildMileageProbeRows("2, 10,0x0b,12")
if len(rows) != 4 {
t.Fatalf("probe rows=%d want 4", len(rows))
}
for index, want := range []byte{2, 10, 0x0B, 12} {
if got := rows[index][0x22]; got != want {
t.Fatalf("row %d byte=%#x want %#x", index, got, want)
}
}
}
// op746's handler sub_1D04F90 reads a u16 count and then one length-prefixed
// client name per row, so the body must be a name list -- not the u32 zero an
// earlier version sent (safe only because its first two bytes are the u16 zero)
// and certainly not 48-byte rows.
func TestCurrentGuildAttendanceDetailBodyListsTodaysAttendees(t *testing.T) {
now := serviceTestNow()
guild := &dnfguild.Guild{Members: map[string]dnfguild.Member{
"1": {CharacterID: "1", Name: "master", Grade: 1, AttendedAt: now},
"2": {CharacterID: "2", Name: "member", Grade: 4, AttendedAt: now.Add(-24 * time.Hour)},
}}
body := buildCurrentGuildAttendanceDetailBody(guild, now)
if len(body) < 2 || binary.LittleEndian.Uint16(body) != 1 {
t.Fatalf("attendance detail body=%x want one names row", body)
}
name, next, ok := parseCurrentGuildDSTR(body, 2)
if !ok || name != "master" || next != len(body) {
t.Fatalf("attendance detail name=%q ok=%t next=%d body=%x", name, ok, next, body)
}
empty := buildCurrentGuildAttendanceDetailBody(nil, now)
if len(empty) != 2 || binary.LittleEndian.Uint16(empty) != 0 {
t.Fatalf("empty attendance detail body=%x want u16 zero", empty)
}
}
// op747 must carry a u32 row count after the success byte: the client's handler
// 0x01D11310 reads it and then memcpys 0x30 bytes per row (call 0x34E3AC0 at
// 01D11380). The old stub answered with the success byte alone, so the client
// read a count out of invalid memory and crashed with 0xC0000005 at 0x03B10747
// (eax=0x098B101F, access 0x098B1000) as soon as 公会积分 was clicked.
func TestCurrentGuildMileageHistoryBodyCarriesCountAndFortyEightByteRows(t *testing.T) {
guild := &dnfguild.Guild{
MasterCharacterID: "7",
Members: map[string]dnfguild.Member{
"7": {CharacterID: "7", Name: "member", Grade: dnfguild.DefaultMasterGrade},
},
MileageHistory: []dnfguild.MileageHistory{{
CharacterID: "7", Name: "member", Amount: 3, CreatedAt: time.Unix(1700000000, 0),
}},
}
t.Setenv("DNF90_GUILD_MILEAGE_ROWS", "empty")
body := currentGuildMileageHistoryBody(currentGuildMileageRows(guild))
if len(body) != 4 || binary.LittleEndian.Uint32(body) != 0 {
t.Fatalf("empty mileage payload=%x want count0", body)
}
t.Setenv("DNF90_GUILD_MILEAGE_ROWS", "real")
body = currentGuildMileageHistoryBody(currentGuildMileageRows(guild))
if len(body) != 4+currentGuildMileageRowSize || binary.LittleEndian.Uint32(body) != 1 {
t.Fatalf("real mileage payload=%x want count1+%d bytes", body, currentGuildMileageRowSize)
}
row := body[4:]
if got := binary.LittleEndian.Uint32(row[0x00:]); got != 1700000000 {
t.Fatalf("mileage row time=%d body=%x", got, body)
}
if !bytes.HasPrefix(row[0x04:0x20], []byte("member")) {
t.Fatalf("mileage row name=%q", row[0x04:0x20])
}
if got := binary.LittleEndian.Uint16(row[0x20:]); got != 0 {
t.Fatalf("mileage row +0x20=%d want 0 (unmapped)", got)
}
if row[0x22] != currentGuildMileageRowKind {
// +0x22 is the reason byte; 0x0B is the value the client itself seeds and
// the one capture that rendered 公会成员每日签到 instead of the unrelated
// text of id 21000.
t.Fatalf("mileage row reason byte=%#x want %#x", row[0x22], currentGuildMileageRowKind)
}
if got := binary.LittleEndian.Uint32(row[0x24:]); got != 7 {
t.Fatalf("mileage row character id=%d", got)
}
if got := binary.LittleEndian.Uint32(row[0x28:]); got != 3 {
t.Fatalf("mileage row points=%d", got)
}
t.Setenv("DNF90_GUILD_MILEAGE_ROWS", "probe3")
body = currentGuildMileageHistoryBody(currentGuildMileageRows(guild))
if len(body) != 4+3*currentGuildMileageRowSize || binary.LittleEndian.Uint32(body) != 3 {
t.Fatalf("probe mileage payload len=%d count=%d", len(body), binary.LittleEndian.Uint32(body))
}
if first := body[4:]; first[0x22] != 0 {
t.Fatalf("probe row 0 reason byte=%#x want 0", first[0x22])
}
// A malformed row must be dropped before the count is written.
body = currentGuildMileageHistoryBody([][]byte{{1, 2, 3}})
if len(body) != 4 || binary.LittleEndian.Uint32(body) != 0 {
t.Fatalf("malformed mileage row leaked into the count: %x", body)
}
}
// op70's byte right after the guild-name DSTR lands at guildManager+0x30 and is
// the only thing that enables the 公会频道设置 button (00FABC98 test eax,eax /
// je -> disabled, tooltip 31583 "只有 \"%s\" 才能使用。"). It carried 0, so the
// master could not press it either. The dialog's own save check tests permission
// 1 of the viewer's grade row, so the byte mirrors that bit.
func TestCurrentGuildManagementBodyGatesChannelButtonOnViewerPermission(t *testing.T) {
registry := dnfguild.NewRegistry()
guild, err := registry.Create(
dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90},
"guild-flag", serviceTestNow())
if err != nil {
t.Fatal(err)
}
channelByte := func(viewerGrade byte) byte {
t.Helper()
body := buildCurrentGuildManagementBody(guild, 0, serviceTestNow(), viewerGrade)
name, next, ok := parseCurrentGuildDSTR(body, 0)
if !ok || name != "guild-flag" {
t.Fatalf("management body name=%q ok=%t body=%x", name, ok, body)
}
return body[next]
}
if got := channelByte(dnfguild.DefaultMasterGrade); got != 1 {
t.Fatalf("master channel-setting byte=%d want 1 (grade 1 mask has permission 1)", got)
}
if got := channelByte(4); got != 0 {
t.Fatalf("plain member channel-setting byte=%d want 0 (grade 4 mask is empty)", got)
}
if got := channelByte(100); got != 0 { // sub_1D85270's nonmember sentinel
t.Fatalf("nonmember channel-setting byte=%d want 0", got)
}
for _, tc := range []struct {
mode string
want byte
}{{"grade", 4}, {"master", 0}, {"7", 7}} {
t.Setenv("DNF90_GUILD_CHANNEL_FLAG", tc.mode)
if got := channelByte(4); got != tc.want {
t.Fatalf("override %q byte=%d want=%d", tc.mode, got, tc.want)
}
}
}
// op352's applicant card carries four bytes between the name and the message and
// the client maps them by position: job, packed advancement, level, reserved.
// The level sits in the THIRD byte (03197DAF movzx ecx,byte [eax+0x27] feeding
// the 'Lv.%d' literal at 03197DB4) and the first byte reaches the class call at
// 03197BFB together with the advancement, so sending {level, grow, 0, 0} made
// every applicant render another class at Lv.0.
func TestCurrentGuildApplicationRowCarriesJobAdvancementAndLevel(t *testing.T) {
application := dnfguild.Application{
CharacterID: "43",
AccountID: "dnf:1",
Name: "applicant",
Job: "4",
GrowType: "2",
Level: 87,
Message: "please",
CreatedAt: serviceTestNow(),
}
body := buildCurrentGuildApplicationListBody([]dnfguild.Application{application})
if got := binary.LittleEndian.Uint32(body[:4]); got != 1 {
t.Fatalf("applicant count=%d body=%x", got, body)
}
offset := 4
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 43 {
t.Fatalf("applicant id=%d body=%x", got, body)
}
offset += 4
name, next, ok := parseCurrentGuildDSTR(body, offset)
if !ok || name != "applicant" {
t.Fatalf("applicant name=%q ok=%t body=%x", name, ok, body)
}
if len(body)-next < 6 {
t.Fatalf("applicant card is truncated: body=%x", body)
}
if body[next] != 4 || body[next+1] != 2 || body[next+2] != 87 || body[next+3] != 0 {
t.Fatalf("applicant card job/advancement/level/reserved=%v want [4 2 87 0] body=%x",
body[next:next+4], body)
}
message, next, ok := parseCurrentGuildDSTR(body, next+4)
if !ok || message != "please" {
t.Fatalf("applicant message=%q ok=%t body=%x", message, ok, body)
}
if len(body)-next != 6 {
t.Fatalf("applicant tail=%x", body[next:])
}
if body[next+5] != currentGuildMemberRegion {
t.Fatalf("applicant region=%d want=%d body=%x", body[next+4], currentGuildMemberRegion, body)
}
}
// 立即加入 has no approver, so the joiner's own client must be told the join
// succeeded BEFORE the membership refresh: the op348 handler rewrites the join
// window from the ack body, and the captured live order (refresh at
// 15:52:21.792-.796, ack at .797) left the window on its pre-join state until it
// was closed and reopened.
func TestCurrentGuildImmediateJoinAcknowledgesBeforeMembershipRefresh(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90},
{CharacterID: "2", AccountID: "dnf:2", Name: "joiner", Level: 70},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(
dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90},
"guild-imm", serviceTestNow())
if err != nil {
t.Fatal(err)
}
// 立即加入: op740 mode byte 1, which Registry.Create already defaults to.
if guild.JoinMode != currentGuildImmediateJoinMode {
t.Fatalf("default join mode=%d want=%d", guild.JoinMode, currentGuildImmediateJoinMode)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "dnf:2", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
joinerSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 2, accountID: "dnf:2"}
if err := service.handleCurrentGuildApply(joinerSession, append(
currentGuildTestDSTR(guild.Name), currentGuildTestDSTR("please")...)); err != nil {
t.Fatal(err)
}
connection := joinerSession.conn.(*bufferConn)
first, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if first.Header.MsgID != uint16(dnfenum.CmdPacketRequestJoinGuild) {
t.Fatalf("first packet after 立即加入 is op%d, want the op%d ack body=%x",
first.Header.MsgID, uint16(dnfenum.CmdPacketRequestJoinGuild), first.Body)
}
if len(rest) == 0 {
t.Fatal("no membership refresh followed the immediate-join ack")
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
joinedGuild, joined, found := reloaded.GuildForCharacter("2")
if !found || joinedGuild.ID != guild.ID || joined.Grade != dnfguild.DefaultMemberGrade {
t.Fatalf("immediate join did not persist membership: guild=%+v member=%+v found=%t",
joinedGuild, joined, found)
}
if len(joinedGuild.Applications) != 0 {
t.Fatalf("immediate join left an application: %+v", joinedGuild.Applications)
}
}
// op351 (deny) writes the region as a u32 through sub_3455020 while op350
// (approve) writes it as a byte through sub_3454FC0 -- 03198793..031987B7.
// The captured deny body is 2b00000001000000, and only the 5-byte approve
// shape used to be decoded, so every rejection was refused with target 0 and
// the applicant stayed on the list.
func TestCurrentGuildApplicationDenyAcceptsWideRegionAndClearsApplicant(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
for _, character := range []dnfrepo.CharacterRecord{
master,
{CharacterID: "2", AccountID: "dnf:2", Name: "member", Level: 80},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-deny", serviceTestNow())
if err != nil {
t.Fatal(err)
}
// 审批流用例:显式使用「接受后加入」。
guild.JoinMode = 0
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
memberSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 2, accountID: "dnf:2"}
if err := service.handleCurrentGuildApply(memberSession, append(currentGuildTestDSTR(guild.Name), currentGuildTestDSTR("please")...)); err != nil {
t.Fatal(err)
}
masterSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1, accountID: "dnf:1"}
var deny packetWriter
deny.writeUint32(2)
deny.writeUint32(uint32(currentGuildMemberRegion))
if err := service.handleCurrentGuildDeny(masterSession, deny.bytes()); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, masterSession.conn.(*bufferConn).write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketDenyJoinGuild) || packet.Body[0] != 1 {
t.Fatalf("deny reply=%+v body=%x", packet.Header, packet.Body)
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, _ := reloaded.GuildByID(guild.ID)
if len(saved.Applications) != 0 {
t.Fatalf("denied application still listed: %+v", saved.Applications)
}
if _, joined := saved.Members["2"]; joined {
t.Fatal("a denied applicant must not become a member")
}
}
func TestCurrentGuildOpenWindowSearchAndStateAlwaysAnswer(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}, "guild-one", serviceTestNow())
if err != nil {
t.Fatal(err)
}
// The rich row's u32 is the 公会倾向 bitmask, not a member limit.
guild.Tags = []byte{0, 2, 5}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
expectedGuild, _, found := registry.GuildForCharacter("1")
if !found {
t.Fatal("created guild has no master membership")
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildSearch(session, []byte{2, 0, 0, 0, 0, 0}); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildAccountListIndex(session, nil); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildApplicationNotice(session, nil); err != nil {
t.Fatal(err)
}
if err := service.handleCurrentGuildDetail(session, currentGuildTestDSTR("guild-one")); err != nil {
t.Fatal(err)
}
wire := connection.write.Bytes()
for _, opcode := range []uint16{1452, 1453, 744, currentGuildMembershipNotificationMsgID, 365, 745} {
packet, rest := splitGameServerUpperPacket(t, wire)
if packet.Header.MsgID != opcode {
t.Fatalf("opcode=%d want=%d body=%x", packet.Header.MsgID, opcode, packet.Body)
}
if opcode == uint16(dnfenum.CmdPacketSearchGuildList) {
if len(packet.Body) < 19 || packet.Body[0] != 1 {
t.Fatalf("search body=%x", packet.Body)
}
if total := binary.LittleEndian.Uint32(packet.Body[1:5]); total != 1 {
t.Fatalf("search total=%d want=1 body=%x", total, packet.Body)
}
if packet.Body[5] != 0 || packet.Body[6] != 1 {
t.Fatalf("search page=%d rows=%d want=0/1 body=%x", packet.Body[5], packet.Body[6], packet.Body)
}
if id := binary.LittleEndian.Uint32(packet.Body[7:11]); id != 1 {
t.Fatalf("search guild id=%d want=1 body=%x", id, packet.Body)
}
if packet.Body[11] != 1 || binary.LittleEndian.Uint32(packet.Body[12:16]) != 1|4|32 ||
binary.LittleEndian.Uint16(packet.Body[16:18]) != 1 || packet.Body[18] != 1 {
t.Fatalf("search rich row prefix=%x", packet.Body[7:])
}
guildName, next, ok := parseCurrentGuildDSTR(packet.Body, 19)
if !ok || guildName != "guild-one" {
t.Fatalf("search guild name=%q ok=%t body=%x", guildName, ok, packet.Body)
}
masterName, next, ok := parseCurrentGuildDSTR(packet.Body, next)
if !ok || masterName != "master" {
t.Fatalf("search master=%q ok=%t body=%x", masterName, ok, packet.Body)
}
promotion, next, ok := readCurrentRawDstr(packet.Body, next)
if !ok || len(promotion) != 0 || next != len(packet.Body) {
t.Fatalf("search promotion=%x ok=%t next=%d body=%x", promotion, ok, next, packet.Body)
}
}
if opcode == uint16(dnfenum.CmdPacketRequestAccountGuildList) {
assertCurrentGuildAccountIndexPacket(t, packet.Body, "", 1, currentGuildTagsMask(expectedGuild))
}
if opcode == currentGuildMembershipNotificationMsgID {
if packet.Header.Classification != 0 || !bytes.Equal(packet.Body, buildCurrentGuildMembershipNotificationBody(expectedGuild)) {
t.Fatalf("final membership state header=%+v body=%x", packet.Header, packet.Body)
}
}
if opcode == uint16(dnfenum.CmdPacketJoinGuildInfo) {
if want := buildCurrentGuildApplicationNoticeBody(expectedGuild.ID, 0, "master"); !bytes.Equal(packet.Body, want) {
t.Fatalf("guild application notice body=%x want=%x", packet.Body, want)
}
}
wire = rest
}
if len(wire) != 0 {
t.Fatalf("trailing=%x", wire)
}
}
func TestCurrentGuildDirectRecommendationSeedsRichGuildCacheFirst(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(character, "guild-one", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildRecommend(session, nil); err != nil {
t.Fatal(err)
}
indexPacket, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if indexPacket.Header.MsgID != uint16(dnfenum.CmdPacketRequestAccountGuildList) {
t.Fatalf("first opcode=%d want=%d", indexPacket.Header.MsgID, dnfenum.CmdPacketRequestAccountGuildList)
}
assertCurrentGuildAccountIndexPacket(t, indexPacket.Body, "", 1, currentGuildTagsMask(guild))
recommendPacket, rest := splitGameServerUpperPacket(t, rest)
if recommendPacket.Header.MsgID != uint16(dnfenum.CmdPacketReqRecommendGuild) {
t.Fatalf("recommend opcode=%d trailing=%x", recommendPacket.Header.MsgID, rest)
}
statePacket, rest := splitGameServerUpperPacket(t, rest)
if statePacket.Header.Classification != 0 ||
statePacket.Header.MsgID != currentGuildMembershipNotificationMsgID ||
!bytes.Equal(statePacket.Body, buildCurrentGuildMembershipNotificationBody(guild)) || len(rest) != 0 {
t.Fatalf("recommend membership state header=%+v body=%x trailing=%x", statePacket.Header, statePacket.Body, rest)
}
}
func TestCurrentGuildApplicationNoticeBodyUsesApplicantNameAndAccountSlot(t *testing.T) {
body := buildCurrentGuildApplicationNoticeBody(7, 7, "applicant-character")
if got := binary.LittleEndian.Uint32(body[:4]); got != 7 {
t.Fatalf("guild id=%d want=7 body=%x", got, body)
}
if body[4] != 7 {
t.Fatalf("account slot=%d want=7 body=%x", body[4], body)
}
name, next, ok := parseCurrentGuildDSTR(body, 5)
if !ok || name != "applicant-character" || next != len(body) {
t.Fatalf("member name=%q ok=%t next=%d body=%x", name, ok, next, body)
}
}
func TestCurrentGuildApplicationNoticeBindsSelectedMemberByAccountSlotAndName(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Slot: 7, Name: "master", Level: 90}
sameAccountApplicant := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:1", Slot: 5, Name: "same-account", Level: 90}
for _, character := range []dnfrepo.CharacterRecord{master, sameAccountApplicant} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-one", serviceTestNow())
if err != nil {
t.Fatal(err)
}
guild.Applications[sameAccountApplicant.CharacterID] = dnfguild.Application{
CharacterID: sameAccountApplicant.CharacterID,
AccountID: sameAccountApplicant.AccountID,
Name: sameAccountApplicant.Name,
Level: sameAccountApplicant.Level,
CreatedAt: serviceTestNow(),
}
guild.Applications["3"] = dnfguild.Application{
CharacterID: "3",
AccountID: "dnf:other",
Name: "external-applicant",
Level: 90,
CreatedAt: serviceTestNow().Add(-time.Minute),
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildApplicationNotice(session, nil); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketJoinGuildInfo) || len(rest) != 0 {
t.Fatalf("opcode=%d trailing=%x", packet.Header.MsgID, rest)
}
want := buildCurrentGuildApplicationNoticeBody(guild.ID, byte(master.Slot), master.Name)
if !bytes.Equal(packet.Body, want) {
t.Fatalf("selected member binding body=%x want=%x", packet.Body, want)
}
}
func TestCurrentGuildApplicationNoticeLeavesPendingNonmemberMappingUntouched(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:owner", Slot: 0, Name: "master", Level: 90}
applicant := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:applicant", Slot: 5, Name: "applicant", Level: 90}
for _, character := range []dnfrepo.CharacterRecord{master, applicant} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-one", serviceTestNow())
if err != nil {
t.Fatal(err)
}
guild.Applications[applicant.CharacterID] = dnfguild.Application{
CharacterID: applicant.CharacterID,
AccountID: applicant.AccountID,
Name: applicant.Name,
Level: applicant.Level,
CreatedAt: serviceTestNow(),
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: applicant.AccountID, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, selectedCharacterID: 2, accountID: applicant.AccountID}
if err := service.handleCurrentGuildApplicationNotice(session, nil); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
want := buildCurrentGuildApplicationNoticeBody(0, 0, "")
if packet.Header.MsgID != uint16(dnfenum.CmdPacketJoinGuildInfo) || len(rest) != 0 || !bytes.Equal(packet.Body, want) {
t.Fatalf("application notice opcode=%d body=%x want=%x trailing=%x", packet.Header.MsgID, packet.Body, want, rest)
}
}
func TestCurrentGuildAccountProjectionKeepsPersistedMasterOutOfApplicationRows(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Slot: 7, Name: "master", Level: 90}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
if _, err := registry.Create(character, "guild-one", serviceTestNow()); err != nil {
t.Fatal(err)
}
projection, guilds, err := currentGuildAccountProjection(ctx, repositories, registry, character.AccountID)
if err != nil {
t.Fatal(err)
}
if len(guilds) != 1 || len(projection) != 1 {
t.Fatalf("guilds=%d projection=%+v", len(guilds), projection)
}
if member := guilds[0].Members[character.CharacterID]; member.Grade != dnfguild.DefaultMasterGrade {
t.Fatalf("persisted master grade=%d want=%d", member.Grade, dnfguild.DefaultMasterGrade)
}
applications, applicationGuilds, err := currentGuildAccountApplicationProjection(ctx, repositories, registry, character.AccountID, character.CharacterID)
if err != nil {
t.Fatal(err)
}
if len(applications) != 0 || len(applicationGuilds) != 0 {
t.Fatalf("master application projection=%+v guilds=%+v", applications, applicationGuilds)
}
}
func TestCurrentGuildAccountIndexUsesAdvertisedRegionForEveryRosterSlot(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
characters := []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:shared", Slot: 0, Name: "master-zero", Level: 90},
{CharacterID: "2", AccountID: "dnf:shared", Slot: 1, Name: "master-one", Level: 90},
}
registry := dnfguild.NewRegistry()
for index, character := range characters {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if _, err := registry.Create(character, fmt.Sprintf("guild-slot-%d", index), serviceTestNow()); err != nil {
t.Fatal(err)
}
}
projection, guilds, err := currentGuildAccountProjection(ctx, repositories, registry, "dnf:shared")
if err != nil {
t.Fatal(err)
}
body := buildCurrentGuildAccountIndexBody(guilds, projection)
if len(body) == 0 || body[0] != 2 {
t.Fatalf("guild row count body=%x", body)
}
offset := 1
for row := 0; row < 2; row++ {
if offset > len(body)-12 {
t.Fatalf("truncated rich row[%d] body=%x", row, body)
}
offset += 12
for field := 0; field < 3; field++ {
_, next, ok := readCurrentRawDstr(body, offset)
if !ok {
t.Fatalf("invalid rich row[%d] string[%d] body=%x", row, field, body)
}
offset = next
}
}
if offset >= len(body) || body[offset] != 2 {
t.Fatalf("mapping count offset=%d body=%x", offset, body)
}
offset++
for mapping, wantGuildID := range []uint32{1, 2} {
if offset > len(body)-5 {
t.Fatalf("truncated mapping[%d] body=%x", mapping, body)
}
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != wantGuildID {
t.Fatalf("mapping[%d] guild=%d want=%d body=%x", mapping, got, wantGuildID, body)
}
offset += 4
if region := body[offset]; region != 0 {
t.Fatalf("mapping[%d] region=%d want=0 body=%x", mapping, region, body)
}
offset++
_, next, ok := parseCurrentGuildDSTR(body, offset)
if !ok {
t.Fatalf("invalid mapping[%d] character name body=%x", mapping, body)
}
offset = next
}
if offset != len(body) {
t.Fatalf("trailing account index bytes=%x", body[offset:])
}
}
func TestCurrentGuildAccountApplicationProjectionContainsOnlyRealPendingRows(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:owner", Slot: 0, Name: "master", Level: 90}
applicant := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:applicant", Slot: 5, Name: "applicant", Level: 90}
for _, character := range []dnfrepo.CharacterRecord{master, applicant} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-one", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := registry.Apply(guild.ID, applicant, "join", serviceTestNow()); err != nil {
t.Fatal(err)
}
projection, guilds, err := currentGuildAccountApplicationProjection(ctx, repositories, registry, applicant.AccountID, applicant.CharacterID)
if err != nil {
t.Fatal(err)
}
if len(projection) != 1 || len(guilds) != 1 {
t.Fatalf("projection=%+v guilds=%+v", projection, guilds)
}
if projection[0].CharacterName != applicant.Name ||
projection[0].GuildName != guild.Name ||
projection[0].GuildID != guild.ID ||
projection[0].ApplicationPending != currentGuildApplicationCancelable {
t.Fatalf("pending application projection=%+v", projection[0])
}
}
func TestCurrentGuildAccountApplicationProjectionDoesNotLeakAnotherAccountCharacter(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:shared", Slot: 0, Name: "master", Level: 90}
otherCharacter := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:shared", Slot: 1, Name: "other", Level: 90}
for _, character := range []dnfrepo.CharacterRecord{master, otherCharacter} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "master-guild", serviceTestNow())
if err != nil {
t.Fatal(err)
}
otherGuild, err := registry.Create(dnfrepo.CharacterRecord{CharacterID: "3", AccountID: "dnf:other", Slot: 0, Name: "other-master", Level: 90}, "other-guild", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := registry.Apply(otherGuild.ID, otherCharacter, "join", serviceTestNow()); err != nil {
t.Fatal(err)
}
projection, guilds, err := currentGuildAccountApplicationProjection(ctx, repositories, registry, master.AccountID, master.CharacterID)
if err != nil {
t.Fatal(err)
}
if len(projection) != 0 || len(guilds) != 0 {
t.Fatalf("master must not inherit another character's application: projection=%+v guilds=%+v", projection, guilds)
}
projection, guilds, err = currentGuildAccountApplicationProjection(ctx, repositories, registry, otherCharacter.AccountID, otherCharacter.CharacterID)
if err != nil {
t.Fatal(err)
}
if len(projection) != 1 || len(guilds) != 1 || projection[0].CharacterName != otherCharacter.Name || projection[0].GuildID != otherGuild.ID {
t.Fatalf("selected applicant projection=%+v guilds=%+v", projection, guilds)
}
_ = guild
}
func TestCurrentGuildCancelApplyReconcilesStaleCreatorRowWithoutChangingRegistry(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{
CharacterID: "1",
AccountID: "dnf:owner",
Slot: 0,
Name: "master",
Level: 90,
}
if err := repositories.Character.Save(ctx, master); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-one", serviceTestNow())
if err != nil {
t.Fatal(err)
}
guild.Promotion = "welcome"
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: master.AccountID, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: master.AccountID}
body := make([]byte, 4)
binary.LittleEndian.PutUint32(body, guild.ID)
if err := service.handleCurrentGuildCancelApply(session, body); err != nil {
t.Fatal(err)
}
wire := connection.write.Bytes()
cancelPacket, rest := splitGameServerUpperPacket(t, wire)
if cancelPacket.Header.MsgID != uint16(dnfenum.CmdPacketCancelJoinGuild) || !bytes.Equal(cancelPacket.Body, []byte{1}) {
t.Fatalf("cancel opcode=%d body=%x", cancelPacket.Header.MsgID, cancelPacket.Body)
}
resetPacket, rest := splitGameServerUpperPacket(t, rest)
if resetPacket.Header.Classification != 0 || resetPacket.Header.MsgID != currentGuildCreateAuthStateOpcode ||
!bytes.Equal(resetPacket.Body, []byte{currentGuildCreateAuthResetValue}) {
t.Fatalf("reset opcode=%d body=%x", resetPacket.Header.MsgID, resetPacket.Body)
}
indexPacket, rest := splitGameServerUpperPacket(t, rest)
if indexPacket.Header.MsgID != uint16(dnfenum.CmdPacketRequestAccountGuildList) {
t.Fatalf("index opcode=%d body=%x", indexPacket.Header.MsgID, indexPacket.Body)
}
assertCurrentGuildAccountIndexPacket(t, indexPacket.Body, guild.Promotion, 1, currentGuildTagsMask(guild))
applicationPacket, rest := splitGameServerUpperPacket(t, rest)
if applicationPacket.Header.MsgID != uint16(dnfenum.CmdPacketReqGuildInfoOfMyChars) ||
!bytes.Equal(applicationPacket.Body, []byte{1, 0}) {
t.Fatalf("application opcode=%d body=%x", applicationPacket.Header.MsgID, applicationPacket.Body)
}
membershipPacket, rest := splitGameServerUpperPacket(t, rest)
if membershipPacket.Header.Classification != 0 ||
membershipPacket.Header.MsgID != currentGuildMembershipNotificationMsgID ||
!bytes.Equal(membershipPacket.Body, buildCurrentGuildMembershipNotificationBody(guild)) ||
len(rest) != 0 {
t.Fatalf("membership class=%d opcode=%d body=%x trailing=%x", membershipPacket.Header.Classification, membershipPacket.Header.MsgID, membershipPacket.Body, rest)
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
reloadedGuild, member, found := reloaded.GuildForCharacter(master.CharacterID)
if !found || reloadedGuild.ID != guild.ID || member.Grade != dnfguild.DefaultMasterGrade || len(reloadedGuild.Applications) != 0 {
t.Fatalf("registry changed during stale-row reconciliation: guild=%+v member=%+v found=%t", reloadedGuild, member, found)
}
}
func TestCurrentGuildCancelApplyStillRemovesRealApplication(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:owner", Slot: 0, Name: "master", Level: 90}
applicant := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:applicant", Slot: 0, Name: "applicant", Level: 90}
for _, character := range []dnfrepo.CharacterRecord{master, applicant} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-one", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := registry.Apply(guild.ID, applicant, "join", serviceTestNow()); err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: applicant.AccountID, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, selectedCharacterID: 2, accountID: applicant.AccountID}
body := make([]byte, 4)
binary.LittleEndian.PutUint32(body, guild.ID)
if err := service.handleCurrentGuildCancelApply(session, body); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketCancelJoinGuild) ||
!bytes.Equal(packet.Body, []byte{1}) ||
len(rest) != 0 {
t.Fatalf("cancel opcode=%d body=%x trailing=%x", packet.Header.MsgID, packet.Body, rest)
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
reloadedGuild, found := reloaded.GuildByID(guild.ID)
if !found {
t.Fatal("guild disappeared after application cancellation")
}
if _, found := reloadedGuild.Applications[applicant.CharacterID]; found {
t.Fatalf("real application was not removed: %+v", reloadedGuild.Applications)
}
}
func TestCurrentGuildMembershipNotificationBodyMatchesCurrentClient(t *testing.T) {
guild := &dnfguild.Guild{
ID: 7,
Name: "guild-name",
MasterName: "guild-master",
Promotion: "guild-promotion",
Level: 3,
JoinMode: 2,
Members: map[string]dnfguild.Member{
"1": {},
"2": {},
},
}
body := buildCurrentGuildMembershipNotificationBody(guild)
if body[0] != 1 || binary.LittleEndian.Uint32(body[1:5]) != guild.ID {
t.Fatalf("notification prefix=%x", body)
}
offset := 5
for _, want := range []string{guild.Name, guild.MasterName} {
got, next, ok := parseCurrentGuildDSTR(body, offset)
if !ok || got != want {
t.Fatalf("notification string=%q want=%q body=%x", got, want, body)
}
offset = next
}
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 0 {
t.Fatalf("notification unused field=%d body=%x", got, body)
}
offset += 4
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 2 {
t.Fatalf("notification member count=%d body=%x", got, body)
}
offset += 4
if body[offset] != guild.JoinMode {
t.Fatalf("notification join mode=%d body=%x", body[offset], body)
}
offset++
promotion, next, ok := parseCurrentGuildDSTR(body, offset)
if !ok || promotion != guild.Promotion || next != len(body) {
t.Fatalf("notification promotion=%q ok=%t next=%d body=%x", promotion, ok, next, body)
}
if empty := buildCurrentGuildMembershipNotificationBody(nil); !bytes.Equal(empty, []byte{0}) {
t.Fatalf("empty notification=%x", empty)
}
}
func assertCurrentGuildAccountIndexPacket(t *testing.T, body []byte, promotion string, memberCount uint16, tagsMask uint32) {
t.Helper()
if len(body) < 14 || body[0] != 1 || body[1] != 1 {
t.Fatalf("account guild index prefix=%x want success=1 guild-count=1", body)
}
offset := 2
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 1 {
t.Fatalf("account guild id=%d want=1 body=%x", got, body)
}
offset += 4
if body[offset] != 1 {
t.Fatalf("account guild join mode=%d want=1 body=%x", body[offset], body)
}
offset++
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != tagsMask {
t.Fatalf("account guild tags mask=%d want=%d body=%x", got, tagsMask, body)
}
offset += 4
if got := binary.LittleEndian.Uint16(body[offset : offset+2]); got != memberCount {
t.Fatalf("account guild member count=%d want=%d body=%x", got, memberCount, body)
}
offset += 2
if body[offset] != 1 {
t.Fatalf("account guild level=%d want=1 body=%x", body[offset], body)
}
offset++
for field, want := range []string{"guild-one", "master", promotion} {
if want == "" {
if offset > len(body)-4 || binary.LittleEndian.Uint32(body[offset:offset+4]) != 0 {
t.Fatalf("account guild string[%d] is not an empty DSTR body=%x", field, body)
}
offset += 4
continue
}
got, next, ok := parseCurrentGuildDSTR(body, offset)
if !ok || got != want {
t.Fatalf("account guild string[%d]=%q ok=%t want=%q body=%x", field, got, ok, want, body)
}
offset = next
}
if offset >= len(body) || body[offset] != 1 {
t.Fatalf("account character mapping count body=%x offset=%d", body, offset)
}
offset++
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 1 {
t.Fatalf("account mapping guild id=%d want=1 body=%x", got, body)
}
offset += 4
if body[offset] != 0 {
t.Fatalf("account mapping slot=%d want=0 body=%x", body[offset], body)
}
offset++
name, next, ok := parseCurrentGuildDSTR(body, offset)
if !ok || name != "master" || next != len(body) {
t.Fatalf("account mapping name=%q ok=%t next=%d body=%x", name, ok, next, body)
}
}
func TestCurrentGuildGradeProjectionMatchesCurrentExeFieldOrder(t *testing.T) {
// The rows carry member names, not grade names: sub_FBE4D0 joins each row
// against a guild member record on (row string == record name,
// row+4 == record+0x182) and sub_FBE700 looks rows up by that same pair,
// so grade names here left the member settings list showing
// 会长/副会长/优秀会员/普通会员 as if they were the members.
guild := &dnfguild.Guild{
ID: 9,
GradeNames: map[byte]string{1: "master", 2: "vice", 3: "elite", 4: "member"},
Members: map[string]dnfguild.Member{
"16": {CharacterID: "16", Name: "founder", Grade: 1},
"3": {CharacterID: "3", Name: "deputy", Grade: 2},
"41": {CharacterID: "41", Name: "regular", Grade: 4},
},
}
body := buildCurrentGuildGradeBody(guild, true)
if got := binary.LittleEndian.Uint32(body[:4]); got != guild.ID {
t.Fatalf("guild id=%d", got)
}
if got := binary.LittleEndian.Uint16(body[4:6]); got != 3 {
t.Fatalf("roster total=%d", got)
}
if got := binary.LittleEndian.Uint16(body[6:8]); got != 3 {
t.Fatalf("row count=%d", got)
}
offset := 8
for _, want := range []struct {
name string
grade byte
}{{"founder", 1}, {"deputy", 2}, {"regular", 4}} {
// Region byte. sub_FBE4D0 joins this row to a member record on
// (name, region), so it has to equal what the member list row sends
// at member+0x182, and sub_26A3590 refuses to send op126 when the
// menu hands it a 0.
if body[offset] != currentGuildMemberRegion {
t.Fatalf("region[%s]=%d want=%d", want.name, body[offset], currentGuildMemberRegion)
}
if currentGuildMemberRegion == 0 || currentGuildMemberRegion > 249 {
t.Fatalf("region %d outside sub_26A3590's 1..249 range", currentGuildMemberRegion)
}
offset++
name, next, ok := parseCurrentGuildDSTR(body, offset)
if !ok || name != want.name {
t.Fatalf("member name=%q want=%q ok=%t", name, want.name, ok)
}
offset = next
// The grade drives sub_FBE280's tab filter: 2 -> 副会长, 3 -> 优秀会员,
// 4 or 0 -> 普通会员. Sending 0 for everyone piled the whole roster
// under the last tab.
if body[offset] != want.grade {
t.Fatalf("grade[%s]=%d want=%d", want.name, body[offset], want.grade)
}
offset++
}
if offset != len(body) {
t.Fatalf("row boundary offset=%d len=%d", offset, len(body))
}
// op1455 appends to the container op1454 cleared, so the follow-up page
// has to be empty or every member would be listed twice.
next := buildCurrentGuildGradeBody(guild, false)
if len(next) != 6 || binary.LittleEndian.Uint32(next[:4]) != guild.ID || binary.LittleEndian.Uint16(next[4:6]) != 0 {
t.Fatalf("follow-up page=%x", next)
}
if offset != len(body) {
t.Fatalf("trailing=%x", body[offset:])
}
}
func TestCurrentGuildNextMemberPageUsesMatchingOpcodeAndCompactLayout(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
if _, err := registry.Create(character, "guild-page", serviceTestNow()); err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
if err := service.handleCurrentGuildMemberListNext(session, nil); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketGuildAllMemberListNext) || len(rest) != 0 {
t.Fatalf("opcode=%d trailing=%x", packet.Header.MsgID, rest)
}
if len(packet.Body) < 7 || packet.Body[0] != 1 || binary.LittleEndian.Uint32(packet.Body[1:5]) != 1 || binary.LittleEndian.Uint16(packet.Body[5:7]) != 1 {
t.Fatalf("body=%x", packet.Body)
}
}
func TestCurrentGuildLegacyMemberPagesUseNativeIdentityAndCursorBranches(t *testing.T) {
guild := &dnfguild.Guild{
ID: 7,
Name: "legacy-guild",
Level: 3,
Members: map[string]dnfguild.Member{
"9": {CharacterID: "9", Name: "member", Level: 90, Grade: 4},
},
}
legacyFirst := buildCurrentGuildMemberListBody(guild, true, false, nil, testGuildMemberListNow())
currentFirst := buildCurrentGuildMemberListBody(guild, true, true, nil, testGuildMemberListNow())
if len(currentFirst) != len(legacyFirst)+4 || !bytes.Equal(currentFirst[:len(legacyFirst)], legacyFirst) {
t.Fatalf("legacy first-page layout=%x current=%x", legacyFirst, currentFirst)
}
legacyNext := buildCurrentGuildMemberListBody(guild, false, false, nil, testGuildMemberListNow())
currentNext := buildCurrentGuildMemberListBody(guild, false, true, nil, testGuildMemberListNow())
if len(currentNext) != len(legacyNext)+4 || !bytes.Equal(currentNext[:len(legacyNext)], legacyNext) {
t.Fatalf("legacy next-page layout=%x current=%x", legacyNext, currentNext)
}
}
func TestCurrentGuildAuxiliaryPacketBodiesMatchCurrentExeEmptyBranches(t *testing.T) {
if got := buildCurrentGuildRefreshInfoBody(); !bytes.Equal(got, make([]byte, 7)) {
t.Fatalf("op339 body=%x want seven-byte stable state", got)
}
if got := buildCurrentGuildBoardEmptyBody(); !bytes.Equal(got, []byte{0, 0}) {
t.Fatalf("op343 body=%x", got)
}
if got := buildCurrentGuildAttendanceBody(); !bytes.Equal(got, []byte{0, 0}) {
t.Fatalf("op355 body=%x", got)
}
info := buildCurrentGuildInfoBody(19, &dnfguild.Guild{Name: "guild-info"})
if len(info) < 5 || binary.LittleEndian.Uint32(info[:4]) != 19 {
t.Fatalf("op74 prefix=%x", info)
}
name, next, ok := parseCurrentGuildDSTR(info, 4)
if !ok || name != "guild-info" || next != len(info) {
t.Fatalf("op74 name=%q next=%d ok=%t body=%x", name, next, ok, info)
}
grade := buildCurrentGuildLegacyGradeBody(3, "member", 2)
if len(grade) < 4 || grade[0] != 0 || grade[1] != 3 || grade[len(grade)-1] != 2 {
t.Fatalf("op126 body=%x", grade)
}
name, next, ok = parseCurrentGuildDSTR(grade, 2)
if !ok || name != "member" || next != len(grade)-1 {
t.Fatalf("op126 name=%q next=%d ok=%t body=%x", name, next, ok, grade)
}
}
func TestCurrentGuildLegacyGradeCommandPersistsAndEchoesNativeFields(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
member := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:1", Name: "member", Level: 90}
for _, character := range []dnfrepo.CharacterRecord{master, member} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
if _, err := registry.Create(master, "guild-grade", serviceTestNow()); err != nil {
t.Fatal(err)
}
if err := registry.Invite(master.CharacterID, member, serviceTestNow()); err != nil {
t.Fatal(err)
}
if _, err := registry.ReplyInvitation(member, true, serviceTestNow()); err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
session.initialTownRouteCharacterID = 1
session.initialTownRouteStage = currentInitialTownRoutePlayerStateSent
request := append([]byte{3}, currentGuildTestDSTR("member")...)
request = append(request, 2)
if err := service.handleCurrentGuildLegacyChangeGrade(session, request); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketSetSubGuildMaster) {
t.Fatalf("header=%+v trailing=%x", packet.Header, trailing)
}
// op127 is followed by the two projections writeCurrentGuildManagementState now
// ends with: the 公会徽章 aura block (opcode CmdPacketSetUDPIPPort, shared with
// the actor packets) and the DLL-private 公会经验排名 413. No 公会 BUFF row: this
// guild carries no Guild_Status benefit.
for _, msgID := range []uint16{70, 127, uint16(dnfenum.CmdPacketSetUDPIPPort), 413} {
notice, rest := splitGameServerUpperPacket(t, trailing)
if notice.Header.Classification != 0 || notice.Header.MsgID != msgID {
t.Fatalf("grade refresh=%+v want=0/%d", notice.Header, msgID)
}
if msgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && !isCurrentGuildAuraBody(notice.Body) {
t.Fatalf("grade refresh aura body=%x want the 公会徽章 block", notice.Body)
}
trailing = rest
}
if len(trailing) != 0 {
t.Fatalf("grade refresh trailing=%x", trailing)
}
if len(packet.Body) < 5 || packet.Body[0] != 1 || packet.Body[1] != 0 || packet.Body[2] != 3 || packet.Body[len(packet.Body)-1] != 2 {
t.Fatalf("body=%x", packet.Body)
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
guild, _, found := reloaded.GuildForCharacter(member.CharacterID)
if !found || guild.Members[member.CharacterID].Grade != 2 {
t.Fatalf("guild=%+v found=%t", guild, found)
}
}
func serviceTestNow() time.Time {
return time.Date(2026, 8, 19, 12, 0, 0, 0, time.Local)
}