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