Files
90CNServer/server/internal/services/dnfbridge/party_manager_runtime_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

162 lines
8.0 KiB
Go

package dnfbridge
import (
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/channelcatalog"
)
func TestManagedPartyCrossJoinRefreshesPriorPartySurvivor(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
partyA := alignedcmd.PartyState{
PartyID: 77, UserID: 1001, IsLeader: true, MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, UserState: 1, HPPercent: 90, MPPercent: 80},
},
}
partyB := alignedcmd.PartyState{
PartyID: 88, UserID: 1003, IsLeader: true, MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{{UserID: 1003, UserState: 1, HPPercent: 100, MPPercent: 100}},
}
leaderA := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1001, party: partySessionState{state: partyA}}
joiner := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1002, party: partySessionState{state: partyA}}
leaderB := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1003, party: partySessionState{state: partyB}}
service.bindGameSessionCharacter(leaderA, 1001)
service.bindGameSessionCharacter(joiner, 1002)
service.bindGameSessionCharacter(leaderB, 1003)
state, result, joined := service.createOrJoinManagedRuntimeParty(joiner, leaderB)
if !joined || !result.OK || result.PriorLeave == nil || !result.PriorLeave.OK {
t.Fatalf("cross-party join=%t result=%+v", joined, result)
}
if state.UserID != 1003 || len(runtimePartyMembers(state)) != 2 {
t.Fatalf("target party projection=%+v", state)
}
// The joiner was the only other member of the prior party, so leaving it
// dissolved that party (single-member disband rule) and the member left
// behind is partyless with its client cache cleared.
if old := runtimePartyStateSnapshot(leaderA); old.PartyID != 0 || len(runtimePartyMembers(old)) != 0 {
t.Fatalf("prior party survivor aggregate = %+v", old)
}
if next := runtimePartyStateSnapshot(joiner); next.UserID != 1003 || len(runtimePartyMembers(next)) != 2 {
t.Fatalf("joiner projection=%+v", next)
}
if snapshot, found := service.runtimePartyManagerForService().SnapshotByUser(1001, leaderA.characterGeneration); found {
t.Fatalf("prior party survivor stayed in the manager=%+v", snapshot)
}
}
func TestManagedPartyInviteTargetKeepsLeadershipOverAcceptorTransientSetPartyInfo(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
inviter := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1001}
acceptor := &gameSession{
conn: &bufferConn{},
channel: channel,
selectedCharacterID: 1002,
party: partySessionState{state: alignedcmd.PartyState{
PartyID: 1002,
UserID: 1002,
IsLeader: true,
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{{
UserID: 1002, UserState: 1, HPPercent: 100, MPPercent: 100,
}},
}},
}
service.bindGameSessionCharacter(inviter, 1001)
service.bindGameSessionCharacter(acceptor, 1002)
// op12 can establish this local one-member cache before the accept button.
// It is deliberately imported here to model that order; accepting the
// inviter must still rebuild under character 1001.
if _, err := service.ensureManagedRuntimePartyForSession(acceptor); err != nil {
t.Fatalf("bootstrap acceptor transient party: %v", err)
}
state, result, joined := service.createOrJoinManagedRuntimeParty(acceptor, inviter)
if !joined || !result.OK {
t.Fatalf("accept invite joined=%t result=%+v", joined, result)
}
if state.UserID != 1001 || !state.IsLeader || len(runtimePartyMembers(state)) != 2 {
t.Fatalf("invite target did not retain leadership: %+v", state)
}
if members := runtimePartyMembers(state); members[0].UserID != 1001 || members[1].UserID != 1002 {
t.Fatalf("party order=%+v, want inviter then acceptor", members)
}
inviterIdentity, ok := service.boundGameSessionCharacterSnapshot(inviter)
if !ok {
t.Fatal("inviter identity was not bound")
}
snapshot, found := service.runtimePartyManagerForService().SnapshotByUser(inviterIdentity.character, inviterIdentity.generation)
if !found || snapshot.Leader != 1001 || len(snapshot.Members) != 2 {
t.Fatalf("authoritative party snapshot=%+v found=%t", snapshot, found)
}
}
// TestManagedPartyReplacementSessionLeavesTheParty pins the operator rule
// (2026-09-27): 「被顶掉以后 客户端的连接就断开了 此时也按退队处理」,「还有更换频道 也算
// 退队」. A second socket for a character the server still holds -- another client
// taking it over, or the same client on a new channel -- used to inherit the party
// (RuntimePartyManager.RebindSession). It now performs a LEAVE under the OLD
// session's generation, so the survivors are told their roster shrank and the
// replacement comes up partyless.
//
// The leave has to happen inside bindGameSessionCharacter: the retired socket's
// own teardown cannot do it, because by then s.gameSessions[char] is the
// replacement, so boundGameSessionCharacterCurrent answers false for the old
// identity and detachRuntimePartySession returns without touching the roster.
// Dropping the hand-over without performing the leave would leave a member nobody
// can remove -- the 2026-09-20 ghost-party class, which this test now guards.
func TestManagedPartyReplacementSessionLeavesTheParty(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
leader := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1001}
member := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1002}
other := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1003}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
service.bindGameSessionCharacter(other, 1003)
for _, joiner := range []*gameSession{member, other} {
if _, result, joined := service.createOrJoinManagedRuntimeParty(joiner, leader); !joined || !result.OK {
t.Fatalf("initial join=%t result=%+v", joined, result)
}
}
state := runtimePartyStateSnapshot(leader)
if len(runtimePartyMembers(state)) != 3 {
t.Fatalf("party has %d members before the takeover, want 3", len(runtimePartyMembers(state)))
}
oldGeneration := member.characterGeneration
leader.conn.(*bufferConn).write.Reset()
replacement := &gameSession{conn: &bufferConn{}, channel: channel, selectedCharacterID: 1002}
service.bindGameSessionCharacter(replacement, 1002)
if replacement.characterGeneration == oldGeneration {
t.Fatalf("replacement generation=%d old=%d", replacement.characterGeneration, oldGeneration)
}
if _, stale := service.runtimePartyManagerForService().SnapshotByUser(1002, oldGeneration); stale {
t.Fatal("retired session generation remained in central party")
}
if _, current := service.runtimePartyManagerForService().SnapshotByUser(1002, replacement.characterGeneration); current {
t.Fatal("the replacement session inherited the party instead of leaving it")
}
if projected := runtimePartyStateSnapshot(replacement); projected.PartyID != 0 {
t.Fatalf("replacement projection=%+v, want no party", projected)
}
if leader.conn.(*bufferConn).write.Len() == 0 {
t.Fatal("the survivors were never told the replacement left the party")
}
// Two survivors remain, so the party survives without the departed member.
survivorState := runtimePartyStateSnapshot(leader)
members := runtimePartyMembers(survivorState)
if survivorState.PartyID != state.PartyID || len(members) != 2 {
t.Fatalf("survivor party=%+v, want party %d with 2 members", survivorState, state.PartyID)
}
if containsRuntimePartyMember(members, 1002) {
t.Fatal("the departed character is still on the survivors' roster")
}
}