服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
194 lines
7.5 KiB
Go
194 lines
7.5 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
)
|
|
|
|
// TestRuntimeRaidPhaseExpiryReleasesEveryAttackPartyMember covers the attack
|
|
// party path of publishExpiredRuntimeRaid. expireRuntimeRaidPhase deletes the
|
|
// authoritative raid, so a twenty-member roster that is never told keeps its
|
|
// raid window, rejoin projection and ordinary-party projection forever. Only
|
|
// single raids used to be published here.
|
|
func TestRuntimeRaidPhaseExpiryReleasesEveryAttackPartyMember(t *testing.T) {
|
|
channel := channelcatalog.Channel{ID: 200, Type: 23}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
raids: make(map[uint32]*runtimeRaidState),
|
|
}
|
|
now := time.Date(2026, 9, 8, 6, 0, 0, 0, time.UTC)
|
|
|
|
members := make([]runtimeRaidMemberState, 0, raid.MaxAttackPartyMembers)
|
|
sessions := make(map[uint16]*gameSession, raid.MaxAttackPartyMembers)
|
|
for i := 0; i < raid.MaxAttackPartyMembers; i++ {
|
|
charID := uint16(1001 + i)
|
|
members = append(members, runtimeRaidMemberState{
|
|
CharID: charID,
|
|
GroupIndex: byte(i/runtimeRaidGroupSize) + 1,
|
|
SlotOrder: byte(i % runtimeRaidGroupSize),
|
|
})
|
|
session := &gameSession{channel: channel, conn: &bufferConn{}}
|
|
service.bindGameSessionCharacter(session, charID)
|
|
sessions[charID] = session
|
|
}
|
|
|
|
const raidKey = uint32(0x00c80401)
|
|
lifecycle := newRuntimeRaidLifecycleState(now.Add(-90 * time.Minute))
|
|
lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
|
lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
lifecycle.PhaseDeadline = now.Add(-time.Minute)
|
|
service.raids[raidKey] = &runtimeRaidState{
|
|
RaidKey: raidKey,
|
|
LeaderID: members[0].CharID,
|
|
TailFlag: 5,
|
|
Started: true,
|
|
EntryCostPaid: true,
|
|
Members: append([]runtimeRaidMemberState(nil), members...),
|
|
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
|
|
Lifecycle: lifecycle,
|
|
}
|
|
|
|
expired, err := service.expireRuntimeRaidPhase(raidKey, runtimeRaidDungeonPhaseOne, now)
|
|
if err != nil {
|
|
t.Fatalf("expire attack-party phase: %v", err)
|
|
}
|
|
if expired.TailFlag == 1 {
|
|
t.Fatalf("fixture is not an attack party: tail_flag=%d", expired.TailFlag)
|
|
}
|
|
if err := service.publishExpiredRuntimeRaid(expired); err != nil {
|
|
t.Fatalf("publish expired attack party: %v", err)
|
|
}
|
|
|
|
for _, member := range members {
|
|
session := sessions[member.CharID]
|
|
raw := session.conn.(*bufferConn).write.Bytes()
|
|
if len(raw) == 0 {
|
|
t.Fatalf("member %d was never released after the phase expired", member.CharID)
|
|
}
|
|
packet, _ := splitGameServerUpperPacket(t, raw)
|
|
if packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveRaid) {
|
|
t.Fatalf("member %d terminal msg_id=%d want=%d",
|
|
member.CharID, packet.Header.MsgID, uint16(dnfenum.CmdPacketLeaveRaid))
|
|
}
|
|
if !runtimeRaidEndedProjectionMatches(session, uint16(expired.RaidKey)) {
|
|
t.Fatalf("member %d kept a live raid projection after expiry", member.CharID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A started team raid must still accept a leave.
|
|
//
|
|
// Live incident 2026-09-16 17:39: the raid leader picked 退出攻坚队 (C2S 665 with
|
|
// its own character id) and the server answered a refusal, because
|
|
// removeRuntimeRaidMemberLocked bailed out whenever StartPending/Started was set.
|
|
// The client had already closed its raid window, so the player sat inside a raid
|
|
// they could not leave and could not create another one beside -- the old object
|
|
// stayed on the roster listing them as its leader.
|
|
//
|
|
// The leave itself has no state gate in the 86JP reference either:
|
|
// RaidManager.LeaveLocked removes the member unconditionally.
|
|
func TestRuntimeRaidStartedTeamLeaveIsAccepted(t *testing.T) {
|
|
fixture := newRuntimeRaidDisconnectFixture(t, 1001, 1002)
|
|
fixture.service.raids[fixture.raidKey].Started = true
|
|
fixture.service.raids[fixture.raidKey].StartPending = true
|
|
|
|
// The leader leaves and the raid survives under the next member in roster
|
|
// order, so the departing leader is free to create another one.
|
|
remaining, removed, disbanded := fixture.service.removeRuntimeRaidMember(1001)
|
|
if !removed || disbanded {
|
|
t.Fatalf("started raid leader leave removed=%t disbanded=%t", removed, disbanded)
|
|
}
|
|
if remaining.LeaderID != 1002 || runtimeRaidMemberIndex(remaining, 1001) >= 0 {
|
|
t.Fatalf("leader leave left the roster wrong: %+v", remaining)
|
|
}
|
|
|
|
// The last member leaving empties and disbands the raid.
|
|
_, removed, disbanded = fixture.service.removeRuntimeRaidMember(1002)
|
|
if !removed || !disbanded {
|
|
t.Fatalf("last member leave removed=%t disbanded=%t", removed, disbanded)
|
|
}
|
|
if len(fixture.service.raids) != 0 {
|
|
t.Fatalf("the started raid survived its last member's leave: %+v", fixture.service.raids)
|
|
}
|
|
}
|
|
|
|
// TestRuntimeRaidManagerMoveRejectsFullSubgroup covers the native
|
|
// runtimeRaidMaxGroups x runtimeRaidGroupSize roster grid. Without the capacity
|
|
// guard the slot allocator fell back to the last slot and put two members on
|
|
// one grid cell.
|
|
func TestRuntimeRaidManagerMoveRejectsFullSubgroup(t *testing.T) {
|
|
channel := channelcatalog.Channel{ID: 200, Type: 23}
|
|
service := &Service{raids: make(map[uint32]*runtimeRaidState)}
|
|
const raidKey = uint32(0x00c80402)
|
|
|
|
members := []runtimeRaidMemberState{
|
|
{CharID: 1001, GroupIndex: 1, SlotOrder: 0},
|
|
{CharID: 1002, GroupIndex: 1, SlotOrder: 1},
|
|
{CharID: 1003, GroupIndex: 1, SlotOrder: 2},
|
|
{CharID: 1004, GroupIndex: 1, SlotOrder: 3},
|
|
{CharID: 1005, GroupIndex: 2, SlotOrder: 0},
|
|
}
|
|
service.raids[raidKey] = &runtimeRaidState{
|
|
RaidKey: raidKey,
|
|
LeaderID: 1001,
|
|
TailFlag: 5,
|
|
Members: append([]runtimeRaidMemberState(nil), members...),
|
|
}
|
|
leader := &gameSession{channel: channel, conn: &bufferConn{}}
|
|
service.bindGameSessionCharacter(leader, 1001)
|
|
|
|
if _, ok := service.moveRuntimeRaidMember(1001, 1005, 1); ok {
|
|
t.Fatal("move into a full subgroup was accepted")
|
|
}
|
|
state := service.raids[raidKey]
|
|
index := runtimeRaidMemberIndex(*state, 1005)
|
|
if index < 0 || state.Members[index].GroupIndex != 2 || state.Members[index].SlotOrder != 0 {
|
|
t.Fatalf("rejected move mutated the roster: %+v", state.Members[index])
|
|
}
|
|
assertRuntimeRaidGridUnique(t, *state)
|
|
|
|
// Re-slotting inside the member's own subgroup must stay available: the
|
|
// mover never counts against its own capacity.
|
|
if _, ok := service.moveRuntimeRaidMember(1001, 1004, 1); !ok {
|
|
t.Fatal("re-slotting inside the current subgroup was rejected")
|
|
}
|
|
assertRuntimeRaidGridUnique(t, *service.raids[raidKey])
|
|
|
|
// A subgroup with a free slot still accepts the move.
|
|
if _, ok := service.moveRuntimeRaidMember(1001, 1003, 2); !ok {
|
|
t.Fatal("move into a subgroup with a free slot was rejected")
|
|
}
|
|
moved := service.raids[raidKey]
|
|
movedIndex := runtimeRaidMemberIndex(*moved, 1003)
|
|
if movedIndex < 0 || moved.Members[movedIndex].GroupIndex != 2 {
|
|
t.Fatalf("accepted move did not land in subgroup 2: %+v", moved.Members[movedIndex])
|
|
}
|
|
assertRuntimeRaidGridUnique(t, *moved)
|
|
}
|
|
|
|
func assertRuntimeRaidGridUnique(t *testing.T, state runtimeRaidState) {
|
|
t.Helper()
|
|
type cell struct {
|
|
group byte
|
|
slot byte
|
|
}
|
|
seen := make(map[cell]uint16, len(state.Members))
|
|
for _, member := range state.Members {
|
|
// Standby members (group 0) share the single "no squad" cell by design;
|
|
// only a real squad seat has to be unique.
|
|
if member.GroupIndex == 0 {
|
|
continue
|
|
}
|
|
key := cell{group: member.GroupIndex, slot: member.SlotOrder}
|
|
if other, clash := seen[key]; clash {
|
|
t.Fatalf("members %d and %d share roster cell group=%d slot=%d",
|
|
other, member.CharID, key.group, key.slot)
|
|
}
|
|
seen[key] = member.CharID
|
|
}
|
|
}
|