Files
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

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)
}
}
}