DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,368 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||||
"longheng.io/server/internal/modules/dnf/raid"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// 超时空:走进一个有怪的区域时,**这一格里的所有人都要一起进副本**,
|
||||
// 与攻坚小队编制无关;编制本身一个字不动(进去的是一支临时普通队伍)。
|
||||
//
|
||||
// 官方口径(九游 2018-06-15《超时空之战机制解读》):
|
||||
//
|
||||
// 「攻坚开始后,处在同一地图的玩家进入副本时自动组队,第一个进入新副本的玩家为队长」
|
||||
// 「每个房间上限4人,达到人数上限后其他人无法进入」 —— 就是 PVF 的 [MEMBER MAX]。
|
||||
//
|
||||
// 客户端自己的提示也是这句:「移动后,会与处于当前城镇的同伴一同开始战斗」。
|
||||
//
|
||||
// 真机报文(2026-10-01 10:36:10,char 22 按下进图):
|
||||
//
|
||||
// RECV op36 town=98 area=6 x=177 y=597 dir=5 opaque=(98, 3)
|
||||
// ^^^^^^ 要打的那个区域(副本 4954)
|
||||
// ^^^^^^^^^^^^^ 他**按下进图时站的格子**
|
||||
//
|
||||
// 所以这份用例的输入就是真机那份:两个人站在格子 3(Town01),
|
||||
// 其中一个人请求进 area 6(3 → 6 是 PVF 里真实的相邻关系)。
|
||||
func TestRealPVFFiendWarMoveEncounterTakesTheWholeArea(t *testing.T) {
|
||||
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
|
||||
if path == "" {
|
||||
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
|
||||
}
|
||||
f := newRuntimeRaidRewardTestFixture(t, true)
|
||||
s := f.service
|
||||
s.options.pvfPath = path
|
||||
if err := s.preloadDungeonWorldMap(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.preloadTownCatalog(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := s.runtimeRaidFiendWarConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := s.raids[f.raidKey]
|
||||
state.RaidMode, state.RouteOrRaidType = runtimeRaidOwnerFiendWar, 2
|
||||
state.AntonConfig, state.FiendWarConfig = nil, config
|
||||
state.Lifecycle, err = newRuntimeRaidLifecycleStateFromModeAndType(
|
||||
config.Battle.Raid, runtimeRaidOwnerFiendWar, f.now, false, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.Lifecycle.FiendWar, err = raid.NewFiendWarBattle(config.Battle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := state.Lifecycle.FiendWar
|
||||
b.GroupAreas[1] = int(currentFiendWarWaitingAreaID)
|
||||
b.GroupAreas[2] = int(currentFiendWarWaitingAreaID)
|
||||
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
||||
// 区域 6 拥有副本 4954,且站着一只守军 —— "这个区域要打"是 fixture 的事实。
|
||||
b.Areas[6] = raid.FiendWarAreaState{Monster: raid.FiendWarUnit{ID: 1, Type: 1}}
|
||||
syncRuntimeFiendWarObjectives(state)
|
||||
|
||||
s.onlinePlayers = newOnlinePlayerManager()
|
||||
for i, peer := range []*gameSession{f.leader, f.member} {
|
||||
peer.accountID = "account-" + strconv.Itoa(i+1)
|
||||
peer.channel = channelcatalog.Channel{ID: 261, Type: 42}
|
||||
peer.selectPreviewObjectStateSent, peer.currentSceneObjectListSent = true, true
|
||||
if err := f.repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: strconv.Itoa(int(peer.selectedCharacterID)),
|
||||
AccountID: peer.accountID,
|
||||
Name: "member",
|
||||
Level: 90,
|
||||
Stats: map[string]int64{
|
||||
"fatigue": 156, "town_id": 98, "area_id": 3,
|
||||
"pos_x": 240, "pos_y": 484, "direction": 5, "area_state": 0,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quests := dnfrepo.QuestRecord{
|
||||
CharacterID: strconv.Itoa(int(peer.selectedCharacterID)),
|
||||
States: map[int64]dnfrepo.QuestState{},
|
||||
}
|
||||
for _, id := range []int64{2, 3, 6, 10, 9, 14} {
|
||||
area, found := s.townArea(98, id)
|
||||
if !found {
|
||||
t.Fatalf("waiting town area %d missing from PVF", id)
|
||||
}
|
||||
for _, quest := range area.NeedQuests {
|
||||
quests.States[quest] = dnfrepo.QuestState{Status: "completed"}
|
||||
}
|
||||
}
|
||||
if err := f.repositories.Quest.Save(context.Background(), quests); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 两个人都站在格子 3(98/3, 240,484):这就是"同格的同伴"。
|
||||
bindDungeonSelectorOriginForTestAt(t, s, peer, 98, 3, 240, 484)
|
||||
registerPartyPeerEndpointForTest(peer, byte(i+2), uint16(5100+i))
|
||||
seedPartyOwnerActorProjectionForTest(t, s, peer, peer.selectedCharacterID, 0)
|
||||
// 进图名单按**实时位置**取自 area 实例表(runtimeRaidFiendWarAreaRoster →
|
||||
// onlinePlayers.GetAreaInstancePlayers)。真机上每一次 op36 都会把人写进
|
||||
// 这张表(town_move.go 的 EnterArea),所以 fixture 必须照样登记,否则
|
||||
// "同格的同伴"在服务端眼里根本不存在。
|
||||
s.onlinePlayers.EnterArea(&onlinePlayerInfo{
|
||||
CharacterID: peer.selectedCharacterID,
|
||||
ChannelID: townPresenceChannelID(peer),
|
||||
TownID: 98,
|
||||
AreaID: 3,
|
||||
InstanceKey: state.RaidKey,
|
||||
PositionX: 240,
|
||||
PositionY: 484,
|
||||
Session: peer,
|
||||
})
|
||||
}
|
||||
if err := s.applyRuntimeRaidParties(*state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 进图单位是**编制小队**(操作员 2026-10-01 定案:不做附近自动组队,按小队编制走)。
|
||||
// 所以这份输入就是"两个人同一小队、站在同一格":1001 和 1002 都在 1 队。
|
||||
state.Members[0].GroupIndex, state.Members[0].SlotOrder = 1, 0
|
||||
state.Members[1].GroupIndex, state.Members[1].SlotOrder = 1, 1
|
||||
state.FrozenMembers[0].GroupIndex, state.FrozenMembers[0].SlotOrder = 1, 0
|
||||
state.FrozenMembers[1].GroupIndex, state.FrozenMembers[1].SlotOrder = 1, 1
|
||||
if got := runtimeRaidNormalizedGroup(state.Members[1].GroupIndex); got != 1 {
|
||||
t.Fatalf("fixture 没造出同队输入:1002 在 %d 队", got)
|
||||
}
|
||||
b.MemberAreas[1001], b.MemberAreas[1002] = 3, 3
|
||||
b.GroupAreas[1] = 3
|
||||
syncRuntimeFiendWarObjectives(state)
|
||||
|
||||
// **按下进图的是 1002**(小队里的第二个人):他才是这一场的驱动者
|
||||
// (官方口径「第一个进入新副本的玩家为队长」)。
|
||||
mover := f.member
|
||||
leader := f.leader
|
||||
|
||||
// [B] 队友被搬进副本前,每个人都要过 raid_current_compat.go 的
|
||||
// validateAntonRaidDungeonEntry。它原来只认 MemberArea(**本人**),于是
|
||||
// "两个人同队同格、一起进图"里的第二个人必被拒:同伴按设计留在原格,
|
||||
// 本人的账本永远对不上目标区域。现在"本人所在小队已经走进目标区域"
|
||||
// (GroupAreas[小队] == 目标区域)也算数。
|
||||
dungeonID := int64(b.Config.Areas[6].Dungeon)
|
||||
if err := s.validateAntonRaidDungeonEntry(leader, dungeonID); err == nil {
|
||||
t.Fatal("小队还没走进区域 6,门禁就放行了:这条放松变成了任何人都能进的洞")
|
||||
}
|
||||
// 真机上这一脚由 town_move.go 先落地:moveRuntimeFiendWarGroup 只把**移动的人**
|
||||
// 和小队记进目标区域,其他队员的 MemberAreas 留在他们当时站的格子上。
|
||||
if _, err := s.moveRuntimeFiendWarGroup(mover.selectedCharacterID, 6); err != nil {
|
||||
t.Fatalf("move ledger: %v", err)
|
||||
}
|
||||
b = state.Lifecycle.FiendWar
|
||||
if b.GroupAreas[1] != 6 || b.MemberAreas[mover.selectedCharacterID] != 6 {
|
||||
t.Fatalf("账本没把小队列进区域 6:group=%v driver=%d",
|
||||
b.GroupAreas, b.MemberAreas[mover.selectedCharacterID])
|
||||
}
|
||||
if got := b.MemberAreas[leader.selectedCharacterID]; got != 3 {
|
||||
t.Fatalf("同伴的账本被一起搬走了:peer area = %d, want 3(按设计他留在原格)", got)
|
||||
}
|
||||
if err := s.validateAntonRaidDungeonEntry(leader, dungeonID); err != nil {
|
||||
t.Fatalf("同队同伴站在原格、小队已走进区域 6,门禁仍然拒绝:%v", err)
|
||||
}
|
||||
// D2 的决定不受影响:没编制的人(Seat.Group == 0)依旧进不来。
|
||||
setGroup := func(members []runtimeRaidMemberState, id uint16, group byte) byte {
|
||||
for i := range members {
|
||||
if members[i].CharID != id {
|
||||
continue
|
||||
}
|
||||
previous := members[i].GroupIndex
|
||||
members[i].GroupIndex = group
|
||||
return previous
|
||||
}
|
||||
return 0
|
||||
}
|
||||
liveGroup := setGroup(state.Members, leader.selectedCharacterID, 0)
|
||||
frozenGroup := setGroup(state.FrozenMembers, leader.selectedCharacterID, 0)
|
||||
if err := s.validateAntonRaidDungeonEntry(leader, dungeonID); err == nil {
|
||||
t.Fatal("未编队成员被放进了超时空副本:GroupAreas[0] 应当是零值")
|
||||
}
|
||||
setGroup(state.Members, leader.selectedCharacterID, liveGroup)
|
||||
setGroup(state.FrozenMembers, leader.selectedCharacterID, frozenGroup)
|
||||
|
||||
// [A] 小队队长由操作员排定(名册序第一的 1001)。这一场按下进图的却是 1002:
|
||||
// 临时队伍必须跟着驱动者走,但小队队长标志不许跟着换。
|
||||
state.SquadPartyLeaders = map[byte]uint16{1: leader.selectedCharacterID}
|
||||
|
||||
// 进图前的编制快照:整段流程一个字都不许改它。
|
||||
beforeSquads := []byte{state.Members[0].GroupIndex, state.Members[1].GroupIndex}
|
||||
|
||||
leader.conn.(*bufferConn).write.Reset()
|
||||
mover.conn.(*bufferConn).write.Reset()
|
||||
|
||||
// 真机报文:目标 area 6,站的格子 3 在 opaque 里。
|
||||
request := currentTownSetUserAreaRequest{
|
||||
TownID: 98, AreaID: 6, PositionX: 177, PositionY: 597, OpaqueU16A: 98, OpaqueU16B: 3,
|
||||
}
|
||||
selfRow := currentSceneTransitionRow{
|
||||
ObjectOrResourceKey: currentSceneActorObjectKey(mover.selectedCharacterID),
|
||||
Value1: 177,
|
||||
Value2: 597,
|
||||
Value3: currentAntonRaidDirection,
|
||||
Value4: currentAntonRaidAreaState,
|
||||
}
|
||||
body, err := buildCurrentSceneTransitionBody(98, 6, []currentSceneTransitionRow{selfRow})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// queueRuntimeFiendWarMoveEncounterLocked 的约定:调用者持 townMu,
|
||||
// 它自己会先放锁、返回前再拿回来。
|
||||
mover.townMu.Lock()
|
||||
handled, err := s.queueRuntimeFiendWarMoveEncounterLocked(mover, request, selfRow, body)
|
||||
mover.townMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatalf("area encounter pass: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("走进有怪的区域没有触发遭遇战:根本进不了副本")
|
||||
}
|
||||
|
||||
// 同一个小队的同伴被一起带进副本:临时普通队伍(op11/op9)+ 副本选择(op153)。
|
||||
// 按编制走之后,编出来的就是这一支队伍本身。
|
||||
written := map[uint16]bool{}
|
||||
for raw := append([]byte(nil), leader.conn.(*bufferConn).write.Bytes()...); len(raw) != 0; {
|
||||
packet, rest := splitGameServerUpperPacket(t, raw)
|
||||
raw = rest
|
||||
written[packet.Header.MsgID] = true
|
||||
}
|
||||
if !written[11] || !written[9] {
|
||||
t.Fatalf("同队的同伴没有拿到临时队伍(op11/op9);msg ids=%v", written)
|
||||
}
|
||||
if !written[153] {
|
||||
t.Fatalf("同队的同伴没有被带进副本(没有 op153 副本选择);msg ids=%v", written)
|
||||
}
|
||||
// 按下进图的那个人自己也必须真的被带着走 —— 否则就是"同伴进去了、我留在原地"。
|
||||
// (这里只看报文:本用例的 fixture 没有真实副本场景,提交 runtime 不是它的职责。)
|
||||
moverWritten := map[uint16]bool{}
|
||||
for raw := append([]byte(nil), mover.conn.(*bufferConn).write.Bytes()...); len(raw) != 0; {
|
||||
packet, rest := splitGameServerUpperPacket(t, raw)
|
||||
raw = rest
|
||||
moverWritten[packet.Header.MsgID] = true
|
||||
}
|
||||
if !moverWritten[11] || !moverWritten[9] || !moverWritten[153] {
|
||||
t.Fatalf("按下进图的人没有被带进副本;msg ids=%v", moverWritten)
|
||||
}
|
||||
|
||||
// 编制不变:攻坚名册还是"1001 在 1 队、1002 在 2 队",谁都没被并队。
|
||||
after := s.raids[f.raidKey]
|
||||
if after.Members[0].GroupIndex != beforeSquads[0] || after.Members[1].GroupIndex != beforeSquads[1] {
|
||||
t.Fatalf("攻坚编制被改了:live=%d/%d want %d/%d;同一个 state 对象=%t;本地 state=%d/%d",
|
||||
after.Members[0].GroupIndex, after.Members[1].GroupIndex, beforeSquads[0], beforeSquads[1],
|
||||
after == state, state.Members[0].GroupIndex, state.Members[1].GroupIndex)
|
||||
}
|
||||
if len(after.Members) != 2 || len(after.FrozenMembers) != 2 {
|
||||
t.Fatalf("名册行数变了:live=%d frozen=%d", len(after.Members), len(after.FrozenMembers))
|
||||
}
|
||||
|
||||
// [A]+[D] 临时队伍:**由按下进图的人领队,并且把同格里站着的人一起带上**。
|
||||
//
|
||||
// 这是整条链路的验收点。修好之前这里是空的(solo)。现在应该是
|
||||
// party.UserID == 驱动者、成员恰好是这一格里登记的两个人。
|
||||
//
|
||||
// 纪律说明:进图提交成功时队伍要**留着**(真机上一直留到队友进图为止,
|
||||
// packet log 2026-10-01 20:56:48 的 party_id=4 slot_count=2 就是它);
|
||||
// 只有提交失败才允许被拆掉,而且必须拆干净。
|
||||
mover.dungeon.mu.Lock()
|
||||
committed := mover.dungeon.runtime != nil
|
||||
mover.dungeon.mu.Unlock()
|
||||
party := runtimePartyStateSnapshot(mover)
|
||||
if committed {
|
||||
if party.PartyID <= 0 {
|
||||
t.Fatalf("进图已提交,临时队伍却不见了:%+v", party)
|
||||
}
|
||||
if party.UserID != mover.selectedCharacterID {
|
||||
t.Fatalf("临时队伍的队长是 %d,want 按下进图的 %d;party=%+v",
|
||||
party.UserID, mover.selectedCharacterID, party)
|
||||
}
|
||||
if len(party.Members) != 2 {
|
||||
t.Fatalf("同格里的两个人没有一起进临时队伍:%+v", party.Members)
|
||||
}
|
||||
var ids []uint16
|
||||
for _, m := range party.Members {
|
||||
ids = append(ids, m.UserID)
|
||||
}
|
||||
if !slices.Contains(ids, mover.selectedCharacterID) || !slices.Contains(ids, leader.selectedCharacterID) {
|
||||
t.Fatalf("临时队伍的成员不是同格的两个人:%v", ids)
|
||||
}
|
||||
} else {
|
||||
if party.PartyID != 0 {
|
||||
t.Fatalf("副本没提交却留下了临时队伍:%+v", party)
|
||||
}
|
||||
if peer := runtimePartyStateSnapshot(leader); peer.PartyID != 0 {
|
||||
t.Fatalf("同伴身上留下了临时队伍:%+v", peer)
|
||||
}
|
||||
}
|
||||
// 临时队伍的队长让给了驱动者,但**攻坚队的小队队长标志不许跟着换**:
|
||||
// 小队队长是操作员在攻坚队管理器里排的编制事实,不该因为"这次是谁按的进图"
|
||||
// 而改手。(这条同时钉住 restoreRuntimeFiendWarSquadPartyLeader:去掉它,
|
||||
// 名册序重排会把 1002 写进 SquadPartyLeaders,这里就会看到 1002。)
|
||||
if got := after.SquadPartyLeaders[1]; got != leader.selectedCharacterID {
|
||||
t.Fatalf("小队队长标志被这一场进图改手了:squad 1 leader = %d, want %d (table=%v)",
|
||||
got, leader.selectedCharacterID, after.SquadPartyLeaders)
|
||||
}
|
||||
}
|
||||
|
||||
// [A] 的机制面:进图这一场的名册序必须由**驱动者**领队。
|
||||
//
|
||||
// runtimeRaidGroupPartyState 拿 members[0] 当队伍的 UserID 与 0 号座位,而
|
||||
// prepareRuntimePartyDungeonEntry 只在"驱动进图的 session 就是队伍队长"时才把队友
|
||||
// 搬进同一个副本(dungeon_navigation_runtime.go:753:
|
||||
// `state.UserID != leader.selectedCharacterID → return nil, nil`);
|
||||
// RuntimePartySnapshot.StateFor 又给**每个**成员的 UserID 都是 snapshot.Leader
|
||||
// (runtime_manager.go:652)。
|
||||
//
|
||||
// 所以"谁驱动这一场"必须排在名册最前面。原来它总是名册序第一(SlotOrder 最小),
|
||||
// 只要按下进图的不是那个人,整支临时队伍就会在那道守卫上被**静默**丢掉:
|
||||
// 队友一个都不搬、驱动者一个人进图,日志里连 game-party-dungeon-entry-blocked
|
||||
// 都不会有 —— 真机 2026-10-01 20:56:48 正是如此。
|
||||
func TestFiendWarEncounterRosterIsLedByTheDriver(t *testing.T) {
|
||||
members := []runtimeRaidMemberState{
|
||||
{CharID: 1001, SlotOrder: 0},
|
||||
{CharID: 1002, SlotOrder: 1},
|
||||
{CharID: 1003, SlotOrder: 2},
|
||||
}
|
||||
led := runtimeRaidRosterLedBy(members, 1002)
|
||||
if len(led) != 3 || led[0].CharID != 1002 || led[1].CharID != 1001 || led[2].CharID != 1003 {
|
||||
t.Fatalf("驱动者没有领队、或其余顺序没逐字保留:%+v", led)
|
||||
}
|
||||
// 入参不许被就地重排:squad.Members 还要用于"这一趟该不该 defer 给名册序第一位"
|
||||
// 的判断(tryRuntimeFiendWarEncounterPlanned 里 plan == nil 那条),动它就等于
|
||||
// 顺手改了那一处的语义。
|
||||
if members[0].CharID != 1001 || members[1].CharID != 1002 || members[2].CharID != 1003 {
|
||||
t.Fatalf("入参被就地重排了:%+v", members)
|
||||
}
|
||||
// 这支名册序真的决定队伍的队长与 0 号座位 —— 也就是决定谁能触发队友 fan-out。
|
||||
party := runtimeRaidGroupPartyState(
|
||||
runtimeRaidState{RaidKey: 12058625, NameBytes: []byte("raid")}, led)
|
||||
if party.UserID != 1002 {
|
||||
t.Fatalf("临时队伍的队长 = %d, want 驱动者 1002", party.UserID)
|
||||
}
|
||||
if len(party.Members) != 3 || party.Members[0].UserID != 1002 || party.Members[0].Slot != 0 {
|
||||
t.Fatalf("0 号座位不是驱动者:%+v", party.Members)
|
||||
}
|
||||
// 驱动者本来就在最前面 / 不在名单里 / 未知:原样返回,不造一个不存在的成员。
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
leader uint16
|
||||
}{{"already first", 1001}, {"absent", 1099}, {"unknown", 0}} {
|
||||
got := runtimeRaidRosterLedBy(members, tc.leader)
|
||||
if len(got) != 3 || got[0].CharID != 1001 {
|
||||
t.Fatalf("%s:名册被改动了 %+v", tc.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同伴没好(离线/还在切场景/坐标没落)时,整格都不进 —— 但要**说出原因**。
|
||||
// 原来这里全是静默 return,真机上只能看到"走进了区域却没开打"。
|
||||
func TestFiendWarEncounterPeerRefusalNamesTheReason(t *testing.T) {
|
||||
if got := (&Service{}).fiendWarEncounterPeerRefusal(runtimeRaidMemberState{CharID: 4242}, 98, 14); got != "peer_offline" {
|
||||
t.Fatalf("离线同伴的拒绝理由 = %q,want peer_offline", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user