DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,897 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/channelinfo"
"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"
)
// op830 (SET_GUILD_RECOMMAND_CHANNEL) carries no body: the client means "the
// channel I am standing in". The handler used to require
// ResidentFor(channel).ID == channel.ID, which is false for every ordinary town
// channel (channelcatalog.isResidentChannel reads type 11 as a death-tower
// channel while 11 is this ETC's ordinary town type), so the master of guild 1
// standing in channel 181 got error 19 and the client rendered 只有公会长才能设置.
func TestCurrentGuildRecommendedChannelStoresTheCastersChannel(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-chan", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
const etc = "\n[dungeon]\n`[noblsky]` `noblsky` 1\n[/dungeon]\n" +
"[dungeon]\n`[crack]` `crack`\n[/dungeon]\n" +
"[server]\n1 181 `noblsky` 11 `[noblsky]` 0 0 1 19 `crack` 10 `[crack]` 0 0\n[/server]\n"
index, err := channelinfo.Parse([]byte(etc))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
if err != nil {
t.Fatalf("New() error = %v", err)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
selectedCharacterID: 1,
accountID: "dnf:1",
channel: channelcatalog.Channel{ID: 181, ServerID: 1, Type: 11, Group: "noblsky"},
}
service := &Service{
options: options{accountID: "dnf:1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
catalog: catalog,
}
if err := service.handleCurrentGuildRecommendedChannel(session, nil); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketSetGuildRecommandChannel) {
t.Fatalf("op830 answer opcode=%d body=%x", packet.Header.MsgID, packet.Body)
}
if len(packet.Body) == 0 || packet.Body[0] != 1 {
t.Fatalf("op830 answer=%x want success", packet.Body)
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, found := reloaded.GuildByID(guild.ID)
if !found || saved.RecommendedChannel != 181 {
t.Fatalf("recommended channel=%d found=%t want 181", saved.RecommendedChannel, found)
}
// A bootstrap channel is still refused.
session.channel = channelcatalog.Channel{ID: 19, ServerID: 1, Type: 10, Group: "crack"}
connection.write.Reset()
if err := service.handleCurrentGuildRecommendedChannel(session, nil); err != nil {
t.Fatal(err)
}
refused, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(refused.Body) < 2 || refused.Body[0] != 0 {
t.Fatalf("crack channel accepted: %x", refused.Body)
}
}
// 公会基地: the client reads {u8 success, u8 code} and shows whatever toast the
// code maps to (decoded into D:\re_tools\agit_codes.txt). A member who is not the
// master must get 36 (您不是公会会长), a second create 181 (已经生成公会基地), a
// second delete 182 (公会基地不存在) and an out-of-range stage 188.
func TestCurrentGuildBaseLifecycleAnswersClientCodes(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
guest := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:2", Name: "guest", Level: 80}
for _, character := range []dnfrepo.CharacterRecord{master, guest} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild-base", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := registry.Apply(guild.ID, guest, "", serviceTestNow()); err != nil {
t.Fatal(err)
}
if _, err := registry.DecideApplication(guild.ID, "1", "2", true, serviceTestNow()); 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 },
}
masterSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1, accountID: "dnf:1"}
guestSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 2, accountID: "dnf:2"}
answer := func(session *gameSession, opcode uint16, call func() error) []byte {
t.Helper()
connection := session.conn.(*bufferConn)
connection.write.Reset()
if err := call(); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != opcode {
t.Fatalf("agit answer opcode=%d want=%d body=%x", packet.Header.MsgID, opcode, packet.Body)
}
if len(packet.Body) < 1 {
t.Fatal("agit answer is empty")
}
return packet.Body
}
failure := func(body []byte, want byte, what string) {
t.Helper()
if body[0] != 0 || len(body) < 2 || body[1] != want {
t.Fatalf("%s answer=%x want failure code %d", what, body, want)
}
}
success := func(body []byte, what string) {
t.Helper()
if body[0] != 1 {
t.Fatalf("%s answer=%x want success", what, body)
}
}
// A guild is born with its base (this client has no 创建基地 button), so the
// create packet must answer 181 已经生成公会基地 until the base is deleted.
createOpcode := uint16(dnfenum.CmdPacketCreateGuildAgit)
failure(answer(guestSession, createOpcode, func() error {
return service.handleCurrentGuildAgitCreate(guestSession, nil)
}), currentGuildAgitErrorNotMaster, "guest create")
failure(answer(masterSession, createOpcode, func() error {
return service.handleCurrentGuildAgitCreate(masterSession, nil)
}), currentGuildAgitErrorExists, "create over the automatic base")
upgradeOpcode := uint16(dnfenum.CmdPacketUpgradeGuildAgit)
success(answer(masterSession, upgradeOpcode, func() error {
return service.handleCurrentGuildAgitUpgrade(masterSession, []byte{3})
}), "upgrade to 3")
failure(answer(masterSession, upgradeOpcode, func() error {
return service.handleCurrentGuildAgitUpgrade(masterSession, []byte{2})
}), currentGuildAgitErrorStage, "downgrade to 2")
failure(answer(masterSession, upgradeOpcode, func() error {
return service.handleCurrentGuildAgitUpgrade(masterSession, []byte{dnfguild.MaxBaseStage + 1})
}), currentGuildAgitErrorStage, "stage beyond the ladder")
failure(answer(masterSession, upgradeOpcode, func() error {
return service.handleCurrentGuildAgitUpgrade(masterSession, nil)
}), currentGuildAgitErrorStage, "upgrade without a stage byte")
deleteOpcode := uint16(dnfenum.CmdPacketDeleteGuildAgit)
success(answer(masterSession, deleteOpcode, func() error {
return service.handleCurrentGuildAgitDelete(masterSession, nil)
}), "delete")
failure(answer(masterSession, deleteOpcode, func() error {
return service.handleCurrentGuildAgitDelete(masterSession, nil)
}), currentGuildAgitErrorMissing, "second delete")
// With the base gone the client's create request has to go through again.
success(answer(masterSession, createOpcode, func() error {
return service.handleCurrentGuildAgitCreate(masterSession, nil)
}), "create after delete")
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, found := reloaded.GuildByID(guild.ID)
if !found || saved.BaseStage != 1 {
t.Fatalf("base stage=%d found=%t want 1 after re-create", saved.BaseStage, found)
}
}
// TestCurrentGuildContentErrorCodesMatchClientTable pins the op761 refusal codes to
// the client's jump table. 182 is 没有基地 and must never answer a 积分 shortfall:
// that mis-mapping made every 公会内容 purchase pop 没有基地 for guilds without a
// base before 2026-09-12.
func TestCurrentGuildContentErrorCodesMatchClientTable(t *testing.T) {
cases := []struct {
err error
want byte
}{
{dnfguild.ErrInsufficientMileage, currentGuildContentErrorMileage},
{dnfguild.ErrPermissionDenied, currentGuildContentErrorPermission},
{dnfguild.ErrContentActive, currentGuildContentErrorAlreadyOwned},
{dnfguild.ErrMemberLimit, currentGuildContentErrorMemberState},
{dnfguild.ErrInsufficientGold, currentGuildContentErrorGold},
{errDungeonPickupInventoryFull, currentGuildContentErrorGeneric},
{errors.New("boom"), currentGuildContentErrorGeneric},
}
for _, testCase := range cases {
if got := currentGuildContentError(testCase.err); got != testCase.want {
t.Fatalf("currentGuildContentError(%v)=%d want=%d", testCase.err, got, testCase.want)
}
}
}
func TestCurrentGuildManagementNativeProjection(t *testing.T) {
guild := &dnfguild.Guild{
ID: 7, Name: "guild", MasterName: "master", Level: 3, Mark: 4,
AuraMark: 2, AuraFrame: 6,
Benefits: map[byte]dnfguild.Benefit{21: {Type: 21, Value: 300, ExpiresAt: serviceTestNow().Add(time.Hour)}},
MemberLimit: 1000, Notice: "notice", Promotion: "welcome", JoinMode: 1,
GradeNames: map[byte]string{1: "master", 4: "member"},
RecommendedRegion: 0, RecommendedChannel: 253, Tags: []byte{0, 3},
Members: map[string]dnfguild.Member{
"16": {CharacterID: "16", Name: "master", Job: "3", Level: 90, Grade: 1},
"46": {CharacterID: "46", Name: "member", Job: "4", Level: 80, Grade: 4},
},
}
body := buildCurrentGuildManagementBody(guild, 1, serviceTestNow(), 1)
reader := bytes.NewReader(body)
u8 := func() byte {
t.Helper()
v, err := reader.ReadByte()
if err != nil {
t.Fatal(err)
}
return v
}
u16 := func() uint16 {
t.Helper()
var v uint16
if err := binary.Read(reader, binary.LittleEndian, &v); err != nil {
t.Fatal(err)
}
return v
}
u32 := func() uint32 {
t.Helper()
var v uint32
if err := binary.Read(reader, binary.LittleEndian, &v); err != nil {
t.Fatal(err)
}
return v
}
dstr := func(want string) {
t.Helper()
n := u32()
b := make([]byte, n)
if _, err := reader.Read(b); err != nil && n != 0 {
t.Fatal(err)
}
if !bytes.Equal(b, rosterNameBytes(want)) {
t.Fatalf("string=%x want=%q", b, want)
}
}
dstr("guild")
u8()
u8()
if u16() != 2 {
t.Fatal("management member count")
}
u8()
// The notice must occupy this 256-unit DSTR: it is the slot
// sub_1D8B560 assigns to guild+0x18 and the notice edit box reads back.
// The 1024-unit introduction slot comes after the experience u32.
dstr("notice")
if u8() != 3 {
t.Fatal("guild level")
}
u8()
u8()
if u8() != 4 {
t.Fatal("guild mark")
}
u32()
// Both name slots carry the guild master: the first is the one the board
// renders in the 会长 field (guild+0x20), the second is the 17-unit name
// sub_2690140 stores on the local actor.
dstr("master")
dstr("master")
if u16() != 1 {
t.Fatal("online count")
}
dstr("welcome")
for grade := byte(0); grade < 6; grade++ {
permissions := u32()
name := make([]byte, 24)
if _, err := reader.Read(name); err != nil {
t.Fatal(err)
}
if grade == 1 && (permissions&(1<<1|1<<4|1<<5|1<<8|1<<11|1<<17)) != (1<<1|1<<4|1<<5|1<<8|1<<11|1<<17) {
t.Fatal("missing master settings permissions")
}
if grade == 4 && permissions != 0 {
t.Fatal("ordinary member received management permissions")
}
if !bytes.Equal(bytes.TrimRight(name, "\x00"), rosterNameBytes(guild.GradeNames[grade])) {
t.Fatal("grade name boundary")
}
}
if u8() != 2 || u8() != 6 || u32() != 0 || u8() != 0 || u32() != 1000 {
t.Fatal("member capacity benefit")
}
if u8() != 21 || u32() != uint32(serviceTestNow().Add(time.Hour).Unix()) || u8() != 0 || u32() != 300 {
t.Fatal("native contract benefit")
}
u32()
u8()
u32()
u8()
if u32() != 0 || u32() != 253 {
t.Fatal("advertised guild channel")
}
if u32() != 2 || u32() != 6 {
t.Fatal("aura mark/frame order")
}
u16()
u8()
u8()
if u32() != 9 || u8() != 1 || reader.Len() != 0 {
t.Fatal("recruitment tail")
}
body = buildCurrentGuildMemberListBody(guild, true, true, map[string]byte{"16": 253}, testGuildMemberListNow())
if binary.LittleEndian.Uint32(body) != 7 {
t.Fatal("member list must start with guild ID")
}
_, next, ok := readCurrentRawDstr(body, 4)
if !ok || binary.LittleEndian.Uint16(body[next:]) != 2 || binary.LittleEndian.Uint16(body[next+2:]) != 2 {
t.Fatal("member total/count")
}
pos := next + 4
for _, want := range []struct {
id uint32
job, grade, channel byte
}{{16, 3, 1, 253}, {46, 4, 4, 255}} {
if binary.LittleEndian.Uint32(body[pos:]) != want.id {
t.Fatal("member ID")
}
pos += 4
for i := 0; i < 2; i++ {
_, n, ok := readCurrentRawDstr(body, pos)
if !ok {
t.Fatal("member string")
}
pos = n
}
pos += 2
if body[pos] != want.job || body[pos+4] != want.channel || body[pos+7] != want.grade {
t.Fatalf("member job/channel/grade fields=%x", body[pos:pos+8])
}
// The roster key must be the member's own character ID: the client
// keys its guild member map (guild+0x2D8) on it, so a shared value
// collapses every row onto one map entry.
if binary.LittleEndian.Uint32(body[pos+8+4:]) != want.id {
t.Fatalf("member roster key=%x want=%d", body[pos+8+4:pos+8+8], want.id)
}
// sub_FB4910's third pass is the only one that can list an offline
// member and it refuses any row whose member+0x15C is 0 (00FB4C95);
// the second member view additionally needs member+0x181 (00FC4E35).
// Marking an online row would pin it above every other row, because
// sub_FA6F00 orders on this field before it looks at the sort column.
wantListable := byte(0)
if want.channel == currentGuildMemberOfflineChannel {
wantListable = 1
}
if body[pos+8+4+4] != wantListable {
t.Fatalf("member offline-listable=%d want=%d", body[pos+8+4+4], wantListable)
}
pos += 8 + 4 + 4 + 1
_, pos, ok = readCurrentRawDstr(body, pos)
if !ok {
t.Fatal("member trailing name")
}
// member+0x181 then member+0x182, the region byte the op1454 settings
// row has to match and the member context menu refuses when it is 0.
if body[pos] != wantListable || body[pos+1] != currentGuildMemberRegion {
t.Fatalf("member trailing flags=%x want=%d,%d", body[pos:pos+2], wantListable, currentGuildMemberRegion)
}
pos += 2
}
if pos+4 != len(body) {
t.Fatal("member cursor boundary")
}
}
func TestCurrentGuildManagementRequestsPersistAndApproveCapturedApplicant(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "16", AccountID: "dnf:master", Name: "master", Level: 90}
applicant := dnfrepo.CharacterRecord{CharacterID: "3", AccountID: "dnf:applicant", Name: "applicant", Level: 80}
for _, c := range []dnfrepo.CharacterRecord{master, applicant} {
if err := repositories.Character.Save(ctx, c); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err = registry.Apply(guild.ID, applicant, "please", serviceTestNow()); err != nil {
t.Fatal(err)
}
if err = dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
index, err := channelinfo.Parse([]byte("[dungeon]\n`[elven_guard]` `town` 1 2\n[/dungeon]\n[server]\n1 253 `town` 0 `[elven_guard]` 1 0\n[/server]"))
if err != nil {
t.Fatal(err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{})
if err != nil {
t.Fatal(err)
}
channel, _ := catalog.Channel(253)
owner := &gameSession{conn: &bufferConn{}, selectedCharacterID: 16, accountID: master.AccountID, channel: channel}
guest := &gameSession{conn: &bufferConn{}, selectedCharacterID: 3, accountID: applicant.AccountID, channel: channel}
for _, session := range []*gameSession{owner, guest} {
session.initialTownRouteCharacterID = session.selectedCharacterID
session.initialTownRouteStage = currentInitialTownRoutePlayerStateSent
}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}, catalog: catalog, gameSessions: map[uint16]*gameSession{16: owner, 3: guest}, repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
module := guildGameplayModule()
send := func(session *gameSession, opcode uint16, body []byte, wantSuccess bool) {
t.Helper()
session.conn.(*bufferConn).write.Reset()
handler := module.UpperHandlers[opcode]
if handler == nil {
t.Fatalf("missing guild route %d", opcode)
}
if err := handler(service, session, gameplayRequest{Opcode: opcode, Classification: 1, Body: body}); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, session.conn.(*bufferConn).write.Bytes())
if packet.Header.MsgID != opcode || (packet.Body[0] == 1) != wantSuccess {
t.Fatalf("opcode=%d packet=%+v body=%x", opcode, packet.Header, packet.Body)
}
}
// Captured request for character 3 previously decoded its last four bytes as ID 0.
// The trailing byte is the region the applicant row carried; the sender at
// 03198789 echoes [esi+0x54] back, so 0 is rejected.
send(owner, 350, []byte{3, 0, 0, 0, currentGuildMemberRegion}, true)
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
joined, member, found := reloaded.GuildForCharacter("3")
if !found || joined.ID != guild.ID || member.Grade != 4 {
t.Fatal("captured application did not join")
}
seenActor, seenGrade := false, false
for wire := guest.conn.(*bufferConn).write.Bytes(); len(wire) > 0; {
packet, rest := splitGameServerUpperPacket(t, wire)
wire = rest
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) {
// The 公会徽章 aura block rides this same opcode, so skip it and
// keep looking for the actor creation/binding body.
if isCurrentGuildAuraBody(packet.Body) {
continue
}
assertCurrentGuildActorState(t, packet.Body, guild.ID, uint32(guild.Mark), guild.Name)
seenActor = true
}
if packet.Header.MsgID == 127 && bytes.Equal(packet.Body, []byte{4}) {
seenGrade = true
}
}
if !seenActor || !seenGrade {
t.Fatalf("approved member refresh: actor=%t grade=%t", seenActor, seenGrade)
}
for _, bad := range [][]byte{nil, {3, 0, 0, 0}, {3, 0, 0, 0, 1}, {3, 0, 0, 0, 0, 0}} {
send(owner, 350, bad, false)
}
send(owner, 787, []byte{4}, true)
send(guest, 787, []byte{1}, false)
send(owner, 787, []byte{5}, false)
send(owner, 830, nil, true)
send(guest, 830, nil, false)
send(owner, 154, currentGuildTestDSTR("new notice"), true)
var settings packetWriter
settings.writeUint32(0x20022)
writeClientNameDstr(&settings, "new promotion")
settings.writeByte(1)
settings.writeUint32(9)
settings.writeByte(2)
send(owner, 740, settings.bytes(), true)
send(guest, 740, settings.bytes(), false)
for _, bad := range [][]byte{nil, settings.bytes()[:13], append(settings.bytes(), 0)} {
send(owner, 740, bad, false)
}
for _, index := range []int{0, len(settings.bytes()) - 5, len(settings.bytes()) - 1} {
bad := append([]byte(nil), settings.bytes()...)
bad[index] = 255
send(owner, 740, bad, false)
}
reloaded, err = dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, _ := reloaded.GuildByID(guild.ID)
// op154 owns the notice (op70's 256-unit DSTR), not the 1024-unit
// introduction slot: routing it to Introduction is what made every
// notice save look lost in the client's edit box.
if saved.Mark != 4 || saved.RecommendedRegion != 0 || saved.RecommendedChannel != 253 || saved.Notice != "new notice" || saved.Promotion != "new promotion" || saved.JoinMode != 2 || !saved.AllianceRecruitment || !bytes.Equal(saved.Tags, []byte{0, 3}) {
t.Fatalf("settings not durable: %+v", saved)
}
assertGrade := func(session *gameSession, want byte) {
t.Helper()
var grades []byte
for wire := session.conn.(*bufferConn).write.Bytes(); len(wire) > 0; {
packet, rest := splitGameServerUpperPacket(t, wire)
wire = rest
if packet.Header.Classification == 0 && packet.Header.MsgID == 127 {
grades = append(grades, packet.Body...)
}
}
if len(grades) == 0 || grades[len(grades)-1] != want {
t.Fatalf("grade notifications=%v want=%d", grades, want)
}
}
// Appointing 副会长/优秀会员 is op126. sub_26A3590 sends
// sub_3454EB0(w, 0x7E) / sub_3454FC0(w, selector) /
// sub_3455180(w, name) / sub_3454FC0(w, grade), and the guild member
// context menu calls it with grade 2, 3 or 4 (0325239C, 032523D5,
// 03252363). This step used to drive op748 with {3,0,0,0,2}, which only
// passed because the handler read that as "target 3, grade 2" -- op748
// is the per-grade permission editor and sends a u8 grade followed by a
// u32 mask (00F9CAA1..00F9CABD), so it is rejected below instead.
var appoint packetWriter
appoint.writeByte(1)
writeClientNameDstr(&appoint, "applicant")
appoint.writeByte(2)
send(owner, 126, appoint.bytes(), true)
assertGrade(guest, 2)
// op748 is the per-grade permission editor: u8 grade + u32 mask. Its
// class-1 reply handler (sub_1D0EA20) reads only the success flag, and
// mapped the generic failure 34 to dstr 0x9CD9 -- "该公会不存在" -- while
// this was rejected.
send(owner, 748, []byte{2, 0xff, 0xff, 0x03, 0x00}, true)
reloaded, err = dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, _ = reloaded.GuildByID(guild.ID)
if saved.GradePermissions[2] != dnfguild.GradePermissionMask {
t.Fatalf("grade permissions=%v", saved.GradePermissions)
}
// op70's grade table is the only place the client rereads the mask from,
// and buildCurrentGuildManagementBody sources every row through this
// resolver. Grades the master never edited keep the fixed defaults.
if got := dnfguild.GradePermissionsFor(saved, 2); got != dnfguild.GradePermissionMask {
t.Fatalf("op70 grade 2 permissions=%x", got)
}
if got := dnfguild.GradePermissionsFor(saved, dnfguild.DefaultMasterGrade); got != dnfguild.GradePermissionMask {
t.Fatalf("op70 master permissions=%x", got)
}
if got := dnfguild.GradePermissionsFor(saved, 3); got != 0 {
t.Fatalf("op70 grade 3 permissions=%x", got)
}
send(owner, 155, []byte{3, 0, 0, 0}, true)
assertGrade(owner, 2)
assertGrade(guest, 1)
}
func TestCurrentGuildLoginRequestsWaitForActorTransition(t *testing.T) {
for _, member := range []bool{false, true} {
t.Run(map[bool]string{false: "nonmember", true: "master"}[member], func(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "16", AccountID: "dnf:1", Name: "master", Level: 90}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
registry := dnfguild.NewRegistry()
if member {
if _, err := registry.Create(character, "guild", serviceTestNow()); err != nil {
t.Fatal(err)
}
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
session := &gameSession{conn: connection, selectedCharacterID: 16, accountID: character.AccountID}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}, repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
module := guildGameplayModule()
for _, state := range []struct {
characterID uint16
stage currentInitialTownRouteStage
}{{16, currentInitialTownRouteArmed}, {16, currentInitialTownRouteActorBound}, {3, currentInitialTownRoutePlayerStateSent}} {
session.initialTownRouteCharacterID, session.initialTownRouteStage = state.characterID, state.stage
for _, opcode := range []uint16{67, 140, 339} {
connection.write.Reset()
if err := module.UpperHandlers[opcode](service, session, gameplayRequest{Opcode: opcode, Classification: 1, Body: []byte{0, 0, 0, 0}}); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.Classification != 1 || packet.Header.MsgID != opcode || len(rest) != 0 {
t.Fatalf("early guild request emitted actor-dependent state: opcode=%d packet=%+v trailing=%x", opcode, packet.Header, rest)
}
}
connection.write.Reset()
session.townMu.Lock()
err := service.sendCurrentInitialTownGuildStateProjectionLocked(session, "test_before_transition")
session.townMu.Unlock()
if err != nil || connection.write.Len() != 0 || session.initialTownGuildStateProjectionSent {
t.Fatalf("premature login projection: err=%v bytes=%d sent=%t", err, connection.write.Len(), session.initialTownGuildStateProjectionSent)
}
}
session.initialTownRouteCharacterID = 16
session.initialTownRouteStage = currentInitialTownRouteTransitionSent
session.townMu.Lock()
err := service.sendCurrentInitialTownGuildStateProjectionLocked(session, "test_after_transition")
session.townMu.Unlock()
if err != nil {
t.Fatal(err)
}
wire := connection.write.Bytes()
// op70 goes only to a member. Its handler shows the notice once per
// client run gated on nothing but the global flag at 0x519297C
// (01D8B8A2), so the empty projection this used to send for a
// guildless character popped a blank 公会公告 line on every login.
//
// Between op127 and the membership notification sit the three
// projections writeCurrentGuildManagementState now ends with, which
// upstream places in exactly this order: the 公会徽章 aura block
// (opcode CmdPacketSetUDPIPPort, which it shares with the actor
// packets) and the DLL-private 公会经验排名 413. No 公会 BUFF row
// appears because this guild has no Guild_Status benefit.
expected := []uint16{70, 127, uint16(dnfenum.CmdPacketSetUDPIPPort), 413, 308, 1453, 744}
if !member {
expected = expected[1:]
}
for _, opcode := range expected {
packet, rest := splitGameServerUpperPacket(t, wire)
wire = rest
if packet.Header.MsgID != opcode {
t.Fatalf("post-transition guild state=%+v want=%d", packet.Header, opcode)
}
if opcode == 127 {
grade := byte(100)
if member {
grade = 1
}
if !bytes.Equal(packet.Body, []byte{grade}) {
t.Fatalf("guild grade=%x want=%d", packet.Body, grade)
}
}
}
if len(wire) != 0 || !session.initialTownGuildStateProjectionSent {
t.Fatal("post-transition guild projection incomplete")
}
connection.write.Reset()
session.townMu.Lock()
err = service.sendCurrentInitialTownGuildStateProjectionLocked(session, "test_duplicate")
session.townMu.Unlock()
if err != nil || connection.write.Len() != 0 {
t.Fatal("duplicate login projection")
}
})
}
}
// A cross-town move and the Seria-room exit (op1425) commit their own scene
// transition and then reset the initial-town route cursor to idle. The guild
// management state must keep flowing after that: the master who walked out of
// the room before creating the guild otherwise kept op127 grade 100 (the
// nonmember sentinel) and an empty op70 body for the rest of the session.
func TestCurrentGuildManagementStateSurvivesTownRouteCursorReset(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "16", 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", serviceTestNow())
if err != nil {
t.Fatal(err)
}
if err := dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
session := &gameSession{conn: connection, selectedCharacterID: 16, accountID: character.AccountID}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
// Exactly the post-move state: cursor idle, town actor committed.
session.initialTownRouteCharacterID = 0
session.initialTownRouteStage = currentInitialTownRouteIdle
session.townSceneReadyCharacterID = 16
if err := service.sendCurrentGuildManagementState(session, guild); err != nil {
t.Fatal(err)
}
wire := connection.write.Bytes()
// op127 is followed by the 公会徽章 aura block and the DLL-private 公会经验排名,
// the two projections writeCurrentGuildManagementState now ends with. The
// guild has no Guild_Status benefit, so no 公会 BUFF row is emitted, and
// publishCurrentGuildAuraToTownPeers is a no-op here because this Service has
// no online player manager.
for _, opcode := range []uint16{70, 127, uint16(dnfenum.CmdPacketSetUDPIPPort), 413} {
packet, rest := splitGameServerUpperPacket(t, wire)
wire = rest
if packet.Header.MsgID != opcode {
t.Fatalf("post-move guild state=%+v want=%d", packet.Header, opcode)
}
if opcode == 127 && !bytes.Equal(packet.Body, []byte{1}) {
t.Fatalf("post-move guild grade=%x want master grade 1", packet.Body)
}
}
if len(wire) != 0 {
t.Fatalf("post-move guild state trailing=%x", wire)
}
// A session with neither cursor nor committed town actor still defers.
connection.write.Reset()
session.townSceneReadyCharacterID = 0
if err := service.sendCurrentGuildManagementState(session, guild); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("guild state sent before any actor: bytes=%x", connection.write.Bytes())
}
}
// sub_1D21FD0 stores channel 0xff as member+0x34 == -1 and the window then
// renders no row at all, so an online member above channel 254 must not be
// announced as offline. Live evidence: two op67 bodies for the same guild
// differed in exactly one byte — the member's channel — and only the client
// that received 0xfe/0xfc rendered both members.
func TestCurrentGuildMemberChannelByteKeepsOnlineMembersVisible(t *testing.T) {
for _, testCase := range []struct {
name string
channelID int
want byte
}{
{"ordinary channel", 121, 121},
{"highest representable", 254, 254},
{"offline sentinel collision", 255, currentGuildMemberUnrepresentedChannel},
{"pvp channel above the byte", 508, currentGuildMemberUnrepresentedChannel},
{"low byte would read as offline", 511, currentGuildMemberUnrepresentedChannel},
{"unbound channel", 0, currentGuildMemberUnrepresentedChannel},
} {
if got := currentGuildMemberChannelByte(testCase.channelID); got != testCase.want {
t.Fatalf("%s: channel %d encoded as %#x want %#x", testCase.name, testCase.channelID, got, testCase.want)
}
if currentGuildMemberChannelByte(testCase.channelID) == currentGuildMemberOfflineChannel {
t.Fatalf("%s: online member encoded as the offline sentinel", testCase.name)
}
}
}
// The requester's own row used to bypass the range check that dropped every
// other row, so a member on a PVP channel saw itself online while the rest of
// the guild saw it offline. Both paths must now agree.
func TestCurrentGuildOnlineChannelsAgreeForRequesterAndPeers(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
peer := dnfrepo.CharacterRecord{CharacterID: "42", AccountID: "dnf:2", Name: "peer", Level: 70}
for _, character := range []dnfrepo.CharacterRecord{master, peer} {
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)
}
if err := registry.Apply(guild.ID, peer, "", serviceTestNow()); err != nil {
t.Fatal(err)
}
if _, err := registry.DecideApplication(guild.ID, "1", "42", true, serviceTestNow()); err != nil {
t.Fatal(err)
}
masterSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1, accountID: master.AccountID}
masterSession.channel.ID = 121
peerSession := &gameSession{conn: &bufferConn{}, selectedCharacterID: 42, accountID: peer.AccountID}
peerSession.channel.ID = 508
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{1: masterSession, 42: peerSession},
}
for _, requester := range []*gameSession{masterSession, peerSession} {
channels := service.currentGuildOnlineChannels(guild, requester)
if channels["1"] != 121 {
t.Fatalf("master channel=%#x want 121", channels["1"])
}
if channels["42"] != currentGuildMemberUnrepresentedChannel {
t.Fatalf("peer channel=%#x want %#x", channels["42"], currentGuildMemberUnrepresentedChannel)
}
}
}
// op153 carries both 退出公会 and 开除会员. sub_26A3540 builds it as a u8
// region plus the target's name (026A3569..026A357D) and the captured kick
// body is 01 04000000 <name>; the handler used to ignore the body and always
// remove the requester, so a master got the generic failure the client renders
// as 您不是工会成员或此工会不存在 and anyone else would have removed themselves.
func TestCurrentGuildSecedeRemovesTheNamedMember(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
master := dnfrepo.CharacterRecord{CharacterID: "16", AccountID: "dnf:master", Name: "master", Level: 90}
member := dnfrepo.CharacterRecord{CharacterID: "3", AccountID: "dnf:member", Name: "applicant", Level: 80}
for _, c := range []dnfrepo.CharacterRecord{master, member} {
if err := repositories.Character.Save(ctx, c); err != nil {
t.Fatal(err)
}
}
registry := dnfguild.NewRegistry()
guild, err := registry.Create(master, "guild", serviceTestNow())
if err != nil {
t.Fatal(err)
}
guild.Members["3"] = dnfguild.Member{CharacterID: "3", AccountID: member.AccountID, Name: member.Name, Level: 80, Grade: 4, JoinedAt: serviceTestNow()}
if err = dnfguild.Save(ctx, repositories.Settings, registry, serviceTestNow()); err != nil {
t.Fatal(err)
}
index, err := channelinfo.Parse([]byte("[dungeon]\n`[elven_guard]` `town` 1 2\n[/dungeon]\n[server]\n1 253 `town` 0 `[elven_guard]` 1 0\n[/server]"))
if err != nil {
t.Fatal(err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{})
if err != nil {
t.Fatal(err)
}
channel, _ := catalog.Channel(253)
owner := &gameSession{conn: &bufferConn{}, selectedCharacterID: 16, accountID: master.AccountID, channel: channel}
owner.initialTownRouteCharacterID = owner.selectedCharacterID
owner.initialTownRouteStage = currentInitialTownRoutePlayerStateSent
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
catalog: catalog,
gameSessions: map[uint16]*gameSession{16: owner},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
handler := guildGameplayModule().UpperHandlers[uint16(dnfenum.CmdPacketReqGuildSecede)]
if handler == nil {
t.Fatal("missing op153 route")
}
var kick packetWriter
kick.writeByte(currentGuildMemberRegion)
writeClientNameDstr(&kick, "applicant")
if err := handler(service, owner, gameplayRequest{Opcode: uint16(dnfenum.CmdPacketReqGuildSecede), Classification: 1, Body: kick.bytes()}); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, owner.conn.(*bufferConn).write.Bytes())
if packet.Header.MsgID != uint16(dnfenum.CmdPacketReqGuildSecede) || packet.Body[0] != 1 {
t.Fatalf("secede reply=%+v body=%x", packet.Header, packet.Body)
}
// sub_1D161F0 reads u8 / DSTR / u8 / DSTR and switches on the first byte,
// so the removed member's name has to be the first DSTR.
name, next, ok := parseCurrentGuildDSTR(packet.Body, 2)
if !ok || name != "applicant" {
t.Fatalf("secede reply name=%q ok=%t", name, ok)
}
if packet.Body[next] != 0 {
t.Fatalf("secede reply second flag=%d", packet.Body[next])
}
reloaded, err := dnfguild.Load(ctx, repositories.Settings)
if err != nil {
t.Fatal(err)
}
saved, _ := reloaded.GuildByID(guild.ID)
if _, still := saved.Members["3"]; still {
t.Fatal("named member was not removed")
}
if _, requester := saved.Members["16"]; !requester {
t.Fatal("the requester was removed instead of the named member")
}
}