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

+382
View File
@@ -0,0 +1,382 @@
// 本文件按当前 EXE/C# 证据构造队伍 S2C body。
package party
import "longheng.io/server/internal/modules/dnf/alignedcmd"
const (
fullPartyMemberPercent = 100
defaultTownUserState = 1
partyPeerEndpointSize = 22
directoryMemberSlots = 6
directoryOrdinaryState = 0xff
directoryEmptyState = 0xff
directoryEmptyUserID = 0xffff
// DirectoryTypeOrdinary selects the first current-client party directory.
DirectoryTypeOrdinary byte = 2
)
// PeerEndpoint is one current-client PARTY_IP_INFO member row. IPv4 is kept
// as four network-order octets while Port is written in network byte order;
// the remaining numeric fields use the packet's ordinary little-endian order.
type PeerEndpoint struct {
UserID uint16
InnerIPv4 [4]byte
OuterIPv4 [4]byte
Port uint16
RoutingKey uint32
NATType byte
PathMTU uint32
UserState byte
}
// DirectoryRecord is the server-owned public party-directory projection used
// by the current client class0/op87 reader.
type DirectoryRecord struct {
PartyID uint16
SelectionID uint32
MemberIDs []uint16
}
// BuildDirectorySnapshot constructs the current NoPack class0/op87 body:
//
// u16 count
// repeat count {
// u16 partyID
// u8 reservedA
// u8 directoryType
// u8 reservedB
// u8 specialState
// u32 selectionID
// repeat 6 { u8 memberState; u16 characterID }
// }
//
// Current EXE sub_1D3F040 subtracts its protected logical base value 2 from
// directoryType before indexing a five-element directory container. Therefore
// this byte is a directory discriminator, not the resident channel ID. This
// builder currently publishes only ordinary-dungeon parties and deliberately
// fixes directoryType to 2 (container index zero). sub_1D3F040 reads but does
// not retain reservedA/reservedB. Ordinary parties must use specialState FF:
// any other value enters the optional complete-display subsystem. Occupied
// slots use neutral state zero; unused slots use the exact FF/FFFF sentinels.
func BuildDirectorySnapshot(records []DirectoryRecord) []byte {
if len(records) > int(^uint16(0)) {
records = records[:int(^uint16(0))]
}
var writer packetWriter
writer.writeUint16(uint16(len(records)))
for _, record := range records {
writer.writeUint16(record.PartyID)
writer.writeByte(0)
writer.writeByte(DirectoryTypeOrdinary)
writer.writeByte(0)
writer.writeByte(directoryOrdinaryState)
writer.writeUint32(record.SelectionID)
for slot := 0; slot < directoryMemberSlots; slot++ {
if slot < len(record.MemberIDs) &&
record.MemberIDs[slot] != 0 &&
record.MemberIDs[slot] != directoryEmptyUserID {
writer.writeByte(0)
writer.writeUint16(record.MemberIDs[slot])
continue
}
writer.writeByte(directoryEmptyState)
writer.writeUint16(directoryEmptyUserID)
}
}
return writer.bytes()
}
// BuildSingleMemberRealtimeInfo 构造 NOTI 0x0099 PARTY_MEMBER_REALTIME_INFO。
//
// 行布局与 86JP 的 PartyRealtimeInfoBuilder 相同,而该 builder 是逐字节照
// df_game_r CParty::get_party_realtime_info @0x0859CBAC 写的:
//
// count:u8, 每有效成员 { uid:u16, hpPercent:u8, isHelpAbuseParty:u8, slotIndex:u8 }
//
// 即 +2 是该成员的 HP 百分比(0~100,满血 100;本包没有 MP 字段,客户端取到
// HP 后自行取 MP),+4 是成员在队伍表里的固定槽位。把 +2 写成 UserState、把
// +4 写死 0 会让一支多人队伍的每一行都声称自己占 0 号槽:客户端按槽位匹配时
// 血条会互相覆盖,而 HP 也永远显示满血。
//
// +4 的取值优先用 member.Slot(op9 下发的就是同一个槽位号)。只有当调用方
// 没有给出可用槽位(越界或与前面的行重复)时才按第一个空槽补齐,规则与
// dnfbridge 侧 appendRuntimePartyMember 的槽位分配完全一致,避免 op153 与
// op9 对同一支队伍给出互相矛盾的行。
func BuildSingleMemberRealtimeInfo(state alignedcmd.PartyState) []byte {
if state.PartyID <= 0 || state.UserID == 0 {
return []byte{0}
}
members := partyMembers(state)
var writer packetWriter
var used [4]bool
writer.writeByte(byte(len(members)))
for _, member := range members {
slot := member.Slot
if slot >= byte(len(used)) || used[slot] {
slot = 0
for candidate := byte(0); candidate < byte(len(used)); candidate++ {
if !used[candidate] {
slot = candidate
break
}
}
}
used[slot] = true
writer.writeUint16(member.UserID)
writer.writeByte(memberPercentByte(member.HPPercent))
writer.writeByte(0)
writer.writeByte(slot)
}
return writer.bytes()
}
// BuildEmptyRealtimeInfo 构造退出队伍后用于清空实时队员 UI 的空列表。
func BuildEmptyRealtimeInfo() []byte {
return []byte{0}
}
// BuildPeerEndpointInfo constructs current-client class0/op11
// PARTY_IP_INFO. The current reader consumes one count byte followed by 22
// bytes per member:
//
// u16 uid, 4B inner IPv4, 4B outer IPv4, u16be port,
// u32 routing key, u8 NAT type, u32 path MTU, u8 connection percentage.
//
// RoutingKey and PathMTU must be the remote connection's values previously
// published by its login/path-report flow; UserState is a connection percent.
func BuildPeerEndpointInfo(endpoints []PeerEndpoint) []byte {
if len(endpoints) > 4 {
endpoints = endpoints[:4]
}
var writer packetWriter
writer.writeByte(byte(len(endpoints)))
for _, endpoint := range endpoints {
writer.writeUint16(endpoint.UserID)
for _, octet := range endpoint.InnerIPv4 {
writer.writeByte(octet)
}
for _, octet := range endpoint.OuterIPv4 {
writer.writeByte(octet)
}
writer.writeByte(byte(endpoint.Port >> 8))
writer.writeByte(byte(endpoint.Port))
writer.writeUint32(endpoint.RoutingKey)
writer.writeByte(endpoint.NATType)
writer.writeUint32(endpoint.PathMTU)
userState := endpoint.UserState
if userState == 0 || userState == defaultTownUserState {
userState = fullPartyMemberPercent
}
writer.writeByte(userState)
}
return writer.bytes()
}
// BuildReserveLeavePartyAck 构造 0x02B3 成功响应:success,u8 flag,u16 targetChar。
func BuildReserveLeavePartyAck(flag byte, targetChar uint16) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeByte(flag)
writer.writeUint16(targetChar)
return writer.bytes()
}
// BuildEntryIntoPartyAck 构造 0x02C1 成功响应:success,u32 target,u32 currentChar。
func BuildEntryIntoPartyAck(targetID uint32, currentCharID uint32) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeUint32(targetID)
writer.writeUint32(currentCharID)
return writer.bytes()
}
// BuildEntryIntoPartyFinishEmptyBody constructs class0/op706 when the bridge
// has no peer-value rows to publish. Current EXE sub_1D56F60 always consumes
// state:u8 and count:u8 before reading count pairs of u32 key/u32 value.
func BuildEntryIntoPartyFinishEmptyBody(state byte) []byte {
return []byte{state, 0}
}
// BuildRequestPeerAck 构造 0x000A 的 class1 最小成功响应。
// 当前 EXE sub_2261E30 先消费 success:u8;success 非零时
// sub_1D11A80 立即返回,不再读取业务字段。
func BuildRequestPeerAck() []byte {
return []byte{1}
}
// BuildRequestPeerSelectionNotice constructs the class0/op7 mode-15 reply
// that completes a town-player click. Current EXE sub_1D82CF0 reads
// target:u16, mode:u8, value0:u32 and party_marker:u16. The marker is not an
// opaque echo: 0xffff clears the selected actor's party-present flag, while
// every other value sets it, before sub_325FA70 opens the interaction menu.
// The trailing value2:u32 from the class1/op10 request is not consumed by
// this mode and therefore must not be appended to the notification.
func BuildRequestPeerSelectionNotice(targetID uint16, value0 uint32, targetHasParty bool) []byte {
var writer packetWriter
writer.writeUint16(targetID)
writer.writeByte(15)
writer.writeUint32(value0)
if targetHasParty {
writer.writeUint16(0)
} else {
writer.writeUint16(^uint16(0))
}
return writer.bytes()
}
// PeerNoticeStats carries the op7 prompt rows the requesting client cannot
// supply, because the mode-10 request tail and the mode-10 reply tail do not
// describe the same fields.
//
// Measured 2026-09-26 on the live server. The request tail (u32 value0,
// u16 value1, u32 value2, shared by modes 0/10/12/15) self-reports the
// requester as (复活币, 抗魔值, 0): char 22 sent value0=10 and value1=2828 while
// its stored 复活币 was 10 and its stored fatigue was 156, and char 1 sent
// value0=430 and value1=5837 while its stored 复活币 was 430 and its stored
// fatigue was 188. 复活币 matches exactly and fatigue cannot, so value1 is
// 抗魔值 -- the one row this server has no other source for, since nothing in
// this tree computes 抗魔.
//
// The reply the client actually reads was pinned by a marker probe (seven u16
// markers 0x1111..0x7777 at offsets 7/9/11/13/15/17/19 of a mode-10 op7): the
// dialog rendered 4369/8738/13107 under 疲劳值/虚弱状态/抗魔值 and ignored every
// marker past offset 11, so the tail is exactly three u16 at 7, 9, 11 in that
// order. Feeding the request's value1 into offset 7 is what put 抗魔值 on the
// 疲劳值 row.
type PeerNoticeStats struct {
// Fatigue is the requester's remaining fatigue (character stat "fatigue").
Fatigue uint16
// Weakness is the requester's 虚弱状态 on the client's own 0..100 scale,
// where 100 is healthy (see currentWeaknessCondition).
Weakness uint16
// Exorcism is the requester's 抗魔值, echoed from the request's value1.
Exorcism uint16
}
// BuildRequestPeerNotice constructs current NoPack class0/op7 for an online
// peer request. sub_1D82CF0 consumes a mode-specific tail: ordinary party
// mode 0 has three u16 values and one u32 server value, trade mode 1 has two
// u32 values. Street-assault modes 4/13 use BuildAssaultInvite instead:
// the generic op7 dialog has no mode-4 content or accepting callback.
//
// Modes 0/12 share mode 10's three value rows (same prompt widget, same
// offsets); only their trailing u32 differs and stays the request's value2.
func BuildRequestPeerNotice(sourceID uint16, req RequestPeerRequest, stats PeerNoticeStats) []byte {
var writer packetWriter
writer.writeUint16(sourceID)
writer.writeByte(req.Mode)
writer.writeUint32(req.Value0)
switch req.Mode {
case 0, 12:
// Same three rows as mode 10. The ordinary party invite renders the
// same prompt widget, so feeding it the request's value1 put 抗魔值 on
// its 疲劳值 row exactly as it did in the raid prompt (operator report
// 2026-09-26: the party prompt showed the same wrong rows until this
// arm was changed too). The trailing u32 stays the request's value2 --
// it is a further field this arm has never been shown to compute.
writer.writeUint16(stats.Fatigue)
writer.writeUint16(stats.Weakness)
writer.writeUint16(stats.Exorcism)
writer.writeUint32(req.Value2)
case 1:
writer.writeUint32(req.Value2)
case 10:
writer.writeUint16(stats.Fatigue)
writer.writeUint16(stats.Weakness)
writer.writeUint16(stats.Exorcism)
}
return writer.bytes()
}
// BuildResponsePeerAckPayload is appended after the class1/op11 success byte.
// Current sub_1D12480 always consumes target:u16 and mode:u8; trade mode 1
// consumes one additional state byte before entering its local trade path.
func BuildResponsePeerAckPayload(targetID uint16, mode byte) []byte {
var writer packetWriter
writer.writeUint16(targetID)
writer.writeByte(mode)
if mode == 1 {
writer.writeByte(0)
}
return writer.bytes()
}
// BuildResponsePeerNotice constructs class0/op8 for the original requester.
// Current sub_1D83250 consumes responder:u16, mode:u8 and value:u32.
func BuildResponsePeerNotice(responderID uint16, mode byte, value uint32) []byte {
var writer packetWriter
writer.writeUint16(responderID)
writer.writeByte(mode)
writer.writeUint32(value)
return writer.bytes()
}
// BuildAssaultInvite constructs class0/op443 (invite) and op444 (waiting).
// Current sub_33C8E20/sub_33C8D00 read actor:u16 and mode:u8.
func BuildAssaultInvite(inviterID uint16, mode byte) []byte {
var writer packetWriter
writer.writeUint16(inviterID)
writer.writeByte(mode)
return writer.bytes()
}
// BuildWalkoutPartyMemberNotice 构造 0x000A 队伍踢人/返回城镇通知:u8 slot,u8 mode。
func BuildWalkoutPartyMemberNotice(slot byte, mode byte) []byte {
var writer packetWriter
writer.writeByte(slot)
writer.writeByte(mode)
return writer.bytes()
}
// BuildChangePartyMemberPositionAck constructs the exact current-EXE op335
// result body: success followed by the authoritative source and target slots.
func BuildChangePartyMemberPositionAck(fromSlot byte, toSlot byte) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeByte(fromSlot)
writer.writeByte(toSlot)
return writer.bytes()
}
func partyMembers(state alignedcmd.PartyState) []alignedcmd.PartyMemberState {
members := make([]alignedcmd.PartyMemberState, 0, 4)
for _, member := range state.Members {
if member.UserID == 0 {
continue
}
members = append(members, member)
if len(members) == 4 {
break
}
}
if len(members) == 0 && state.UserID != 0 {
members = append(members, alignedcmd.PartyMemberState{
UserID: state.UserID,
UserState: state.UserState,
HPPercent: fullPartyMemberPercent,
MPPercent: fullPartyMemberPercent,
})
}
return members
}
func memberUserState(member alignedcmd.PartyMemberState, fallback byte) byte {
if member.UserState != 0 {
return member.UserState
}
if fallback != 0 {
return fallback
}
return defaultTownUserState
}
func memberPercentByte(value byte) byte {
if value == 0 {
return fullPartyMemberPercent
}
if value > fullPartyMemberPercent {
return fullPartyMemberPercent
}
return value
}
@@ -0,0 +1,603 @@
package party
import (
"bytes"
"context"
"encoding/binary"
"reflect"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
func TestDecodeSetPartyInfoRequestCurrentPresetLayout(t *testing.T) {
body := []byte{
0x00, 0x05,
0x04,
0x00, 0x00, 0x00, 0x00,
0x01,
0x00, 0x00,
0x00,
0x01,
0xe9, 0x12,
}
got, err := DecodeSetPartyInfoRequest(body)
if err != nil {
t.Fatalf("DecodeSetPartyInfoRequest error = %v", err)
}
if got.Prefix0 != 0 || got.Prefix1 != 5 ||
got.MemberSelectCode != 4 || got.MaxMembers != 4 ||
got.SelectionID != 0 || got.SelectionCode != 1 || got.SelectionValue != 0 ||
got.RecruitFlag != 0 || got.TargetMode != 1 || got.TargetDungeonID != 0x12e9 {
t.Fatalf("decoded preset request = %+v", got)
}
if len(got.NameBytes) == 0 {
t.Fatalf("preset name was not resolved")
}
}
func TestDecodeSetPartyInfoRequestCurrentSpecialChannelLayout(t *testing.T) {
body := []byte{
0x00, 0x02,
0x04,
0x00, 0x00, 0x00, 0x00,
0x05,
0x00, 0x00,
0x00,
0x00,
0x00, 0x00,
}
got, err := DecodeSetPartyInfoRequest(body)
if err != nil {
t.Fatalf("DecodeSetPartyInfoRequest error = %v", err)
}
if got.SelectionCode != 5 || got.TargetMode != 0 || got.TargetDungeonID != 0 {
t.Fatalf("decoded special-channel request = %+v", got)
}
}
func TestDecodeSetPartyInfoRequestCurrentCustomNameLayout(t *testing.T) {
body := []byte{
0x00, 0x00,
0x03, 0x00, 0x00, 0x00,
'a', 'b', 'c',
0x04,
0x44, 0x33, 0x22, 0x11,
0x02,
0x66, 0x55,
0x01,
0x02,
0xaa, 0x99,
}
got, err := DecodeSetPartyInfoRequest(body)
if err != nil {
t.Fatalf("DecodeSetPartyInfoRequest error = %v", err)
}
if string(got.NameBytes) != "abc" || got.SelectionID != 0x11223344 ||
got.SelectionCode != 2 || got.SelectionValue != 0x5566 ||
got.RecruitFlag != 1 || got.TargetMode != 2 || got.TargetDungeonID != 0x99aa {
t.Fatalf("decoded custom-name request = %+v", got)
}
}
func TestDecodeSetPartyInfoRequestRejectsLegacyOrTrailingLayout(t *testing.T) {
for _, body := range [][]byte{
{0, 3, 4, 0x9c, 0, 5, 0},
{0, 3, 4, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0x9c, 0, 0},
} {
if _, err := DecodeSetPartyInfoRequest(body); err == nil {
t.Fatalf("DecodeSetPartyInfoRequest accepted invalid layout: % X", body)
}
}
}
func TestBuildCurrentPartyStateSupportsFourRealMembers(t *testing.T) {
state := alignedcmd.PartyState{
PartyID: 1,
UserID: 1001,
NameBytes: []byte("party"),
MaxMembers: 4,
TargetDungeonID: 0x009c,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, Slot: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, Slot: 1, UserState: 1, HPPercent: 90, MPPercent: 80},
{UserID: 1003, Slot: 2, UserState: 1, HPPercent: 70, MPPercent: 60},
{UserID: 1004, Slot: 3, UserState: 1, HPPercent: 50, MPPercent: 40},
},
}
realtime := BuildSingleMemberRealtimeInfo(state)
if realtime[0] != 4 {
t.Fatalf("realtime count = %d, want 4", realtime[0])
}
for i, want := range []uint16{1001, 1002, 1003, 1004} {
offset := 1 + i*5
if got := binary.LittleEndian.Uint16(realtime[offset : offset+2]); got != want {
t.Fatalf("realtime member %d userID = %d, want %d", i, got, want)
}
// +2 is the HP percentage, byte-identical to the member's own value
// (0 falls back to full health, exactly like the pre-S1 behaviour).
if got, want := realtime[offset+2], []byte{100, 90, 70, 50}[i]; got != want {
t.Fatalf("realtime member %d hpPercent = %d, want %d", i, got, want)
}
if got := realtime[offset+3]; got != 0 {
t.Fatalf("realtime member %d helper flag = %d, want 0", i, got)
}
// +4 is the native party slot, so the four rows must stay distinct.
if got := realtime[offset+4]; got != byte(i) {
t.Fatalf("realtime member %d slot = %d, want %d", i, got, byte(i))
}
}
}
// TestBuildSingleMemberRealtimeInfoCarriesHpPercentAndSlot pins the two bytes
// S1 corrected against df_game_r CParty::get_party_realtime_info @0x0859CBAC:
// +2 = HP percent, +4 = native slot index. A slot left unset by the caller is
// filled with the first free slot so a multi-member body never repeats slot 0.
func TestBuildSingleMemberRealtimeInfoCarriesHpPercentAndSlot(t *testing.T) {
state := alignedcmd.PartyState{
PartyID: 7,
UserID: 1001,
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, Slot: 2, UserState: 1, HPPercent: 90, MPPercent: 90},
{UserID: 1002, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
got := BuildSingleMemberRealtimeInfo(state)
want := []byte{2, 0xe9, 0x03, 90, 0, 2, 0xea, 0x03, 100, 0, 0}
if !bytes.Equal(got, want) {
t.Fatalf("realtime body = % X, want % X", got, want)
}
if full := BuildSingleMemberRealtimeInfo(alignedcmd.PartyState{
PartyID: 7, UserID: 1001, MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{{UserID: 1001, UserState: 1}},
}); !bytes.Equal(full, []byte{1, 0xe9, 0x03, 100, 0, 0}) {
t.Fatalf("single-member body = % X, want 01 E9 03 64 00 00", full)
}
}
func TestBuildPeerEndpointInfoCurrentTwentyTwoByteMemberLayout(t *testing.T) {
got := BuildPeerEndpointInfo([]PeerEndpoint{
{UserID: 1001, InnerIPv4: [4]byte{192, 168, 1, 10}, OuterIPv4: [4]byte{192, 168, 1, 10}, Port: 10000, RoutingKey: 11, PathMTU: 512, UserState: 100},
{UserID: 1002, InnerIPv4: [4]byte{127, 0, 0, 1}, OuterIPv4: [4]byte{127, 0, 0, 1}, Port: 2311, RoutingKey: 22, NATType: 1, PathMTU: 1472, UserState: 100},
})
if len(got) != 1+2*partyPeerEndpointSize {
t.Fatalf("peer endpoint body len=%d want=%d body=%x", len(got), 1+2*partyPeerEndpointSize, got)
}
want := []byte{
2,
0xe9, 0x03,
192, 168, 1, 10,
192, 168, 1, 10,
0x27, 0x10,
0x0b, 0x00, 0x00, 0x00,
0,
0x00, 0x02, 0x00, 0x00,
100,
0xea, 0x03,
127, 0, 0, 1,
127, 0, 0, 1,
0x09, 0x07,
0x16, 0x00, 0x00, 0x00,
1,
0xc0, 0x05, 0x00, 0x00,
100,
}
if !bytes.Equal(got, want) {
t.Fatalf("peer endpoint body=%x want=%x", got, want)
}
}
func TestBuildDirectorySnapshotCurrentOp87Layout(t *testing.T) {
got := BuildDirectorySnapshot([]DirectoryRecord{
{
PartyID: 1001,
SelectionID: 0x12e9,
MemberIDs: []uint16{1001, 1002},
},
})
if len(got) != 2+28 {
t.Fatalf("directory body len = %d, want 30", len(got))
}
wantPrefix := []byte{
1, 0,
0xe9, 0x03,
0,
2,
0,
0xff,
0xe9, 0x12, 0, 0,
0, 0xe9, 0x03,
0, 0xea, 0x03,
}
if !reflect.DeepEqual(got[:len(wantPrefix)], wantPrefix) {
t.Fatalf("directory prefix = % X, want % X", got[:len(wantPrefix)], wantPrefix)
}
for offset := len(wantPrefix); offset < len(got); offset += 3 {
if !reflect.DeepEqual(got[offset:offset+3], []byte{0xff, 0xff, 0xff}) {
t.Fatalf("directory empty slot at %d = % X", offset, got[offset:offset+3])
}
}
}
func TestBuildDirectorySnapshotEmpty(t *testing.T) {
if got := BuildDirectorySnapshot(nil); !reflect.DeepEqual(got, []byte{0, 0}) {
t.Fatalf("empty directory body = % X, want 00 00", got)
}
}
func TestSetPartyInfoDefersCurrentPartyFrameProjectionToBridge(t *testing.T) {
state := alignedcmd.PartyState{}
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketSetPartyInfo),
Body: []byte{0, 3, 4, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0x9c, 0},
SelectedCharacterID: 1001,
Party: &state,
})
if err != nil {
t.Fatalf("Handle(SetPartyInfo) error = %v", err)
}
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("handled=%t responseAllowed=%t", got.Handled, got.ResponseAllowed)
}
if len(got.UpperResponses) != 1 {
t.Fatalf("response count = %d, want ACK before bridge projection", len(got.UpperResponses))
}
for _, response := range got.UpperResponses {
if response.Classification == 0 && response.MsgID == 0x0009 {
t.Fatalf("unsafe class0/op9 scene record emitted: % X", response.Body)
}
}
ack := got.UpperResponses[0]
if ack.Classification != dnfproto.DefaultChannelClassification ||
ack.MsgID != uint16(dnfenum.CmdPacketSetPartyInfo) ||
!reflect.DeepEqual(ack.Body, []byte{1}) {
t.Fatalf("ack = class %d msg %d body % X", ack.Classification, ack.MsgID, ack.Body)
}
if !reflect.DeepEqual(got.PostActions, []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedPartyFrame}) {
t.Fatalf("post actions = %v, want party frame refresh", got.PostActions)
}
if state.PartyID != 1001 {
t.Fatalf("party ID = %d, want current leader character ID 1001", state.PartyID)
}
}
func TestQuickPartyAckOnlyCommands(t *testing.T) {
h := NewHandler()
for _, opcode := range []dnfenum.CmdPacket{
dnfenum.CmdPacketCancelQuickParty,
dnfenum.CmdPacketDirectEntranceDungeonQuickParty,
} {
t.Run(dnfenum.CmdPacketName(uint16(opcode)), func(t *testing.T) {
got, err := h.Handle(context.Background(), alignedcmd.Request{Opcode: uint16(opcode)})
if err != nil {
t.Fatalf("Handle(%d) error = %v", opcode, err)
}
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("Handle(%d) handled=%v responseAllowed=%v", opcode, got.Handled, got.ResponseAllowed)
}
if len(got.UpperResponses) != 1 {
t.Fatalf("Handle(%d) response count = %d, want 1", opcode, len(got.UpperResponses))
}
resp := got.UpperResponses[0]
if resp.MsgID != uint16(opcode) || !reflect.DeepEqual(resp.Body, []byte{1}) {
t.Fatalf("Handle(%d) response msg=%d body=% X", opcode, resp.MsgID, resp.Body)
}
})
}
}
func TestRegisterQuickPartyPending(t *testing.T) {
body := make([]byte, 4+5*2)
binary.LittleEndian.PutUint32(body[:4], 2)
binary.LittleEndian.PutUint32(body[4:8], 1001)
body[8] = 1
binary.LittleEndian.PutUint32(body[9:13], 1002)
body[13] = 0
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRegisterQuickParty),
Body: body,
})
if err != nil {
t.Fatalf("Handle(register quick party) error = %v", err)
}
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
t.Fatalf("register quick party handled=%v responseAllowed=%v responses=%d",
got.Handled, got.ResponseAllowed, len(got.UpperResponses))
}
}
func TestReserveLeavePartyAck(t *testing.T) {
state := alignedcmd.PartyState{}
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketReserveLeaveParty),
Body: []byte{1},
SelectedCharacterID: 1001,
Party: &state,
})
if err != nil {
t.Fatalf("Handle(reserve leave party) error = %v", err)
}
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("reserve leave party handled=%v responseAllowed=%v", got.Handled, got.ResponseAllowed)
}
if state.ReserveLeaveFlag != 1 {
t.Fatalf("ReserveLeaveFlag = %d, want 1", state.ReserveLeaveFlag)
}
if len(got.UpperResponses) != 1 {
t.Fatalf("response count = %d, want 1", len(got.UpperResponses))
}
want := []byte{1, 1, 0xe9, 0x03}
resp := got.UpperResponses[0]
if resp.MsgID != uint16(dnfenum.CmdPacketReserveLeaveParty) || !reflect.DeepEqual(resp.Body, want) {
t.Fatalf("reserve response msg=%d body=% X, want msg=%d body=% X",
resp.MsgID, resp.Body, dnfenum.CmdPacketReserveLeaveParty, want)
}
}
func TestEntryIntoPartyRequestAndAck(t *testing.T) {
body := make([]byte, 4)
binary.LittleEndian.PutUint32(body, 1002)
parsed, err := DecodeEntryIntoPartyRequest(body)
if err != nil {
t.Fatalf("DecodeEntryIntoPartyRequest error = %v", err)
}
if parsed.TargetID != 1002 {
t.Fatalf("target = %d, want 1002", parsed.TargetID)
}
want := []byte{1, 0xea, 0x03, 0x00, 0x00, 0xe9, 0x03, 0x00, 0x00}
if got := BuildEntryIntoPartyAck(1002, 1001); !reflect.DeepEqual(got, want) {
t.Fatalf("entry ack = % X, want % X", got, want)
}
}
func TestBuildAssaultInvite(t *testing.T) {
got := BuildAssaultInvite(1001, 13)
want := []byte{0xe9, 0x03, 13}
if !reflect.DeepEqual(got, want) {
t.Fatalf("quick party invite = % X, want % X", got, want)
}
}
func TestBuildRequestPeerNoticesUseCurrentModeSpecificShapes(t *testing.T) {
tests := []struct {
name string
req RequestPeerRequest
stats PeerNoticeStats
want []byte
}{
{
// The ordinary party invite renders the same prompt widget as the
// raid application, so it writes the same three value rows and
// keeps value1 only as the 抗魔值 source. Its trailing u32 is the
// one thing this arm does not compute and stays the request's.
name: "party",
req: RequestPeerRequest{Mode: 0, Value0: 0x11223344, Value1: 0x5566, Value2: 0x778899aa},
stats: PeerNoticeStats{Fatigue: 156, Weakness: 100, Exorcism: 0x5566},
want: []byte{
0xe9, 0x03, 0,
0x44, 0x33, 0x22, 0x11, // 复活币 0x11223344
0x9c, 0, // 疲劳值 156
0x64, 0, // 虚弱状态 100 (healthy)
0x66, 0x55, // 抗魔值 0x5566
0xaa, 0x99, 0x88, 0x77, // value2, unchanged
},
},
{
name: "trade",
req: RequestPeerRequest{Mode: 1, Value0: 0x11223344, Value2: 0x55667788},
want: []byte{0xe9, 0x03, 1, 0x44, 0x33, 0x22, 0x11, 0x88, 0x77, 0x66, 0x55},
},
{
name: "quick party",
req: RequestPeerRequest{Mode: 13, Value0: 0x11223344},
want: []byte{0xe9, 0x03, 13, 0x44, 0x33, 0x22, 0x11},
},
{
// Raid admission. The reply tail is 疲劳值/虚弱状态/抗魔值, which is
// NOT the request tail (复活币/抗魔值/0): char 22 asked with
// Value1=2828 for a character whose stored fatigue was 156, so
// Value1 is 抗魔值 and the two middle rows must be supplied by the
// server. Offsets pinned on the wire by the 2026-09-26 marker
// probe -- 0x1111/0x2222/0x3333 at 7/9/11 rendered under exactly
// those three labels, and every marker past offset 11 was ignored.
name: "raid admission",
req: RequestPeerRequest{Mode: 10, Value0: 10, Value1: 2828},
stats: PeerNoticeStats{Fatigue: 156, Weakness: 100, Exorcism: 2828},
want: []byte{
0xe9, 0x03, 10,
0x0a, 0, 0, 0, // 复活币 10
0x9c, 0, // 疲劳值 156
0x64, 0, // 虚弱状态 100 (healthy)
0x0c, 0x0b, // 抗魔值 2828
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := BuildRequestPeerNotice(1001, test.req, test.stats); !bytes.Equal(got, test.want) {
t.Fatalf("notice=% X want=% X", got, test.want)
}
})
}
}
func TestBuildResponsePeerBodies(t *testing.T) {
if got, want := BuildResponsePeerAckPayload(1001, 0), []byte{0xe9, 0x03, 0}; !bytes.Equal(got, want) {
t.Fatalf("party ack payload=% X want=% X", got, want)
}
if got, want := BuildResponsePeerAckPayload(1001, 1), []byte{0xe9, 0x03, 1, 0}; !bytes.Equal(got, want) {
t.Fatalf("trade ack payload=% X want=% X", got, want)
}
if got, want := BuildResponsePeerNotice(1002, 1, 0), []byte{0xea, 0x03, 1, 0, 0, 0, 0}; !bytes.Equal(got, want) {
t.Fatalf("response notice=% X want=% X", got, want)
}
}
func TestBuildWalkoutPartyMemberNotice(t *testing.T) {
got := BuildWalkoutPartyMemberNotice(1, 0)
want := []byte{1, 0}
if !reflect.DeepEqual(got, want) {
t.Fatalf("walkout party member notice = % X, want % X", got, want)
}
}
func TestBuildChangePartyMemberPositionAck(t *testing.T) {
got := BuildChangePartyMemberPositionAck(1, 0)
want := []byte{1, 1, 0}
if !reflect.DeepEqual(got, want) {
t.Fatalf("change party member position ack = % X, want % X", got, want)
}
}
func TestBuildRequestPeerAck(t *testing.T) {
if got := BuildRequestPeerAck(); !reflect.DeepEqual(got, []byte{1}) {
t.Fatalf("request peer ack = % X, want 01", got)
}
}
func TestBuildRequestPeerSelectionNotice(t *testing.T) {
partyless := BuildRequestPeerSelectionNotice(1002, 0x11223344, false)
wantPartyless := []byte{0xea, 0x03, 15, 0x44, 0x33, 0x22, 0x11, 0xff, 0xff}
if !reflect.DeepEqual(partyless, wantPartyless) {
t.Fatalf("partyless request peer selection notice = % X, want % X", partyless, wantPartyless)
}
active := BuildRequestPeerSelectionNotice(1002, 0x11223344, true)
wantActive := []byte{0xea, 0x03, 15, 0x44, 0x33, 0x22, 0x11, 0, 0}
if !reflect.DeepEqual(active, wantActive) {
t.Fatalf("active request peer selection notice = % X, want % X", active, wantActive)
}
}
func TestDecodeRequestPeerRequest(t *testing.T) {
body := []byte{
0xea, 0x03,
15,
0x44, 0x33, 0x22, 0x11,
0x66, 0x55,
0xaa, 0x99, 0x88, 0x77,
}
got, err := DecodeRequestPeerRequest(body)
if err != nil {
t.Fatalf("DecodeRequestPeerRequest error = %v", err)
}
if got.TargetID != 1002 || got.Mode != 15 ||
got.Value0 != 0x11223344 || got.Value1 != 0x5566 || got.Value2 != 0x778899aa {
t.Fatalf("request peer = %+v", got)
}
}
func TestDecodeResponsePeerRequest(t *testing.T) {
got, err := DecodeResponsePeerRequest([]byte{0xea, 0x03, 13, 1, 0, 0, 0})
if err != nil {
t.Fatalf("DecodeResponsePeerRequest error = %v", err)
}
if got.TargetID != 1002 || got.Mode != 13 || got.Value != 1 {
t.Fatalf("response peer = %+v, want target=1002 mode=13 value=1", got)
}
}
func TestDecodeRequestPeerModeSpecificLengths(t *testing.T) {
partyBody := []byte{0xea, 0x03, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
partyReq, err := DecodeRequestPeerRequest(partyBody)
if err != nil || partyReq.TargetID != 1002 || partyReq.Mode != 0 || partyReq.Value1 != 0x0605 || partyReq.Value2 != 0x0a090807 {
t.Fatalf("party request=%+v err=%v", partyReq, err)
}
tradeBody := []byte{0xea, 0x03, 1, 1, 2, 3, 4, 5, 6, 7, 8}
tradeReq, err := DecodeRequestPeerRequest(tradeBody)
if err != nil || tradeReq.TargetID != 1002 || tradeReq.Mode != 1 || tradeReq.Value2 != 0x08070605 {
t.Fatalf("trade request=%+v err=%v", tradeReq, err)
}
}
func TestDecodeWalkoutPartyMemberRequest(t *testing.T) {
got, err := DecodeWalkoutPartyMemberRequest([]byte{1})
if err != nil {
t.Fatalf("DecodeWalkoutPartyMemberRequest error = %v", err)
}
if got.Slot != 1 {
t.Fatalf("slot = %d, want 1", got.Slot)
}
}
func TestDecodeChangePartyMemberPositionRequest(t *testing.T) {
got, err := DecodeChangePartyMemberPositionRequest([]byte{2, 3})
if err != nil {
t.Fatalf("DecodeChangePartyMemberPositionRequest error = %v", err)
}
if got.Slot != 2 || got.Position != 3 {
t.Fatalf("change position = %+v, want slot=2 position=3", got)
}
if _, err := DecodeChangePartyMemberPositionRequest([]byte{2, 2}); err == nil {
t.Fatalf("DecodeChangePartyMemberPositionRequest accepted invalid position")
}
}
func TestPartyFixedRequestDecodersRejectTrailingBytes(t *testing.T) {
quick := make([]byte, 10)
binary.LittleEndian.PutUint32(quick[:4], 1)
tests := []struct {
name string
body []byte
call func([]byte) error
}{
{name: "register quick party", body: quick, call: func(body []byte) error { _, err := DecodeRegisterQuickPartyRequest(body); return err }},
{name: "reserve leave", body: []byte{1, 0}, call: func(body []byte) error { _, err := DecodeReserveLeavePartyRequest(body); return err }},
{name: "entry into party", body: []byte{1, 0, 0, 0, 0}, call: func(body []byte) error { _, err := DecodeEntryIntoPartyRequest(body); return err }},
{name: "request peer", body: make([]byte, 14), call: func(body []byte) error { _, err := DecodeRequestPeerRequest(body); return err }},
{name: "response peer", body: []byte{1, 0, 13, 1, 0, 0, 0, 0}, call: func(body []byte) error { _, err := DecodeResponsePeerRequest(body); return err }},
{name: "walkout", body: []byte{1, 0}, call: func(body []byte) error { _, err := DecodeWalkoutPartyMemberRequest(body); return err }},
{name: "change position", body: []byte{1, 3, 0}, call: func(body []byte) error { _, err := DecodeChangePartyMemberPositionRequest(body); return err }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.call(tc.body); err == nil {
t.Fatalf("decoder accepted trailing byte: % X", tc.body)
}
})
}
}
func TestEntryIntoPartyFinishAckRequiresPartyContext(t *testing.T) {
state := alignedcmd.PartyState{
PartyID: 1,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001},
{UserID: 1002},
},
}
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketEntryIntoPartyFinish),
Party: &state,
})
if err != nil {
t.Fatalf("Handle(entry finish) error = %v", err)
}
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("entry finish handled=%v responseAllowed=%v responses=%d",
got.Handled, got.ResponseAllowed, len(got.UpperResponses))
}
resp := got.UpperResponses[0]
if resp.MsgID != uint16(dnfenum.CmdPacketEntryIntoPartyFinish) ||
resp.Classification != 0 || resp.AllowCodec ||
!reflect.DeepEqual(resp.Body, []byte{1, 0}) {
t.Fatalf("entry finish response msg=%d body=% X", resp.MsgID, resp.Body)
}
}
func TestEntryIntoPartyFinishWithoutContextDoesNotForgeClassOneFailure(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketEntryIntoPartyFinish),
})
if err != nil {
t.Fatalf("Handle(entry finish without context) error = %v", err)
}
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
t.Fatalf("entry finish without context handled=%v responseAllowed=%v responses=%d",
got.Handled, got.ResponseAllowed, len(got.UpperResponses))
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package party 负责当前 DNF 客户端已经闭合的队伍请求和实时成员状态。
//
// 当前 NoPack.exe 将 class0/op153 解析为队伍实时成员状态。class0/op9 属于城镇场景记录,
// 不能复用旧服务端的队伍快照布局。
package party
@@ -0,0 +1,329 @@
// 本文件把已验证的队伍 C2S 命令转换成客户端期望的 upper 回包序列。
package party
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type handler struct{}
// NewHandler 创建队伍协议处理器。
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain 返回队伍业务域。
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainParty
}
// Handle 开放已验证的队伍命令;快速组队入口目前只做 ACK,不伪造进场景。
func (handler) Handle(_ context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketSetPartyInfo:
return handleSetPartyInfo(req), nil
case dnfenum.CmdPacketLeaveParty:
return handleLeaveParty(req), nil
case dnfenum.CmdPacketCallPartyMemberRealtimeInfo:
return handleCallRealtime(req), nil
case dnfenum.CmdPacketRegisterQuickParty:
return handleRegisterQuickParty(req), nil
case dnfenum.CmdPacketCancelQuickParty:
return handleAckOnly(req, "cancel_quick_party", "MCP 20260705 证据:S2C 0x1BC 成功分支不读正文,取消快速组队只需成功 ACK"), nil
case dnfenum.CmdPacketDirectEntranceDungeonQuickParty:
return handleAckOnly(req, "direct_entrance_dungeon_quick_party", "MCP 20260705 证据:S2C 0x1BD 成功分支不读正文;这里只确认入口请求,后续进场景链单独发送"), nil
case dnfenum.CmdPacketReserveLeaveParty:
return handleReserveLeaveParty(req), nil
case dnfenum.CmdPacketEntryIntoParty:
return handleEntryIntoParty(req), nil
case dnfenum.CmdPacketEntryIntoPartyFinish:
return handleEntryIntoPartyFinish(req), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("party 模块未登记 opcode %d", req.Opcode),
}, nil
}
}
func handleEntryIntoParty(req alignedcmd.Request) alignedcmd.Result {
parsed, err := DecodeEntryIntoPartyRequest(req.Body)
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: "entry_into_party",
Reason: fmt.Sprintf("ENTRY_INTO_PARTY 解析失败:%v;禁止伪造成功 ACK", err),
}
}
if req.SelectedCharacterID == 0 {
return failureAckCode(req.Opcode, "entry_into_party", 3, "缺少已选择角色,按 705 失败分支返回通用失败码")
}
if parsed.TargetID == 0 {
return failureAckCode(req.Opcode, "entry_into_party", 19, "705 目标 ID 为空,按 EXE 失败码集合返回 19")
}
return failureAckCode(req.Opcode, "entry_into_party", 3, "705 需要 dnfbridge 在线 session 协调目标端;模块兜底不伪造入队成功")
}
func handleEntryIntoPartyFinish(req alignedcmd.Request) alignedcmd.Result {
if req.Party != nil && req.Party.PartyID > 0 && len(req.Party.Members) > 1 {
state := req.Party.UserState
if state == 0 {
state = 1
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: "entry_into_party_finish",
Reason: "Current EXE class0/op706 reads state:u8,count:u8 and count pairs of u32/u32; runtime has no peer-value owner, so send an exact zero-count body",
UpperResponses: []alignedcmd.UpperResponse{
{
MsgID: req.Opcode,
Body: BuildEntryIntoPartyFinishEmptyBody(state),
Classification: 0,
AllowCodec: false,
},
},
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: "entry_into_party_finish",
Reason: "706 lacks a class1 result envelope in the current EXE; without party context, do not send the old malformed {0,3} body",
}
}
func handleRegisterQuickParty(req alignedcmd.Request) alignedcmd.Result {
parsed, err := DecodeRegisterQuickPartyRequest(req.Body)
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: "register_quick_party",
Reason: fmt.Sprintf("REGISTER_QUICK_PARTY 解析失败:%v;不能按旧枚举伪造 ACK", err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: "register_quick_party",
Reason: fmt.Sprintf(
"MCP 20260705:C2S 0x1BB 已解析 %d 个目标;S2C 同号 handler 读 u16+u8,不是本请求 ACK,等待真实快速组队推送结构",
len(parsed.Targets),
),
}
}
func handleReserveLeaveParty(req alignedcmd.Request) alignedcmd.Result {
parsed, err := DecodeReserveLeavePartyRequest(req.Body)
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: "reserve_leave_party",
Reason: fmt.Sprintf("RESERVE_LEAVE_PARTY 解析失败:%v;禁止伪造成功 ACK", err),
}
}
if req.SelectedCharacterID == 0 {
return failureAck(req.Opcode, "reserve_leave_party", "缺少已选择角色,返回失败 ACK 避免客户端读取不存在的 targetChar")
}
if req.Party != nil {
req.Party.ReserveLeaveFlag = parsed.Flag
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: "reserve_leave_party",
Reason: "MCP 20260705:class1 0x02B3 成功响应读取 u8 flag + u16 targetChar",
UpperResponses: []alignedcmd.UpperResponse{
{
MsgID: req.Opcode,
Body: BuildReserveLeavePartyAck(parsed.Flag, req.SelectedCharacterID),
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
},
},
}
}
func handleAckOnly(req alignedcmd.Request, operation string, reason string) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: reason,
UpperResponses: []alignedcmd.UpperResponse{successAck(req.Opcode)},
}
}
func handleSetPartyInfo(req alignedcmd.Request) alignedcmd.Result {
if req.Party == nil || req.SelectedCharacterID == 0 || req.SelectedCharacterID == ^uint16(0) {
return failureAck(req.Opcode, "set_party_info", "缺少已选择角色或队伍会话态")
}
parsed, err := DecodeSetPartyInfoRequest(req.Body)
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: "set_party_info",
Reason: fmt.Sprintf("SET_PARTY_INFO 请求体解析失败:%v;禁止伪造成功 ACK", err),
}
}
state := req.Party
if state.PartyID <= 0 || state.PartyID >= int(^uint16(0)) {
// Current EXE op87/op90 carries the party key as u16. Use the
// online leader character ID as the stable active-party key instead
// of the historical 100000+ IDs that truncated on wire.
state.PartyID = int(req.SelectedCharacterID)
}
state.IsLeader = true
state.UserID = req.SelectedCharacterID
if state.UserState == 0 {
state.UserState = defaultTownUserState
}
state.RequestPrefix0 = parsed.Prefix0
state.RequestPrefix1 = parsed.Prefix1
state.NameBytes = append(state.NameBytes[:0], parsed.NameBytes...)
state.MemberSelectCode = parsed.MemberSelectCode
state.MaxMembers = parsed.MaxMembers
state.SelectionID = parsed.SelectionID
state.SelectionCode = parsed.SelectionCode
state.SelectionValue = parsed.SelectionValue
state.RecruitFlag = parsed.RecruitFlag
state.TargetMode = parsed.TargetMode
state.TargetDungeonID = parsed.TargetDungeonID
upsertCurrentMember(state)
responses := []alignedcmd.UpperResponse{
successAck(req.Opcode),
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: "set_party_info",
Reason: fmt.Sprintf(
"当前 NoPack.exe:class1/op12 成功 ACK 后,由 bridge 重建 sub_1D64CA0 的 class0/op9 kind0 角色队伍槽位表,再发送 class0/op153 与 class0/op7;party=%d memberCode=0x%02X max=%d selection=%d/%d/%d recruit=%d targetMode=%d dungeon=0x%04X",
state.PartyID, state.MemberSelectCode, state.MaxMembers, state.SelectionID, state.SelectionCode,
state.SelectionValue, state.RecruitFlag, state.TargetMode, state.TargetDungeonID,
),
UpperResponses: responses,
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedPartyFrame},
}
}
func handleLeaveParty(req alignedcmd.Request) alignedcmd.Result {
if req.Party == nil || req.SelectedCharacterID == 0 {
return failureAck(req.Opcode, "leave_party", "缺少已选择角色或队伍会话态")
}
*req.Party = alignedcmd.PartyState{}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: "leave_party",
Reason: "当前 NoPack.exe:退队 ACK 后,由 bridge 先清空 class0/op9 kind0 角色队伍槽位表,再以 class0/op153 count=0 清空实时成员状态",
UpperResponses: []alignedcmd.UpperResponse{
successAck(req.Opcode),
},
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedPartyFrame},
}
}
func handleCallRealtime(req alignedcmd.Request) alignedcmd.Result {
if req.Party == nil || req.Party.PartyID <= 0 || req.Party.UserID == 0 {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: "call_party_member_realtime_info",
Reason: "当前不在队伍中,按当前 NoPack.exe sub_1D800C0 布局返回 class0/op153 count=0",
UpperResponses: []alignedcmd.UpperResponse{partyRealtimeResponse(BuildEmptyRealtimeInfo())},
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: "call_party_member_realtime_info",
Reason: "Current NoPack.exe sub_1D800C0 consumes each class0/op153 row as uid, HP percent, helper flag, and slot index; one exact op153 snapshot is complete",
UpperResponses: []alignedcmd.UpperResponse{partyRealtimeResponse(BuildSingleMemberRealtimeInfo(*req.Party))},
}
}
func upsertCurrentMember(state *alignedcmd.PartyState) {
for i := range state.Members {
if state.Members[i].UserID == state.UserID {
state.Members[i].UserState = state.UserState
state.Members[i].HPPercent = 100
state.Members[i].MPPercent = 100
return
}
}
if len(state.Members) >= 4 {
return
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: state.UserID,
UserState: state.UserState,
HPPercent: 100,
MPPercent: 100,
})
}
func successAck(opcode uint16) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: opcode,
Body: []byte{1},
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}
}
func failureAck(opcode uint16, operation string, reason string) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: reason,
UpperResponses: []alignedcmd.UpperResponse{
{
MsgID: opcode,
Body: []byte{0},
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
},
},
}
}
func failureAckCode(opcode uint16, operation string, code byte, reason string) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: reason,
UpperResponses: []alignedcmd.UpperResponse{
{
MsgID: opcode,
Body: []byte{0, code},
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
},
},
}
}
func partyRealtimeResponse(body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: 0x0099,
Body: body,
Classification: 0,
AllowCodec: true,
}
}
@@ -0,0 +1,289 @@
// 本文件解析队伍模块已追到读取顺序的客户端请求体。
package party
import (
"encoding/binary"
"errors"
)
// SetPartyInfoRequest 是创建/修改队伍弹窗提交后的已知字段。
type SetPartyInfoRequest struct {
Prefix0 byte
Prefix1 byte
NameBytes []byte
MemberSelectCode byte
MaxMembers byte
SelectionID uint32
SelectionCode byte
SelectionValue uint16
RecruitFlag byte
TargetMode byte
TargetDungeonID uint16
}
// QuickPartyTarget 是 0x01BB 快速组队登记里的一条目标记录。
type QuickPartyTarget struct {
TargetID uint32
Available byte
}
// RegisterQuickPartyRequest 保存 0x01BB 的批量目标列表。
type RegisterQuickPartyRequest struct {
Targets []QuickPartyTarget
}
// ReserveLeavePartyRequest 保存 0x02B3 预约离队/取消预约的本地状态位。
type ReserveLeavePartyRequest struct {
Flag byte
}
// EntryIntoPartyRequest 保存 0x02C1 申请进入队伍时携带的目标角色/对象 ID。
type EntryIntoPartyRequest struct {
TargetID uint32
}
// ResponsePeerRequest 保存 0x000B 的通用 peer 回应体;组队邀请只使用其中 mode=4/13 的分支。
type ResponsePeerRequest struct {
TargetID uint16
Mode byte
Value uint32
}
// RequestPeerRequest 保存 0x000A 的通用 peer 请求体。
// 当前 EXE 按 mode 使用两种尾部:mode 0/10/12/15 写
// u32 value0,u16 value1,u32 value2;mode 1/4/13 写
// u32 value0,u32 value2。
type RequestPeerRequest struct {
TargetID uint16
Mode byte
Value0 uint32
Value1 uint16
Value2 uint32
}
// WalkoutPartyMemberRequest 保存 0x000E 队长踢出队员时写入的槽位号。
type WalkoutPartyMemberRequest struct {
Slot byte
}
// ChangePartyMemberPositionRequest 保存 0x014F 队伍槽位位置调整请求。
type ChangePartyMemberPositionRequest struct {
Slot byte
Position byte
}
var errSetPartyInfoBodyShort = errors.New("set party info body too short")
var errRegisterQuickPartyBodyShort = errors.New("register quick party body too short")
var errReserveLeavePartyBodyShort = errors.New("reserve leave party body too short")
var errEntryIntoPartyBodyShort = errors.New("entry into party body too short")
var errRequestPeerBodyShort = errors.New("request peer body too short")
var errResponsePeerBodyShort = errors.New("response peer body too short")
var errWalkoutPartyMemberBodyShort = errors.New("walkout party member body too short")
var errChangePartyMemberPositionBodyShort = errors.New("change party member position body too short")
var errChangePartyMemberPositionInvalid = errors.New("change party member position invalid")
var partyNamePresetTable = [][]byte{
[]byte("\u5F3A\u5F3A\u8054\u5408~"),
[]byte("\u75AF\u72C2\u7EC3\u7EA7\uFF01 \u6709\u610F\u8005\u901F\u5EA6\u7EC4\u961F\uFF01 "),
[]byte("\u4EBA\u54C1\u597D\u7684\uFF0C \u8BF7\u6765\u8FD9\u91CC\u5427\u3002 "),
[]byte("\u53D7\u8D5B\u4E3D\u4E9A\u795D\u798F\u7684\u961F\u4F0D"),
[]byte("\u5237\u7A00\u6709\u88C5\u5907\uFF0C \u6709\u610F\u8005\u901F\u6765\u3002 "),
[]byte("\u52DF\u96C6\u5F3A\u8005\u961F\u4F0D\u3002 "),
}
// DecodeSetPartyInfoRequest follows the current NoPack.exe op12 writer:
// mode,u8 namePreset,[DSTR customName],u8 memberCode,u32 selectionID,
// u8 selectionCode,u16 selectionValue,u8 recruitFlag,u8 targetMode,u16 targetID.
func DecodeSetPartyInfoRequest(body []byte) (SetPartyInfoRequest, error) {
const fixedTailSize = 12
if len(body) < 2+fixedTailSize {
return SetPartyInfoRequest{}, errSetPartyInfoBodyShort
}
req := SetPartyInfoRequest{
Prefix0: body[0],
Prefix1: body[1],
MaxMembers: 4,
}
tailOffset := 2
if req.Prefix1 == 0 {
if len(body) < 6+fixedTailSize {
return SetPartyInfoRequest{}, errSetPartyInfoBodyShort
}
nameLen := int(int32(binary.LittleEndian.Uint32(body[2:6])))
if nameLen < 0 || nameLen > 128 || 6+nameLen+fixedTailSize != len(body) {
return SetPartyInfoRequest{}, errSetPartyInfoBodyShort
}
req.NameBytes = append([]byte(nil), body[6:6+nameLen]...)
tailOffset = 6 + nameLen
} else if len(body) != 2+fixedTailSize {
return SetPartyInfoRequest{}, errSetPartyInfoBodyShort
}
if len(body)-tailOffset != fixedTailSize {
return SetPartyInfoRequest{}, errSetPartyInfoBodyShort
}
req.MemberSelectCode = body[tailOffset]
req.SelectionID = binary.LittleEndian.Uint32(body[tailOffset+1 : tailOffset+5])
req.SelectionCode = body[tailOffset+5]
req.SelectionValue = binary.LittleEndian.Uint16(body[tailOffset+6 : tailOffset+8])
req.RecruitFlag = body[tailOffset+8]
req.TargetMode = body[tailOffset+9]
req.TargetDungeonID = binary.LittleEndian.Uint16(body[tailOffset+10 : tailOffset+12])
req.MaxMembers = decodePartyMaxMembers(req.MemberSelectCode)
if len(req.NameBytes) == 0 {
req.NameBytes = resolvePartyNamePreset(req.Prefix1)
}
return req, nil
}
// DecodeRegisterQuickPartyRequest 按 EXE 0x21B20C0 读取顺序解析:u32 count + count*(u32 target,u8 available)。
func DecodeRegisterQuickPartyRequest(body []byte) (RegisterQuickPartyRequest, error) {
if len(body) < 4 {
return RegisterQuickPartyRequest{}, errRegisterQuickPartyBodyShort
}
count := binary.LittleEndian.Uint32(body[:4])
if count > uint32((len(body)-4)/5) {
return RegisterQuickPartyRequest{}, errRegisterQuickPartyBodyShort
}
need := 4 + int(count)*5
if len(body) != need {
return RegisterQuickPartyRequest{}, errRegisterQuickPartyBodyShort
}
req := RegisterQuickPartyRequest{Targets: make([]QuickPartyTarget, 0, int(count))}
offset := 4
for i := uint32(0); i < count; i++ {
req.Targets = append(req.Targets, QuickPartyTarget{
TargetID: binary.LittleEndian.Uint32(body[offset : offset+4]),
Available: body[offset+4],
})
offset += 5
}
return req, nil
}
// DecodeReserveLeavePartyRequest 按 EXE 0x2B3 发送点解析单字节 flag。
func DecodeReserveLeavePartyRequest(body []byte) (ReserveLeavePartyRequest, error) {
if len(body) != 1 {
return ReserveLeavePartyRequest{}, errReserveLeavePartyBodyShort
}
return ReserveLeavePartyRequest{Flag: body[0]}, nil
}
// DecodeEntryIntoPartyRequest 按 EXE 0x12C8B47 发送点解析:body 只写入一个 u32 目标 ID。
func DecodeEntryIntoPartyRequest(body []byte) (EntryIntoPartyRequest, error) {
if len(body) != 4 {
return EntryIntoPartyRequest{}, errEntryIntoPartyBodyShort
}
return EntryIntoPartyRequest{TargetID: binary.LittleEndian.Uint32(body[:4])}, nil
}
// DecodeRequestPeerRequest 按当前 EXE sub_325F9D0 等发送点解析:
// u16 target + u8 mode + u32 value0 + u16 value1 + u32 value2。
func DecodeRequestPeerRequest(body []byte) (RequestPeerRequest, error) {
if len(body) < 7 {
return RequestPeerRequest{}, errRequestPeerBodyShort
}
req := RequestPeerRequest{
TargetID: binary.LittleEndian.Uint16(body[:2]),
Mode: body[2],
Value0: binary.LittleEndian.Uint32(body[3:7]),
}
switch req.Mode {
case 0, 10, 12, 15:
if len(body) != 13 {
return RequestPeerRequest{}, errRequestPeerBodyShort
}
req.Value1 = binary.LittleEndian.Uint16(body[7:9])
req.Value2 = binary.LittleEndian.Uint32(body[9:13])
case 1, 4, 13:
if len(body) != 11 {
return RequestPeerRequest{}, errRequestPeerBodyShort
}
req.Value2 = binary.LittleEndian.Uint32(body[7:11])
default:
if len(body) != 7 {
return RequestPeerRequest{}, errRequestPeerBodyShort
}
}
return req, nil
}
// DecodeResponsePeerRequest 按 EXE 发送点解析:u16 target + u8 mode + u32 value。
func DecodeResponsePeerRequest(body []byte) (ResponsePeerRequest, error) {
if len(body) != 7 {
return ResponsePeerRequest{}, errResponsePeerBodyShort
}
return ResponsePeerRequest{
TargetID: binary.LittleEndian.Uint16(body[:2]),
Mode: body[2],
Value: binary.LittleEndian.Uint32(body[3:7]),
}, nil
}
// ResponsePeerRefusalReason 是当前 EXE 在「拒绝」时追加的 u16 原因码。
//
// 现场实测(packet_log 2026-09-26 07:24:24 / 08:32:39 / 08:32:48,三次点「否」):
//
// 点「否」 body = 16 00 | 00 | 00 00 00 00 | 85 00 (9 字节)
// 点「是」 body = 16 00 | 00 | 00 00 00 00 (7 字节)
//
// 86JP 参考服务端认的是同一个形状,并且直接把它叫做 captured refusal
// (body.Length == 9 && BitConverter.ToUInt16(body, 7) == 0x0085)。
const ResponsePeerRefusalReason uint16 = 0x0085
// DecodeResponsePeerRefusal 解析「拒绝」那种九字节形状:七字节回应 + u16 原因。
//
// 它存在的原因:DecodeResponsePeerRequest 只收正好七字节,而唯一的九字节兜底曾经把
// mode 限制在 10。于是一次**被拒绝的组队申请**根本解不出来 —— 服务端只留下一句
// game-party-response-peer-body-short 就返回,待处理的邀请从未被消费,申请人也收不到
// 任何东西,客户端因此永远不弹「对方不在线,或拒绝了您的组队请求」。
func DecodeResponsePeerRefusal(body []byte) (ResponsePeerRequest, uint16, bool) {
if len(body) != 9 {
return ResponsePeerRequest{}, 0, false
}
parsed, err := DecodeResponsePeerRequest(body[:7])
if err != nil {
return ResponsePeerRequest{}, 0, false
}
return parsed, binary.LittleEndian.Uint16(body[7:9]), true
}
// DecodeWalkoutPartyMemberRequest 按 EXE sub_32513D0 case 6 解析:body 只写入 u8 slot。
func DecodeWalkoutPartyMemberRequest(body []byte) (WalkoutPartyMemberRequest, error) {
if len(body) != 1 {
return WalkoutPartyMemberRequest{}, errWalkoutPartyMemberBodyShort
}
return WalkoutPartyMemberRequest{Slot: body[0]}, nil
}
// DecodeChangePartyMemberPositionRequest 按 EXE sub_30D0320 解析:u8 slot,u8 pos;pos 只接受 1 或 3。
func DecodeChangePartyMemberPositionRequest(body []byte) (ChangePartyMemberPositionRequest, error) {
if len(body) != 2 {
return ChangePartyMemberPositionRequest{}, errChangePartyMemberPositionBodyShort
}
req := ChangePartyMemberPositionRequest{
Slot: body[0],
Position: body[1],
}
if req.Slot > 7 || (req.Position != 1 && req.Position != 3) {
return ChangePartyMemberPositionRequest{}, errChangePartyMemberPositionInvalid
}
return req, nil
}
func decodePartyMaxMembers(code byte) byte {
switch code {
case 1, 2, 3, 4:
return code
case 6:
return 4
default:
return 4
}
}
func resolvePartyNamePreset(code byte) []byte {
if code < 1 || int(code) > len(partyNamePresetTable) {
return nil
}
return append([]byte(nil), partyNamePresetTable[int(code)-1]...)
}
@@ -0,0 +1,714 @@
package party
import (
"sort"
"sync"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// RuntimePartyManager owns the process-local lifecycle of online parties.
// It deliberately contains no connection or packet code: dnfbridge validates
// the live session generation at its boundary, then supplies that immutable
// identity here. This keeps one authoritative membership graph instead of a
// divergent party copy on every game session.
type RuntimePartyManager struct {
mu sync.Mutex
nextID uint16
parties map[uint16]*runtimeParty
byUser map[uint16]uint16
peerRequests map[uint16]runtimePartyPeerRequest
}
type RuntimePartyMember struct {
UserID uint16
SessionGeneration uint64
State alignedcmd.PartyMemberState
Slot byte
}
type RuntimePartySnapshot struct {
ID uint16
Leader uint16
Settings alignedcmd.PartyState
Members []RuntimePartyMember
}
type RuntimePartyResult struct {
OK bool
Reason string
Party RuntimePartySnapshot
Previous *RuntimePartySnapshot
PriorLeave *RuntimePartyResult
TargetUser uint16
Disbanded bool
LeaderMoved bool
}
type runtimeParty struct {
id uint16
leader uint16
settings alignedcmd.PartyState
members [currentRuntimePartyMaxMembers]*RuntimePartyMember
}
// RuntimePartyPeerRequestContext is what the bridge learns from the pending op10
// peer request it just consumed. The direction is part of it because the two
// ordinary party modes travel in the same packet shape both ways: the packet
// alone cannot say whether the sender invited the target or applied to join the
// target's party (see runtimePartyPeerRequest.application).
type RuntimePartyPeerRequestContext struct {
// PartyID is the requester's own party at request time, zero when the
// requester had none.
PartyID uint16
// JoinApplication is true when the requester asked to JOIN the decider's
// party. The decider owns the party that survives, so an acceptance moves
// the REQUESTER; when it is false the request was an invitation and the
// acceptance moves the DECIDER (the answering client) instead.
JoinApplication bool
}
// runtimePartyPeerRequest is one pending op10 peer request, keyed by the
// character that received the prompt and owes the op11 answer.
type runtimePartyPeerRequest struct {
decider uint16
deciderGeneration uint64
requester uint16
requesterGeneration uint64
partyID uint16
mode byte
// application records that the requester asked to join the decider's party.
// The current client sends op10 mode 0 for both menu entries -- 邀请组队 for
// a partyless target and 加入 for a target who already has a party -- so the
// bridge decides the direction from the target's party state at request time
// and stores it here. Re-deriving it when the answer arrives would instead
// read whatever the party graph looks like a second later.
application bool
}
const currentRuntimePartyMaxMembers = 4
// currentRuntimePartyMinMembers is the smallest membership this server keeps as
// a party. Operator rule (2026-09-22): 「一个人不成队」-- whenever a leave or a
// kick would leave exactly one member behind, the party dissolves and that last
// member ends up partyless as well.
//
// The retained one-member aggregate used to be deliberate (see
// dnfbridge.publishRuntimePartyMembershipSnapshot: the native kind-2 transition
// owns the multiplayer-to-solo confirmation). Its visible cost was that the
// survivor sat in a party window the client never draws and could not leave --
// the open item in the 2026-09-20 party-peer-selection ghost-party fix, section
// 6: the bridge therefore publishes the native kind-2 one-member row first and
// clears the party object right after (see broadcastRuntimePartyMemberRemoved),
// so the client still gets that transition before the window disappears.
const currentRuntimePartyMinMembers = 2
func NewRuntimePartyManager() *RuntimePartyManager {
return &RuntimePartyManager{
// Current NoPack reserves party model key 1 during its own bootstrap.
nextID: 2,
parties: make(map[uint16]*runtimeParty),
byUser: make(map[uint16]uint16),
peerRequests: make(map[uint16]runtimePartyPeerRequest),
}
}
func (m *RuntimePartyManager) SnapshotByUser(userID uint16, generation uint64) (RuntimePartySnapshot, bool) {
if m == nil || userID == 0 {
return RuntimePartySnapshot{}, false
}
m.mu.Lock()
defer m.mu.Unlock()
party, member := m.partyMemberLocked(userID)
if party == nil || member == nil || (generation != 0 && member.SessionGeneration != generation) {
return RuntimePartySnapshot{}, false
}
return snapshotRuntimeParty(party), true
}
func (m *RuntimePartyManager) SnapshotByID(partyID uint16) (RuntimePartySnapshot, bool) {
if m == nil || partyID == 0 {
return RuntimePartySnapshot{}, false
}
m.mu.Lock()
defer m.mu.Unlock()
party := m.parties[partyID]
if party == nil {
return RuntimePartySnapshot{}, false
}
return snapshotRuntimeParty(party), true
}
func (m *RuntimePartyManager) Snapshots() []RuntimePartySnapshot {
if m == nil {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
ids := make([]int, 0, len(m.parties))
for partyID := range m.parties {
ids = append(ids, int(partyID))
}
sort.Ints(ids)
result := make([]RuntimePartySnapshot, 0, len(ids))
for _, partyID := range ids {
result = append(result, snapshotRuntimeParty(m.parties[uint16(partyID)]))
}
return result
}
// Create starts a fresh party for leader. If that character already belongs
// to another party, it is detached atomically and the old-party result is
// returned through PriorLeave so the bridge can notify survivors.
func (m *RuntimePartyManager) Create(leader RuntimePartyMember, settings alignedcmd.PartyState) RuntimePartyResult {
if m == nil || !validRuntimePartyMember(leader) {
return runtimePartyFailure("invalid_leader")
}
m.mu.Lock()
defer m.mu.Unlock()
partyID := m.nextPartyIDLocked()
if partyID == 0 {
return runtimePartyFailure("party_id_exhausted")
}
prior := m.leaveLocked(leader.UserID, leader.SessionGeneration, true)
party := &runtimeParty{id: partyID, leader: leader.UserID, settings: normalizeRuntimePartySettings(settings)}
party.settings.PartyID = int(party.id)
party.settings.UserID = leader.UserID
party.settings.IsLeader = true
if !party.add(leader) {
return runtimePartyFailure("add_leader_failed")
}
m.parties[party.id] = party
m.byUser[leader.UserID] = party.id
result := runtimePartySuccess(party, leader.UserID)
if prior.OK {
result.PriorLeave = &prior
}
return result
}
// EnsureLeader creates a lobby only when this exact live leader has no party.
// It is used by bridge-side migration/first-contact paths, where two packets
// may observe the same partyless target concurrently. Unlike Create, it never
// detaches an existing party merely because a duplicate bootstrap arrived.
func (m *RuntimePartyManager) EnsureLeader(leader RuntimePartyMember, settings alignedcmd.PartyState) RuntimePartyResult {
if m == nil || !validRuntimePartyMember(leader) {
return runtimePartyFailure("invalid_leader")
}
m.mu.Lock()
defer m.mu.Unlock()
if existing, member := m.partyMemberLocked(leader.UserID); existing != nil && member != nil {
if member.SessionGeneration != leader.SessionGeneration {
return runtimePartyFailure("stale_member_session")
}
if existing.leader != leader.UserID {
return runtimePartyFailure("already_party_member")
}
return runtimePartySuccess(existing, leader.UserID)
}
party := &runtimeParty{id: m.nextPartyIDLocked(), leader: leader.UserID, settings: normalizeRuntimePartySettings(settings)}
if party.id == 0 {
return runtimePartyFailure("party_id_exhausted")
}
party.settings.PartyID = int(party.id)
party.settings.UserID = leader.UserID
party.settings.IsLeader = true
if !party.add(leader) {
return runtimePartyFailure("add_leader_failed")
}
m.parties[party.id] = party
m.byUser[leader.UserID] = party.id
return runtimePartySuccess(party, leader.UserID)
}
// Join joins member to the current party generation. A previous membership is
// always resolved within the same lock, so no character can exist in two
// parties even while join, leave and disconnect packets race.
func (m *RuntimePartyManager) Join(partyID uint16, member RuntimePartyMember) RuntimePartyResult {
if m == nil || partyID == 0 || !validRuntimePartyMember(member) {
return runtimePartyFailure("invalid_join")
}
m.mu.Lock()
defer m.mu.Unlock()
party := m.parties[partyID]
if party == nil {
return runtimePartyFailure("party_not_found")
}
if existing := party.member(member.UserID); existing != nil {
if existing.SessionGeneration == member.SessionGeneration {
return runtimePartyFailure("already_member")
}
return runtimePartyFailure("stale_member_session")
}
if party.full() {
return runtimePartyFailure("party_full")
}
prior := m.leaveLocked(member.UserID, member.SessionGeneration, true)
if !party.add(member) {
return runtimePartyFailure("add_member_failed")
}
m.byUser[member.UserID] = party.id
result := runtimePartySuccess(party, member.UserID)
if prior.OK {
result.PriorLeave = &prior
}
return result
}
func (m *RuntimePartyManager) Leave(userID uint16, generation uint64) RuntimePartyResult {
if m == nil || userID == 0 || generation == 0 {
return runtimePartyFailure("invalid_leave")
}
m.mu.Lock()
defer m.mu.Unlock()
return m.leaveLocked(userID, generation, false)
}
func (m *RuntimePartyManager) leaveLocked(userID uint16, generation uint64, allowAbsent bool) RuntimePartyResult {
party, member := m.partyMemberLocked(userID)
if party == nil || member == nil {
if allowAbsent {
return RuntimePartyResult{}
}
return runtimePartyFailure("not_in_party")
}
if generation != 0 && member.SessionGeneration != generation {
return runtimePartyFailure("stale_session")
}
previous := snapshotRuntimeParty(party)
previousCopy := previous
wasLeader := party.leader == userID
party.remove(userID)
delete(m.byUser, userID)
m.clearPeerRequestsForLocked(userID, generation)
if party.count() == 0 {
delete(m.parties, party.id)
return RuntimePartyResult{OK: true, Previous: &previousCopy, TargetUser: userID, Disbanded: true}
}
if result, disbanded := m.disbandSingleMemberPartyLocked(party, previousCopy, userID); disbanded {
return result
}
if !wasLeader {
result := runtimePartySuccess(party, userID)
result.Previous = &previousCopy
return result
}
// Same-version NoPack retains the native party model and fixed slots when
// the leader leaves. Authority passes to the lowest occupied slot and the
// complete kind-2 membership row carries that new leader slot.
members := party.membersBySlot()
newLeader := members[0]
party.leader = newLeader.UserID
party.settings.UserID = newLeader.UserID
party.settings.IsLeader = true
result := runtimePartySuccess(party, userID)
result.Previous = &previousCopy
result.LeaderMoved = true
return result
}
func (m *RuntimePartyManager) Kick(byUser uint16, byGeneration uint64, targetUser uint16, targetGeneration uint64) RuntimePartyResult {
if m == nil || byUser == 0 || targetUser == 0 || byUser == targetUser || byGeneration == 0 || targetGeneration == 0 {
return runtimePartyFailure("invalid_kick")
}
m.mu.Lock()
defer m.mu.Unlock()
party, leader := m.partyMemberLocked(byUser)
if party == nil || leader == nil {
return runtimePartyFailure("not_in_party")
}
if leader.SessionGeneration != byGeneration {
return runtimePartyFailure("stale_session")
}
if party.leader != byUser {
return runtimePartyFailure("not_leader")
}
target := party.member(targetUser)
if target == nil {
return runtimePartyFailure("target_not_member")
}
if target.SessionGeneration != targetGeneration {
return runtimePartyFailure("stale_target_session")
}
previous := snapshotRuntimeParty(party)
party.remove(targetUser)
delete(m.byUser, targetUser)
m.clearPeerRequestsForLocked(targetUser, targetGeneration)
if result, disbanded := m.disbandSingleMemberPartyLocked(party, previous, targetUser); disbanded {
return result
}
result := runtimePartySuccess(party, targetUser)
result.Previous = &previous
return result
}
// disbandSingleMemberPartyLocked drops a party that a leave or a kick just
// reduced below the minimum membership, and detaches the last member with it.
// Both sides end up partyless, and Previous still carries the full pre-mutation
// roster so the bridge can publish the reduction to whoever was left behind.
// The caller must already hold m.mu and must have removed targetUser from
// m.byUser.
func (m *RuntimePartyManager) disbandSingleMemberPartyLocked(
party *runtimeParty,
previous RuntimePartySnapshot,
targetUser uint16,
) (RuntimePartyResult, bool) {
if m == nil || party == nil || party.count() >= currentRuntimePartyMinMembers {
return RuntimePartyResult{}, false
}
for _, member := range party.membersBySlot() {
party.remove(member.UserID)
delete(m.byUser, member.UserID)
}
delete(m.parties, party.id)
return RuntimePartyResult{
OK: true,
Previous: &previous,
TargetUser: targetUser,
Disbanded: true,
}, true
}
// TransferLeader changes only the authoritative leader of the existing party.
// Current NoPack sends the target's stable slot in op121 and expects the same
// party model and slot table back with a different trailing leader-slot byte.
// Rebuilding the party here would restart P2P and destroy the fixed-slot
// identity used by the HUD and minimap.
func (m *RuntimePartyManager) TransferLeader(byUser uint16, byGeneration uint64, newLeader uint16, newLeaderGeneration uint64) RuntimePartyResult {
if m == nil || byUser == 0 || newLeader == 0 || byGeneration == 0 || newLeaderGeneration == 0 {
return runtimePartyFailure("invalid_leader_transfer")
}
m.mu.Lock()
defer m.mu.Unlock()
party, leader := m.partyMemberLocked(byUser)
if party == nil || leader == nil || leader.SessionGeneration != byGeneration {
return runtimePartyFailure("stale_session")
}
if party.leader != byUser {
return runtimePartyFailure("not_leader")
}
next := party.member(newLeader)
if next == nil || next.SessionGeneration != newLeaderGeneration {
return runtimePartyFailure("stale_target_session")
}
previous := snapshotRuntimeParty(party)
party.leader = newLeader
party.settings.UserID = newLeader
party.settings.IsLeader = true
result := runtimePartySuccess(party, newLeader)
result.Previous = &previous
result.LeaderMoved = true
return result
}
// RecordInvite registers an ordinary party INVITATION: the requester asked the
// decider (the op10 target, which is the client that shows the prompt and
// answers with op11) to join the requester's own party.
func (m *RuntimePartyManager) RecordInvite(invitee uint16, inviteeGeneration uint64, inviter uint16, inviterGeneration uint64, partyID uint16, mode byte) bool {
return m.recordPeerRequest(invitee, inviteeGeneration, inviter, inviterGeneration, partyID, mode, false)
}
// RecordJoinApplication registers an ordinary party JOIN APPLICATION: the
// requester asked to join a party the decider already owns, so the decider is
// the one who decides and the requester is the one that moves on acceptance.
//
// Named the other way round from RecordInvite on purpose -- owner/applicant
// instead of invitee/inviter -- because the two differ only in which side ends
// up changing party, and a call site that quietly swapped them would reproduce
// the 2026-09-26 defect where a leader was pulled out of his own party.
func (m *RuntimePartyManager) RecordJoinApplication(owner uint16, ownerGeneration uint64, applicant uint16, applicantGeneration uint64, partyID uint16, mode byte) bool {
return m.recordPeerRequest(owner, ownerGeneration, applicant, applicantGeneration, partyID, mode, true)
}
func (m *RuntimePartyManager) recordPeerRequest(decider uint16, deciderGeneration uint64, requester uint16, requesterGeneration uint64, partyID uint16, mode byte, application bool) bool {
if m == nil || decider == 0 || requester == 0 || decider == requester || deciderGeneration == 0 || requesterGeneration == 0 {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
m.peerRequests[decider] = runtimePartyPeerRequest{
decider: decider,
deciderGeneration: deciderGeneration,
requester: requester,
requesterGeneration: requesterGeneration,
partyID: partyID,
mode: mode,
application: application,
}
return true
}
// ConsumePeerRequest validates and consumes the pending request that the given
// op11 answer belongs to. Both generations are checked, so an answer from a
// retired session -- or one aimed at a requester that has since reconnected --
// never moves anybody.
func (m *RuntimePartyManager) ConsumePeerRequest(decider uint16, deciderGeneration uint64, requester uint16, requesterGeneration uint64, mode byte) (RuntimePartyPeerRequestContext, bool) {
if m == nil {
return RuntimePartyPeerRequestContext{}, false
}
m.mu.Lock()
defer m.mu.Unlock()
request, ok := m.peerRequests[decider]
if !ok || request.deciderGeneration != deciderGeneration || request.requester != requester || request.requesterGeneration != requesterGeneration || request.mode != mode {
return RuntimePartyPeerRequestContext{}, false
}
delete(m.peerRequests, decider)
return RuntimePartyPeerRequestContext{PartyID: request.partyID, JoinApplication: request.application}, true
}
func (m *RuntimePartyManager) UpdateSettings(userID uint16, generation uint64, settings alignedcmd.PartyState) RuntimePartyResult {
if m == nil || userID == 0 || generation == 0 {
return runtimePartyFailure("invalid_settings")
}
m.mu.Lock()
defer m.mu.Unlock()
party, member := m.partyMemberLocked(userID)
if party == nil || member == nil {
return runtimePartyFailure("not_in_party")
}
if member.SessionGeneration != generation {
return runtimePartyFailure("stale_session")
}
if party.leader != userID {
return runtimePartyFailure("not_leader")
}
settings = normalizeRuntimePartySettings(settings)
settings.PartyID = int(party.id)
settings.UserID = party.leader
settings.IsLeader = true
party.settings = settings
return runtimePartySuccess(party, userID)
}
func (m *RuntimePartyManager) OnDisconnected(userID uint16, generation uint64) RuntimePartyResult {
return m.Leave(userID, generation)
}
// RebindSession moves an existing member to a replacement game-session
// generation without changing its party, slot or leader. Channel reconnects
// temporarily have two sockets for one character; accepting packets from the
// retired generation after this point would let it remove the replacement
// member, so this is deliberately generation-conditional.
func (m *RuntimePartyManager) RebindSession(userID uint16, priorGeneration uint64, replacement RuntimePartyMember) RuntimePartyResult {
if m == nil || userID == 0 || priorGeneration == 0 || !validRuntimePartyMember(replacement) || replacement.UserID != userID {
return runtimePartyFailure("invalid_session_rebind")
}
m.mu.Lock()
defer m.mu.Unlock()
party, member := m.partyMemberLocked(userID)
if party == nil || member == nil {
m.clearPeerRequestsForLocked(userID, priorGeneration)
return runtimePartyFailure("not_in_party")
}
if member.SessionGeneration != priorGeneration {
return runtimePartyFailure("stale_session")
}
replacement.Slot = member.Slot
replacement.State = normalizeRuntimePartyMember(replacement.State, userID)
*member = replacement
m.clearPeerRequestsForLocked(userID, priorGeneration)
return runtimePartySuccess(party, userID)
}
// Reposition moves any member between adjacent persistent party slots. The
// leader authority stays attached to the member identity and the authoritative
// leader slot is projected separately in op9.
func (m *RuntimePartyManager) Reposition(byUser uint16, byGeneration uint64, fromSlot uint8, toSlot uint8) RuntimePartyResult {
if m == nil || byUser == 0 || byGeneration == 0 || fromSlot >= currentRuntimePartyMaxMembers || toSlot >= currentRuntimePartyMaxMembers || fromSlot == toSlot {
return runtimePartyFailure("invalid_reposition")
}
m.mu.Lock()
defer m.mu.Unlock()
party, leader := m.partyMemberLocked(byUser)
if party == nil || leader == nil || leader.SessionGeneration != byGeneration {
return runtimePartyFailure("stale_session")
}
if party.leader != byUser {
return runtimePartyFailure("not_leader")
}
if party.members[fromSlot] == nil {
return runtimePartyFailure("invalid_source_slot")
}
party.members[fromSlot], party.members[toSlot] = party.members[toSlot], party.members[fromSlot]
if party.members[fromSlot] != nil {
party.members[fromSlot].Slot = fromSlot
}
if party.members[toSlot] != nil {
party.members[toSlot].Slot = toSlot
}
return runtimePartySuccess(party, byUser)
}
func (m *RuntimePartyManager) nextPartyIDLocked() uint16 {
for attempts := 0; attempts < int(^uint16(0))-1; attempts++ {
id := m.nextID
m.nextID++
if m.nextID == 0 || m.nextID == ^uint16(0) {
m.nextID = 2
}
if id != 0 && id != ^uint16(0) && m.parties[id] == nil {
return id
}
}
return 0
}
func (m *RuntimePartyManager) partyMemberLocked(userID uint16) (*runtimeParty, *RuntimePartyMember) {
partyID := m.byUser[userID]
party := m.parties[partyID]
if party == nil {
return nil, nil
}
return party, party.member(userID)
}
func (m *RuntimePartyManager) clearPeerRequestsForLocked(userID uint16, generation uint64) {
for decider, request := range m.peerRequests {
if (decider == userID && (generation == 0 || request.deciderGeneration == generation)) ||
(request.requester == userID && (generation == 0 || request.requesterGeneration == generation)) {
delete(m.peerRequests, decider)
}
}
}
func (p *runtimeParty) count() int {
count := 0
for _, member := range p.members {
if member != nil {
count++
}
}
return count
}
func (p *runtimeParty) full() bool {
limit := p.settings.MaxMembers
if limit == 0 || limit > currentRuntimePartyMaxMembers {
limit = currentRuntimePartyMaxMembers
}
return p.count() >= int(limit)
}
func (p *runtimeParty) member(userID uint16) *RuntimePartyMember {
for _, member := range p.members {
if member != nil && member.UserID == userID {
return member
}
}
return nil
}
func (p *runtimeParty) add(member RuntimePartyMember) bool {
if p == nil || !validRuntimePartyMember(member) || p.full() || p.member(member.UserID) != nil {
return false
}
for slot := range p.members {
if p.members[slot] != nil {
continue
}
member.Slot = byte(slot)
member.State = normalizeRuntimePartyMember(member.State, member.UserID)
p.members[slot] = &member
return true
}
return false
}
func (p *runtimeParty) remove(userID uint16) bool {
for slot, member := range p.members {
if member != nil && member.UserID == userID {
p.members[slot] = nil
return true
}
}
return false
}
func (p *runtimeParty) membersBySlot() []RuntimePartyMember {
result := make([]RuntimePartyMember, 0, currentRuntimePartyMaxMembers)
for _, member := range p.members {
if member != nil {
result = append(result, cloneRuntimePartyMember(*member))
}
}
return result
}
func snapshotRuntimeParty(p *runtimeParty) RuntimePartySnapshot {
if p == nil {
return RuntimePartySnapshot{}
}
return RuntimePartySnapshot{ID: p.id, Leader: p.leader, Settings: cloneRuntimePartySettings(p.settings), Members: p.membersBySlot()}
}
func (s RuntimePartySnapshot) StateFor(userID uint16) alignedcmd.PartyState {
if s.ID == 0 || !s.contains(userID) {
return alignedcmd.PartyState{}
}
state := cloneRuntimePartySettings(s.Settings)
state.PartyID = int(s.ID)
state.UserID = s.Leader
state.IsLeader = s.Leader == userID
state.Members = make([]alignedcmd.PartyMemberState, 0, len(s.Members))
for _, member := range s.Members {
projected := normalizeRuntimePartyMember(member.State, member.UserID)
projected.Slot = member.Slot
state.Members = append(state.Members, projected)
}
return state
}
func (s RuntimePartySnapshot) contains(userID uint16) bool {
for _, member := range s.Members {
if member.UserID == userID {
return true
}
}
return false
}
func runtimePartySuccess(p *runtimeParty, target uint16) RuntimePartyResult {
return RuntimePartyResult{OK: true, Party: snapshotRuntimeParty(p), TargetUser: target}
}
func runtimePartyFailure(reason string) RuntimePartyResult { return RuntimePartyResult{Reason: reason} }
func validRuntimePartyMember(member RuntimePartyMember) bool {
return member.UserID != 0 && member.UserID != ^uint16(0) && member.SessionGeneration != 0
}
func normalizeRuntimePartySettings(state alignedcmd.PartyState) alignedcmd.PartyState {
state = cloneRuntimePartySettings(state)
if state.MaxMembers == 0 || state.MaxMembers > currentRuntimePartyMaxMembers {
state.MaxMembers = currentRuntimePartyMaxMembers
}
state.Members = nil
return state
}
func cloneRuntimePartySettings(state alignedcmd.PartyState) alignedcmd.PartyState {
state.NameBytes = append([]byte(nil), state.NameBytes...)
state.Members = append([]alignedcmd.PartyMemberState(nil), state.Members...)
return state
}
func normalizeRuntimePartyMember(member alignedcmd.PartyMemberState, userID uint16) alignedcmd.PartyMemberState {
member.UserID = userID
if member.UserState == 0 || member.UserState == 1 {
member.UserState = 100
}
if member.HPPercent == 0 {
member.HPPercent = 100
}
if member.MPPercent == 0 {
member.MPPercent = 100
}
return member
}
func cloneRuntimePartyMember(member RuntimePartyMember) RuntimePartyMember {
member.State = normalizeRuntimePartyMember(member.State, member.UserID)
return member
}
@@ -0,0 +1,373 @@
package party
import (
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// A leave from a party that keeps at least two members still moves leadership
// to the lowest occupied slot and preserves every stable slot. The two-member
// case is covered by TestRuntimePartyManagerDisbandsWhenLeaveLeavesOneMember:
// operator rule 2026-09-22 「一个人不成队」.
func TestRuntimePartyManagerLeaderLeaveMovesAuthorityAndKeepsStableSlots(t *testing.T) {
manager := NewRuntimePartyManager()
leader := testRuntimePartyMember(101, 1)
created := manager.Create(leader, alignedcmd.PartyState{MaxMembers: 4})
if !created.OK || created.Party.ID == 0 {
t.Fatalf("create result = %+v", created)
}
for _, userID := range []uint16{202, 303} {
if joined := manager.Join(created.Party.ID, testRuntimePartyMember(userID, 1)); !joined.OK {
t.Fatalf("join %d result = %+v", userID, joined)
}
}
left := manager.Leave(101, 1)
if !left.OK || !left.LeaderMoved {
t.Fatalf("leader leave result = %+v", left)
}
if left.Party.ID != created.Party.ID {
t.Fatalf("party generation old=%d new=%d", created.Party.ID, left.Party.ID)
}
if left.Party.Leader != 202 || len(left.Party.Members) != 2 {
t.Fatalf("surviving party = %+v", left.Party)
}
if got := runtimePartySlot(left.Party, 202); got != 1 {
t.Fatalf("new leader slot = %d, want preserved slot 1", got)
}
if got := runtimePartySlot(left.Party, 303); got != 2 {
t.Fatalf("member 303 slot = %d, want preserved slot 2", got)
}
if current, found := manager.SnapshotByID(created.Party.ID); !found || current.Leader != 202 {
t.Fatalf("party by id = %+v found=%v", current, found)
}
if current, found := manager.SnapshotByUser(202, 1); !found || current.ID != left.Party.ID {
t.Fatalf("survivor lookup = %+v found=%v", current, found)
}
}
// Whatever drops a party to a single member dissolves it: the last member is
// detached too, both sides end up partyless, and Previous still carries the
// full pre-mutation roster so the bridge can tell that member the party is gone.
func TestRuntimePartyManagerDisbandsWhenLeaveLeavesOneMember(t *testing.T) {
for _, test := range []struct {
name string
leavingID uint16
survivorID uint16
wantLeader uint16
wantMoved bool
description string
}{
{
name: "leader leaves a two-member party", leavingID: 101, survivorID: 202,
wantLeader: 101, wantMoved: false,
description: "leader leave used to keep a one-member aggregate with moved authority",
},
{
name: "follower leaves a two-member party", leavingID: 202, survivorID: 101,
wantLeader: 101, wantMoved: false,
description: "the surviving leader is detached as well",
},
} {
t.Run(test.name, func(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
if joined := manager.Join(created.Party.ID, testRuntimePartyMember(202, 1)); !joined.OK {
t.Fatalf("join result = %+v", joined)
}
left := manager.Leave(test.leavingID, 1)
if !left.OK || !left.Disbanded {
t.Fatalf("leave result = %+v (%s)", left, test.description)
}
if left.Party.ID != 0 || len(left.Party.Members) != 0 {
t.Fatalf("dissolved result still carries a party = %+v", left.Party)
}
if left.Previous == nil || left.Previous.Leader != test.wantLeader ||
len(left.Previous.Members) != 2 {
t.Fatalf("previous roster = %+v, want both members", left.Previous)
}
if left.LeaderMoved != test.wantMoved {
t.Fatalf("leader moved = %t, want %t", left.LeaderMoved, test.wantMoved)
}
if _, found := manager.SnapshotByID(created.Party.ID); found {
t.Fatal("dissolved party is still addressable by id")
}
for _, userID := range []uint16{101, 202} {
if snapshot, found := manager.SnapshotByUser(userID, 1); found {
t.Fatalf("member %d still in party after dissolve: %+v", userID, snapshot)
}
}
})
}
}
// Kicking the only other member leaves the kicker alone, which is the same
// single-member state, so the party dissolves and the kicker is detached.
func TestRuntimePartyManagerKickDisbandsTheTwoMemberParty(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
if joined := manager.Join(created.Party.ID, testRuntimePartyMember(202, 1)); !joined.OK {
t.Fatalf("join result = %+v", joined)
}
kicked := manager.Kick(101, 1, 202, 1)
if !kicked.OK || !kicked.Disbanded || kicked.TargetUser != 202 {
t.Fatalf("kick result = %+v", kicked)
}
if kicked.Previous == nil || len(kicked.Previous.Members) != 2 {
t.Fatalf("previous roster = %+v, want both members", kicked.Previous)
}
for _, userID := range []uint16{101, 202} {
if snapshot, found := manager.SnapshotByUser(userID, 1); found {
t.Fatalf("member %d still in party after kick dissolve: %+v", userID, snapshot)
}
}
}
func TestRuntimePartyManagerMaintainsStableSlotsUntilLeaderRebuild(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
for _, userID := range []uint16{202, 303, 404} {
if result := manager.Join(created.Party.ID, testRuntimePartyMember(userID, 1)); !result.OK {
t.Fatalf("join %d = %+v", userID, result)
}
}
if result := manager.Leave(202, 1); !result.OK || result.LeaderMoved {
t.Fatalf("nonleader leave = %+v", result)
}
snapshot, found := manager.SnapshotByUser(303, 1)
if !found {
t.Fatal("remaining party not found")
}
if got := runtimePartySlot(snapshot, 303); got != 2 {
t.Fatalf("member 303 slot = %d, want preserved slot 2", got)
}
if got := runtimePartySlot(snapshot, 404); got != 3 {
t.Fatalf("member 404 slot = %d, want preserved slot 3", got)
}
if result := manager.Join(snapshot.ID, testRuntimePartyMember(505, 1)); !result.OK {
t.Fatalf("join replacement = %+v", result)
}
snapshot, _ = manager.SnapshotByUser(505, 1)
if got := runtimePartySlot(snapshot, 505); got != 1 {
t.Fatalf("replacement slot = %d, want released slot 1", got)
}
}
func TestRuntimePartyManagerBindsInvitesAndMutationsToSessionGeneration(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 9), alignedcmd.PartyState{MaxMembers: 4})
if !manager.RecordInvite(202, 7, 101, 9, created.Party.ID, 0) {
t.Fatal("record invite failed")
}
if _, ok := manager.ConsumePeerRequest(202, 6, 101, 9, 0); ok {
t.Fatal("retired invitee session consumed pending invite")
}
request, ok := manager.ConsumePeerRequest(202, 7, 101, 9, 0)
if !ok || request.PartyID != created.Party.ID || request.JoinApplication {
t.Fatalf("consume invite party=%d application=%v ok=%v", request.PartyID, request.JoinApplication, ok)
}
if result := manager.Join(created.Party.ID, testRuntimePartyMember(202, 7)); !result.OK {
t.Fatalf("join result = %+v", result)
}
if result := manager.Kick(101, 8, 202, 7); result.Reason != "stale_session" {
t.Fatalf("stale leader kick = %+v", result)
}
if result := manager.Kick(101, 9, 202, 6); result.Reason != "stale_target_session" {
t.Fatalf("stale target kick = %+v", result)
}
}
func TestRuntimePartyManagerMovesJoinerOutOfPriorPartyAndReportsPriorLeave(t *testing.T) {
manager := NewRuntimePartyManager()
first := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
for _, userID := range []uint16{202, 404} {
if result := manager.Join(first.Party.ID, testRuntimePartyMember(userID, 1)); !result.OK {
t.Fatalf("join first party %d = %+v", userID, result)
}
}
second := manager.Create(testRuntimePartyMember(303, 1), alignedcmd.PartyState{MaxMembers: 4})
result := manager.Join(second.Party.ID, testRuntimePartyMember(202, 1))
if !result.OK || result.PriorLeave == nil || !result.PriorLeave.OK {
t.Fatalf("cross-party join = %+v", result)
}
if result.PriorLeave.Disbanded {
t.Fatalf("two survivors were dissolved: %+v", result.PriorLeave)
}
if old, found := manager.SnapshotByUser(101, 1); !found || len(old.Members) != 2 {
t.Fatalf("old party = %+v found=%v", old, found)
}
if next, found := manager.SnapshotByUser(202, 1); !found || next.ID != second.Party.ID {
t.Fatalf("new party = %+v found=%v", next, found)
}
}
// A joiner who was the only other member of its old party dissolves that party
// on the way out, and the bridge is told through PriorLeave so it can clear the
// member that was left behind.
func TestRuntimePartyManagerReportsPriorLeaveDisbandOnCrossPartyJoin(t *testing.T) {
manager := NewRuntimePartyManager()
first := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
if result := manager.Join(first.Party.ID, testRuntimePartyMember(202, 1)); !result.OK {
t.Fatalf("join first party = %+v", result)
}
second := manager.Create(testRuntimePartyMember(303, 1), alignedcmd.PartyState{MaxMembers: 4})
result := manager.Join(second.Party.ID, testRuntimePartyMember(202, 1))
if !result.OK || result.PriorLeave == nil || !result.PriorLeave.Disbanded {
t.Fatalf("cross-party join = %+v", result)
}
if snapshot, found := manager.SnapshotByUser(101, 1); found {
t.Fatalf("leftover member kept a party after the prior party dissolved: %+v", snapshot)
}
}
func TestRuntimePartyManagerRejectsFifthMemberAndKeepsFourStableSlots(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
for _, userID := range []uint16{202, 303, 404} {
if joined := manager.Join(created.Party.ID, testRuntimePartyMember(userID, 1)); !joined.OK {
t.Fatalf("join %d = %+v", userID, joined)
}
}
if fifth := manager.Join(created.Party.ID, testRuntimePartyMember(505, 1)); fifth.Reason != "party_full" {
t.Fatalf("fifth join = %+v", fifth)
}
snapshot, found := manager.SnapshotByID(created.Party.ID)
if !found || len(snapshot.Members) != 4 {
t.Fatalf("party after fifth join = %+v found=%v", snapshot, found)
}
for slot, userID := range []uint16{101, 202, 303, 404} {
if got := runtimePartySlot(snapshot, userID); got != byte(slot) {
t.Fatalf("member %d slot=%d want=%d", userID, got, slot)
}
}
}
// 现场 2026-09-26 13:23:17:C 点 A 的菜单只出「加入」,客户端发的却是与「邀请
// 组队」完全相同的 op10 mode 0。桥接层当时把它记成邀请,A 一同意就被拔出自己
// 的队(party 4 解散、队友 B 被丢下)。方向属于请求本身,必须在这里存下来。
func TestRuntimePartyManagerKeepsPeerRequestDirection(t *testing.T) {
manager := NewRuntimePartyManager()
owner := manager.Create(testRuntimePartyMember(101, 9), alignedcmd.PartyState{MaxMembers: 4})
if result := manager.Join(owner.Party.ID, testRuntimePartyMember(102, 9)); !result.OK {
t.Fatalf("owner party join = %+v", result)
}
// C(103) 申请加入 A(101) 的队:申请人是 103,决定人是 101。
if !manager.RecordJoinApplication(101, 9, 103, 9, 0, 0) {
t.Fatal("record join application failed")
}
if _, ok := manager.ConsumePeerRequest(101, 8, 103, 9, 0); ok {
t.Fatal("retired decider session consumed the application")
}
application, ok := manager.ConsumePeerRequest(101, 9, 103, 9, 0)
if !ok || !application.JoinApplication {
t.Fatalf("consume application = %+v ok=%v, want JoinApplication", application, ok)
}
if application.PartyID != 0 {
t.Fatalf("applicant party = %d, want 0 (it had none)", application.PartyID)
}
if _, again := manager.ConsumePeerRequest(101, 9, 103, 9, 0); again {
t.Fatal("the application was consumed twice")
}
// 旧方向原样保留:邀请人 101 邀请 202,同意之后移动的是 202。
if !manager.RecordInvite(202, 9, 101, 9, owner.Party.ID, 0) {
t.Fatal("record invite failed")
}
invite, ok := manager.ConsumePeerRequest(202, 9, 101, 9, 0)
if !ok || invite.JoinApplication {
t.Fatalf("consume invite = %+v ok=%v, want a plain invitation", invite, ok)
}
if invite.PartyID != owner.Party.ID {
t.Fatalf("invite party = %d, want the inviter's party %d", invite.PartyID, owner.Party.ID)
}
}
func TestRuntimePartyManagerRebindInvalidatesPriorGenerationInvites(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 4), alignedcmd.PartyState{MaxMembers: 4})
if !manager.RecordInvite(202, 7, 101, 4, created.Party.ID, 13) {
t.Fatal("record invite")
}
rebound := manager.RebindSession(101, 4, testRuntimePartyMember(101, 5))
if !rebound.OK || rebound.Party.ID != created.Party.ID {
t.Fatalf("rebind = %+v", rebound)
}
if _, accepted := manager.ConsumePeerRequest(202, 7, 101, 4, 13); accepted {
t.Fatal("invite from retired session was accepted")
}
if stale := manager.Leave(101, 4); stale.Reason != "stale_session" {
t.Fatalf("stale leave = %+v", stale)
}
if current, found := manager.SnapshotByUser(101, 5); !found || current.ID != created.Party.ID {
t.Fatalf("rebound snapshot = %+v found=%v", current, found)
}
}
func TestRuntimePartyManagerRepositionMovesLeaderWithItsIdentity(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
for _, userID := range []uint16{202, 303} {
if joined := manager.Join(created.Party.ID, testRuntimePartyMember(userID, 1)); !joined.OK {
t.Fatalf("join %d = %+v", userID, joined)
}
}
if moved := manager.Reposition(101, 1, 1, 2); !moved.OK {
t.Fatalf("reposition = %+v", moved)
}
snapshot, _ := manager.SnapshotByID(created.Party.ID)
if runtimePartySlot(snapshot, 101) != 0 || runtimePartySlot(snapshot, 202) != 2 || runtimePartySlot(snapshot, 303) != 1 {
t.Fatalf("reposition snapshot = %+v", snapshot)
}
if moved := manager.Reposition(101, 1, 0, 1); !moved.OK ||
runtimePartySlot(moved.Party, 101) != 1 || runtimePartySlot(moved.Party, 303) != 0 || moved.Party.Leader != 101 {
t.Fatalf("leader reposition = %+v", moved)
}
}
func TestRuntimePartyManagerTransferLeaderKeepsPartyAndFixedSlots(t *testing.T) {
manager := NewRuntimePartyManager()
created := manager.Create(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4})
joined := manager.Join(created.Party.ID, testRuntimePartyMember(202, 1))
if !joined.OK {
t.Fatalf("join = %+v", joined)
}
changed := manager.TransferLeader(101, 1, 202, 1)
if !changed.OK || !changed.LeaderMoved ||
changed.Party.ID != created.Party.ID || changed.Party.Leader != 202 ||
runtimePartySlot(changed.Party, 101) != 0 || runtimePartySlot(changed.Party, 202) != 1 {
t.Fatalf("transfer = %+v", changed)
}
oldLeaderState := changed.Party.StateFor(101)
newLeaderState := changed.Party.StateFor(202)
if oldLeaderState.IsLeader || !newLeaderState.IsLeader ||
oldLeaderState.UserID != 202 || newLeaderState.UserID != 202 ||
oldLeaderState.Members[0].Slot != 0 || oldLeaderState.Members[1].Slot != 1 {
t.Fatalf("old=%+v new=%+v", oldLeaderState, newLeaderState)
}
}
func TestRuntimePartyManagerEnsureLeaderIsIdempotent(t *testing.T) {
manager := NewRuntimePartyManager()
first := manager.EnsureLeader(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4, SelectionID: 7})
if !first.OK {
t.Fatalf("first ensure = %+v", first)
}
second := manager.EnsureLeader(testRuntimePartyMember(101, 1), alignedcmd.PartyState{MaxMembers: 4, SelectionID: 99})
if !second.OK || second.Party.ID != first.Party.ID || second.Party.Settings.SelectionID != 7 {
t.Fatalf("duplicate ensure = %+v first=%+v", second, first)
}
}
func testRuntimePartyMember(userID uint16, generation uint64) RuntimePartyMember {
return RuntimePartyMember{UserID: userID, SessionGeneration: generation, State: alignedcmd.PartyMemberState{UserID: userID, UserState: 1, HPPercent: 100, MPPercent: 100}}
}
func runtimePartySlot(snapshot RuntimePartySnapshot, userID uint16) byte {
for _, member := range snapshot.Members {
if member.UserID == userID {
return member.Slot
}
}
return 0xff
}
@@ -0,0 +1,42 @@
// 本文件提供队伍协议 body 所需的最小二进制写入器。
package party
import "encoding/binary"
type packetWriter struct {
buf []byte
}
func (w *packetWriter) writeByte(value byte) {
w.buf = append(w.buf, value)
}
func (w *packetWriter) writeZero(count int) {
if count <= 0 {
return
}
w.buf = append(w.buf, make([]byte, count)...)
}
func (w *packetWriter) writeUint16(value uint16) {
var data [2]byte
binary.LittleEndian.PutUint16(data[:], value)
w.buf = append(w.buf, data[:]...)
}
func (w *packetWriter) writeUint32(value uint32) {
var data [4]byte
binary.LittleEndian.PutUint32(data[:], value)
w.buf = append(w.buf, data[:]...)
}
func (w *packetWriter) writeRawDstr(data []byte) {
var length [4]byte
binary.LittleEndian.PutUint32(length[:], uint32(len(data)))
w.buf = append(w.buf, length[:]...)
w.buf = append(w.buf, data...)
}
func (w *packetWriter) bytes() []byte {
return append([]byte(nil), w.buf...)
}