服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
129 lines
5.5 KiB
Go
129 lines
5.5 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"testing"
|
||
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
)
|
||
|
||
// 现场 2026-09-26 13:23:14–13:23:29(packet_log):
|
||
//
|
||
// 13:23:12 char 20 接受 char 1 的邀请 → party 4 = [1, 20]
|
||
// 13:23:15 char 2 点 char 1,服务端答
|
||
// target_party_id=4 party_marker=0000 → 菜单只有「加入」
|
||
// 13:23:16 char 2 发 op10 mode 0(与「邀请组队」同一串字节)
|
||
// 13:23:17 char 1 点「是」
|
||
// game-party-disbanded-by-single-member-left party_id=4 removed_id=1
|
||
// → char 1 被拔进 char 2 的新队,party 4 解散,char 20 被丢下
|
||
//
|
||
// 正确结果是 char 2 加入 party 4(3 人、队长仍是 char 1),B 留在队里。方向只能
|
||
// 在收到请求时按「被点的目标有没有活跃队伍」定死 —— 那正是让客户端画出「加入」
|
||
// 的那个判据;到了 op11 再按包体猜是猜不出来的,两种请求的字节完全一样。
|
||
|
||
// peerSelectionMode0RequestBody 是 op10 普通组队请求的线上形状:
|
||
// u16 target + u8 mode + u32 value0 + u16 value1 + u32 value2。
|
||
func peerSelectionMode0RequestBody(targetID uint16) []byte {
|
||
body := make([]byte, 13)
|
||
binary.LittleEndian.PutUint16(body[0:2], targetID)
|
||
body[2] = 0
|
||
return body
|
||
}
|
||
|
||
// peerResponseAcceptBody 是 op11「同意」的七字节形状(点「否」会多一个 u16 原因码)。
|
||
func peerResponseAcceptBody(targetID uint16) []byte {
|
||
body := make([]byte, 7)
|
||
binary.LittleEndian.PutUint16(body[0:2], targetID)
|
||
return body
|
||
}
|
||
|
||
func TestTownPeerJoinApplicationAddsApplicantToTheExistingParty(t *testing.T) {
|
||
service, leader, leftover, outsider := newPeerSelectionGhostPartyFixture(t)
|
||
partyID := runtimePartyStateSnapshot(leader).PartyID
|
||
if partyID == 0 {
|
||
t.Fatal("fixture party has no id")
|
||
}
|
||
for host, session := range map[byte]*gameSession{1: leader, 2: leftover, 3: outsider} {
|
||
registerPartyPeerEndpointForTest(session, host, uint16(5000)+uint16(host))
|
||
}
|
||
|
||
// 前置:被点的 char 1 有活跃队伍,菜单因此只出「加入」。
|
||
if marker, _ := clickTownPeerForSelection(t, service, outsider, leader.selectedCharacterID); marker != 0 {
|
||
t.Fatalf("an owned party answered marker=0x%04x, want 0x0000", marker)
|
||
}
|
||
|
||
// char 2 点「加入」:op10 mode 0 落到 char 1 头上。
|
||
outsider.conn.(*bufferConn).write.Reset()
|
||
if err := service.handleOnlineRequestPeer(
|
||
outsider,
|
||
uint16(dnfenum.CmdPacketRequestPeer),
|
||
peerSelectionMode0RequestBody(leader.selectedCharacterID),
|
||
); err != nil {
|
||
t.Fatalf("join application request: %v", err)
|
||
}
|
||
// 申请人自己只拿到 class1 ACK;提示框发给被申请的队长。
|
||
prompt, ok := runtimeRaidPeerFind(runtimeRaidPeerTake(t, leader), 0x0007)
|
||
if !ok || len(prompt.Body) < 3 ||
|
||
binary.LittleEndian.Uint16(prompt.Body[0:2]) != outsider.selectedCharacterID ||
|
||
prompt.Body[2] != 0 {
|
||
t.Fatalf("owner prompt = % X (found=%v), want requester=%d mode=0",
|
||
prompt.Body, ok, outsider.selectedCharacterID)
|
||
}
|
||
|
||
// char 1 点「是」。
|
||
leader.conn.(*bufferConn).write.Reset()
|
||
outsider.conn.(*bufferConn).write.Reset()
|
||
if err := service.handleOnlineResponsePeer(
|
||
leader,
|
||
uint16(dnfenum.CmdPacketResponsePeer),
|
||
peerResponseAcceptBody(outsider.selectedCharacterID),
|
||
); err != nil {
|
||
t.Fatalf("join application acceptance: %v", err)
|
||
}
|
||
|
||
// 队伍没有重建:同一个 party id、同一个队长,只是多了一个人。
|
||
state := runtimePartyStateSnapshot(leader)
|
||
if state.PartyID != partyID {
|
||
t.Fatalf("party id = %d, want the applied-to party %d", state.PartyID, partyID)
|
||
}
|
||
if state.UserID != leader.selectedCharacterID || !state.IsLeader {
|
||
t.Fatalf("leadership moved: leader=%d is_leader=%v, want %d",
|
||
state.UserID, state.IsLeader, leader.selectedCharacterID)
|
||
}
|
||
members := runtimePartyMembers(state)
|
||
if len(members) != 3 {
|
||
t.Fatalf("party has %d members, want 3: %+v", len(members), members)
|
||
}
|
||
for _, want := range []uint16{leader.selectedCharacterID, leftover.selectedCharacterID, outsider.selectedCharacterID} {
|
||
if !containsRuntimePartyMember(members, want) {
|
||
t.Fatalf("char %d is missing from the party: %+v", want, members)
|
||
}
|
||
}
|
||
|
||
// 被留下的队友 char 20 必须还在队里 —— 这正是本次事故丢掉的那个人。
|
||
survivor := runtimePartyStateSnapshot(leftover)
|
||
if survivor.PartyID != partyID || len(runtimePartyMembers(survivor)) != 3 {
|
||
t.Fatalf("survivor party = %+v, want party %d with 3 members", survivor, partyID)
|
||
}
|
||
|
||
// 申请人拿到的投影是「别人的队」,不是自己新建的队。
|
||
applicant := runtimePartyStateSnapshot(outsider)
|
||
if applicant.PartyID != partyID || applicant.UserID != leader.selectedCharacterID || applicant.IsLeader {
|
||
t.Fatalf("applicant projection = %+v, want a follower row in party %d", applicant, partyID)
|
||
}
|
||
|
||
// 权威图与三个客户端投影一致,且没有留下任何第二支队伍。
|
||
snapshot, found := service.runtimePartyManagerForService().SnapshotByID(uint16(partyID))
|
||
if !found || snapshot.Leader != leader.selectedCharacterID || len(snapshot.Members) != 3 {
|
||
t.Fatalf("authoritative party = %+v found=%v", snapshot, found)
|
||
}
|
||
if parties := service.runtimePartyManagerForService().Snapshots(); len(parties) != 1 {
|
||
t.Fatalf("manager holds %d parties, want exactly the applied-to one: %+v", len(parties), parties)
|
||
}
|
||
|
||
// 三条队伍帧都按三行发出(申请人这一侧第一包就是 op11 端点表)。
|
||
if rest := assertRuntimePartySnapshot(t, outsider.conn.(*bufferConn).write.Bytes(), 3); len(rest) == 0 {
|
||
t.Fatal("applicant received no peer/party frame beyond the roster rows")
|
||
}
|
||
}
|