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

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

374 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}