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

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

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

No files matched your search

@@ -0,0 +1,128 @@
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")
}
}