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