DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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)
}
}