服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
314 lines
12 KiB
Go
314 lines
12 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
)
|
|
|
|
func TestOnlinePlayerManagerSeparatesAntonWaitingInstancesByRaidKey(t *testing.T) {
|
|
manager := newOnlinePlayerManager()
|
|
const (
|
|
firstRaidKey = uint32(0x00170001)
|
|
secondRaidKey = uint32(0x00170002)
|
|
)
|
|
first := &onlinePlayerInfo{CharacterID: 1001, TownID: byte(currentAntonRaidTownID), AreaID: currentAntonRaidWaitingAreaID, InstanceKey: firstRaidKey}
|
|
ally := &onlinePlayerInfo{CharacterID: 1002, TownID: byte(currentAntonRaidTownID), AreaID: currentAntonRaidWaitingAreaID, InstanceKey: firstRaidKey}
|
|
otherRaid := &onlinePlayerInfo{CharacterID: 2001, TownID: byte(currentAntonRaidTownID), AreaID: currentAntonRaidWaitingAreaID, InstanceKey: secondRaidKey}
|
|
|
|
if peers := manager.EnterArea(first); len(peers) != 0 {
|
|
t.Fatalf("first raid member saw unexpected waiting peers: %+v", peers)
|
|
}
|
|
if peers := manager.EnterArea(ally); len(peers) != 1 || peers[0].CharacterID != first.CharacterID {
|
|
t.Fatalf("same raid waiting peers=%+v", peers)
|
|
}
|
|
if peers := manager.EnterArea(otherRaid); len(peers) != 0 {
|
|
t.Fatalf("different raid leaked into waiting peers: %+v", peers)
|
|
}
|
|
if !manager.PeerInSameArea(first.CharacterID, ally.CharacterID) {
|
|
t.Fatal("same raid waiting members are not co-present")
|
|
}
|
|
if manager.PeerInSameArea(first.CharacterID, otherRaid.CharacterID) {
|
|
t.Fatal("different raid waiting members are visible to each other")
|
|
}
|
|
if got := manager.GetAreaInstancePlayers(
|
|
0, byte(currentAntonRaidTownID), currentAntonRaidWaitingAreaID, firstRaidKey); len(got) != 2 {
|
|
t.Fatalf("first raid waiting roster=%+v", got)
|
|
}
|
|
if got := manager.GetAreaInstancePlayers(
|
|
0, byte(currentAntonRaidTownID), currentAntonRaidWaitingAreaID, secondRaidKey); len(got) != 1 || got[0].CharacterID != otherRaid.CharacterID {
|
|
t.Fatalf("second raid waiting roster=%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCurrentRaidTownSpecSeparatesAntonAndLukeChannels(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
channelType byte
|
|
want runtimeRaidTownSpec
|
|
ok bool
|
|
}{
|
|
{
|
|
name: "anton",
|
|
channelType: dnfenum.RaidChannelType,
|
|
want: runtimeRaidTownSpec{
|
|
townID: currentAntonRaidTownID, areaID: currentAntonRaidAreaID,
|
|
waitingArea: currentAntonRaidWaitingAreaID, ownerName: "anton",
|
|
},
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "luke",
|
|
channelType: dnfenum.HiddenRaidType,
|
|
want: runtimeRaidTownSpec{
|
|
townID: currentLukeRaidTownID, areaID: currentLukeRaidAreaID,
|
|
waitingArea: currentLukeRaidWaitingAreaID, ownerName: "luke",
|
|
},
|
|
ok: true,
|
|
},
|
|
{name: "ordinary", channelType: 1},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
session := &gameSession{channel: channelcatalog.Channel{Type: test.channelType}}
|
|
got, ok := currentRaidTownSpecForSession(session)
|
|
if ok != test.ok || got != test.want {
|
|
t.Fatalf("raid town spec=(%+v,%t), want=(%+v,%t)", got, ok, test.want, test.ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCurrentRaidTownSpecPinsFiendWarChannelToItsOwnTown covers the third raid
|
|
// owner. Before this, a type-42 channel fell through currentRaidTownSpecForSession
|
|
// to the ordinary-town default, so entering one of the three `[fiendwar]` channels
|
|
// landed the character in whatever town their record held rather than town 98 --
|
|
// and all 22 call sites that ask for the spec agreed on that wrong answer.
|
|
//
|
|
// Town 98 is `180621_Cosmofiend.twn` (town/town.lst); its area 0 is
|
|
// `GContents/180621_Fiendwar/Town/Gate_Fiend.map` with gate (474,234), the same
|
|
// gate coordinate the Anton and Luke raid towns publish.
|
|
func TestCurrentRaidTownSpecPinsFiendWarChannelToItsOwnTown(t *testing.T) {
|
|
session := &gameSession{channel: channelcatalog.Channel{ID: 184, Type: dnfenum.FiendWarRaidChannelType}}
|
|
got, ok := currentRaidTownSpecForSession(session)
|
|
want := runtimeRaidTownSpec{
|
|
townID: currentFiendWarRaidTownID, areaID: currentFiendWarRaidAreaID,
|
|
waitingArea: currentFiendWarWaitingAreaID, ownerName: "fiendwar",
|
|
}
|
|
if !ok || got != want {
|
|
t.Fatalf("fiendwar raid town spec=(%+v,%t), want=(%+v,%t)", got, ok, want, true)
|
|
}
|
|
if !isCurrentFiendWarRaidChannel(session) || !isCurrentImplementedRaidChannel(session) {
|
|
t.Fatal("fiendwar channel is not recognised as an implemented raid channel")
|
|
}
|
|
// The three raid owners must stay disjoint. Adding Fiend War must not move
|
|
// an Anton or Luke channel off its own town, and must not classify either as
|
|
// Fiend War.
|
|
for _, owner := range []struct {
|
|
name string
|
|
channelType byte
|
|
townID int64
|
|
}{
|
|
{name: "anton", channelType: dnfenum.RaidChannelType, townID: currentAntonRaidTownID},
|
|
{name: "luke", channelType: dnfenum.HiddenRaidType, townID: currentLukeRaidTownID},
|
|
} {
|
|
peer := &gameSession{channel: channelcatalog.Channel{Type: owner.channelType}}
|
|
spec, found := currentRaidTownSpecForSession(peer)
|
|
if !found || spec.townID != owner.townID {
|
|
t.Fatalf("%s town spec=(%+v,%t), want town %d", owner.name, spec, found, owner.townID)
|
|
}
|
|
if isCurrentFiendWarRaidChannel(peer) {
|
|
t.Fatalf("channel type %d was classified as Fiend War", owner.channelType)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLukeRaidSelectorAdmissionRequiresPrivateWaitingInstance(t *testing.T) {
|
|
const (
|
|
raidKey = uint32(0x00200001)
|
|
charID = uint16(1001)
|
|
)
|
|
session := &gameSession{
|
|
conn: &bufferConn{},
|
|
channel: channelcatalog.Channel{ID: 241, Type: dnfenum.HiddenRaidType, Name: "luke", Port: 10241},
|
|
selectedCharacterID: charID,
|
|
}
|
|
state := &runtimeRaidState{
|
|
RaidKey: raidKey,
|
|
LeaderID: charID,
|
|
RaidMode: runtimeRaidOwnerLuke,
|
|
Started: true,
|
|
EntryCostPaid: true,
|
|
Members: []runtimeRaidMemberState{{CharID: charID, GroupIndex: 1, SlotOrder: 0}},
|
|
FrozenMembers: []runtimeRaidMemberState{{CharID: charID, GroupIndex: 1, SlotOrder: 0}},
|
|
Lifecycle: runtimeRaidLifecycleState{
|
|
Status: runtimeRaidLifecyclePhaseActive,
|
|
Phase: runtimeRaidDungeonPhaseOne,
|
|
},
|
|
}
|
|
service := &Service{
|
|
raids: map[uint32]*runtimeRaidState{raidKey: state},
|
|
onlinePlayers: newOnlinePlayerManager(),
|
|
}
|
|
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
|
CharacterID: charID,
|
|
TownID: byte(currentLukeRaidTownID),
|
|
AreaID: currentLukeRaidWaitingAreaID,
|
|
InstanceKey: raidKey,
|
|
Session: session,
|
|
})
|
|
|
|
if admitted, allowed, reason := service.runtimeRaidSelectorAdmission(session); !allowed || reason != "raid_phase_active" || admitted.RaidKey != raidKey {
|
|
t.Fatalf("Luke private waiting admission allowed=%t reason=%q state=%+v", allowed, reason, admitted)
|
|
}
|
|
|
|
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
|
CharacterID: charID,
|
|
TownID: byte(currentLukeRaidTownID),
|
|
AreaID: 1,
|
|
Session: session,
|
|
})
|
|
if _, allowed, reason := service.runtimeRaidSelectorAdmission(session); allowed || reason != "raid_member_not_in_private_waiting_instance" {
|
|
t.Fatalf("Luke public lobby admission allowed=%t reason=%q", allowed, reason)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeRaidSelectorAdmissionRequiresActivePrivateWaitingInstance(t *testing.T) {
|
|
const (
|
|
raidKey = uint32(0x00170001)
|
|
charID = uint16(1001)
|
|
)
|
|
connection := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: connection,
|
|
channel: channelcatalog.Channel{ID: 23, Type: 23, Name: "anton", Port: 10023},
|
|
selectedCharacterID: charID,
|
|
}
|
|
state := &runtimeRaidState{
|
|
RaidKey: raidKey,
|
|
LeaderID: charID,
|
|
Members: []runtimeRaidMemberState{{CharID: charID, GroupIndex: 1, SlotOrder: 0}},
|
|
Lifecycle: runtimeRaidLifecycleState{Status: runtimeRaidLifecycleCreated},
|
|
}
|
|
service := &Service{
|
|
raids: map[uint32]*runtimeRaidState{raidKey: state},
|
|
onlinePlayers: newOnlinePlayerManager(),
|
|
}
|
|
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
|
CharacterID: charID,
|
|
TownID: byte(currentAntonRaidTownID),
|
|
AreaID: currentAntonRaidWaitingAreaID,
|
|
InstanceKey: raidKey,
|
|
Session: session,
|
|
})
|
|
|
|
if _, allowed, reason := service.runtimeRaidSelectorAdmission(session); allowed || reason != "raid_not_started" {
|
|
t.Fatalf("created raid admission allowed=%t reason=%q", allowed, reason)
|
|
}
|
|
if err := service.sendEnterSelectDungeonState(session, "test_unstarted_raid", false, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("unstarted raid selector wrote %d bytes", connection.write.Len())
|
|
}
|
|
|
|
state.Started = true
|
|
state.EntryCostPaid = true
|
|
state.FrozenMembers = append([]runtimeRaidMemberState(nil), state.Members...)
|
|
state.Lifecycle.Status = runtimeRaidLifecyclePreparing
|
|
if _, allowed, reason := service.runtimeRaidSelectorAdmission(session); allowed || reason != "raid_phase_not_active" {
|
|
t.Fatalf("preparing raid admission allowed=%t reason=%q", allowed, reason)
|
|
}
|
|
|
|
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
|
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
|
CharacterID: charID,
|
|
TownID: byte(currentAntonRaidTownID),
|
|
AreaID: 1,
|
|
Session: session,
|
|
})
|
|
if _, allowed, reason := service.runtimeRaidSelectorAdmission(session); allowed || reason != "raid_member_not_in_private_waiting_instance" {
|
|
t.Fatalf("public lobby admission allowed=%t reason=%q", allowed, reason)
|
|
}
|
|
|
|
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
|
CharacterID: charID,
|
|
TownID: byte(currentAntonRaidTownID),
|
|
AreaID: currentAntonRaidWaitingAreaID,
|
|
InstanceKey: raidKey,
|
|
Session: session,
|
|
})
|
|
if admitted, allowed, reason := service.runtimeRaidSelectorAdmission(session); !allowed || reason != "raid_phase_active" || admitted.RaidKey != raidKey {
|
|
t.Fatalf("active private waiting admission allowed=%t reason=%q state=%+v", allowed, reason, admitted)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeRaidStateIsSharedAcrossRaidChannelIDs(t *testing.T) {
|
|
const raidKey = uint32(0x00170001)
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
raids: map[uint32]*runtimeRaidState{
|
|
raidKey: {
|
|
RaidKey: raidKey,
|
|
LeaderID: 1001,
|
|
NameBytes: []byte("cross-channel"),
|
|
Members: []runtimeRaidMemberState{{CharID: 1001, GroupIndex: 1, SlotOrder: 0}},
|
|
},
|
|
},
|
|
}
|
|
joining := &gameSession{
|
|
conn: &bufferConn{},
|
|
channel: channelcatalog.Channel{ID: 200, Type: dnfenum.RaidChannelType, Name: "anton-200", Port: 10200},
|
|
}
|
|
service.bindGameSessionCharacter(joining, 1002)
|
|
request := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(request, raidKey)
|
|
if err := service.handleOnlineRejoinRaid(joining, uint16(dnfenum.CmdPacketRejoinRaid), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := service.raids[raidKey]
|
|
if state == nil || runtimeRaidMemberIndex(*state, 1002) < 0 {
|
|
t.Fatalf("channel 200 did not join raid created under channel 23: %+v", state)
|
|
}
|
|
}
|
|
|
|
// The 0x0279 raid Buff body publishes the party topology that gives the client
|
|
// its team-channel Buff registration owner, and it must not register a single
|
|
// active row: each row's two u32 are activation deadlines, so a row means "this
|
|
// buff is running for that member".
|
|
//
|
|
// Live report 2026-09-16: the player entered a gate already carrying a damage
|
|
// buff. The bodies this server sent until then attached one row per member with
|
|
// both deadlines zero, which 86JP would read as an ATTACK BONUS activation (its
|
|
// group byte is a buff type: 1 = ATTACK BONUS). The rows are therefore gone while
|
|
// the group/type byte itself is still being proven against the 90 client -- do not
|
|
// re-add member rows to a registration, and do not empty the group count: that
|
|
// leaves the client with no Buff registration owner at all.
|
|
func TestRuntimeRaidBuffRegistrationKeepsTopologyWithoutActiveRows(t *testing.T) {
|
|
state := runtimeRaidState{
|
|
Members: []runtimeRaidMemberState{
|
|
{CharID: 1001, GroupIndex: 1, SlotOrder: 0},
|
|
{CharID: 1002, GroupIndex: 1, SlotOrder: 1},
|
|
{CharID: 2001, GroupIndex: 2, SlotOrder: 0},
|
|
},
|
|
}
|
|
body := mustRuntimeRaidBuffRegistrationSnapshotBody(t, state)
|
|
// The 90 client's slot byte is the table key (sub_15C6AD0 walks five fixed
|
|
// slots), so the registration must name all five with rowCount 0. A row means
|
|
// "running", and only resultType-style deadlines above `now` ever light up:
|
|
// rowCount 0 ends in the clean false return at 0x017B5869.
|
|
wantSlots := []byte{2, 3, 4, 1, 0}
|
|
if len(body) != 1+2*len(wantSlots) || body[0] != byte(len(wantSlots)) {
|
|
t.Fatalf("Buff registration header len=%d body=%x", len(body), body)
|
|
}
|
|
for index, slot := range wantSlots {
|
|
if body[1+index*2] != slot || body[2+index*2] != 0 {
|
|
t.Fatalf("Buff registration slot %d=%d rows=%d want slot=%d rows=0 body=%x",
|
|
index, body[1+index*2], body[2+index*2], slot, body)
|
|
}
|
|
}
|
|
}
|