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,328 @@
package dnfbridge
import (
"context"
"os"
"strconv"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 超时空:战况表 1 Hz 心跳不许把"计划中的整队进图"顶成单人进图。
//
// 真机 2026-10-01(本地 00:03:49~00:03:54,分片 002,UTC 16:03:xx)的形状:
//
// 16:03:49.334 char 22 committed town=98 area=4 instance=12058625 ← 两人同格 4
// 16:03:50.815 char 1 transition-send area=4 row_count=2 peer_row_count=1
// 16:03:50.930 char 1 committed town=98 area=4 instance=12058625
// 16:03:53.743 char 1 RECV op36 {area 7, x419 y601, from 4} ← 按下进图
// 16:03:53.761 op9 party_id=4 slot_count=1 ← 临时进图队伍只有 1 个人(铁证)
// 16:03:53.790 game-fiendwar-maze-selected area=7 ← 单人副本开出来了
// 16:03:54.110 game-fiendwar-encounter-entry entered=true
// (char 22 全程留在格 4,日志里连一行拒绝都没有:area-peer-skipped /
// encounter-peer-not-ready / solo-fallback **一条都不存在**)
//
// 42 秒后的第二脚(16:04:35.786 op36 → 16:04:35.811 slot_count=2 →
// 16:04:35.906 member-prepared char_id=22)却是整队进图。两脚的操作一模一样,
// 差别只有一个:**按键落在 1 Hz 心跳刻度之前还是之后**。
//
// 战况表 tick 的节奏是 raid-start(16:03:40.769) → 16:03:43.733 → 之后每 1 秒:
// 刻度落在 …53.73 / …35.73。第一脚的 op36 在 53.743(刻度之后 13ms)——
//
// 计划那一趟(plan != nil)开工前先把**本人的实时城镇坐标推到目标区域 7**
// (raid_fiendwar_battle.go:setCurrentTownPositionSceneFromTransitionLocked),
// 而副本 runtime 要 ~300ms 后才落地(54.108)。心跳 53.777 正好落在这段窗口里,
// plan==nil 那一路于是把"我站在 7"当成名单锚点,算出**空名单**、只带自己进图,
// 并把随后到达的整队那一趟顶掉(那时 runtime 已存在,计划那趟直接返回)。
//
// 第二脚的 op36 在 35.786(刻度之后 56ms,刻度先跑)—— 心跳跑的时候人还在格 4,
// 而格 4 没有怪(AreaOpen(4)=false),心跳自然放弃,整队进图才成功。
//
// 这个用例把那段时序按真机顺序摆出来:立牌之后心跳必须让位;牌落之后同一个心跳
// 就会单人开图 —— 后半段是**反证**,它证明"让位"这条守卫是唯一把两者分开的东西。
func TestFiendWarBoardHeartbeatCannotStealAPlannedAreaEntry(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 拥有副本,且站着一只守军 —— 心跳那一趟的 AreaOpen(6) 就靠它。
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": 38, "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:这就是"同格的同伴"。
bindDungeonSelectorOriginForTestAt(t, s, peer, 98, 3, 240, 484)
registerPartyPeerEndpointForTest(peer, byte(i+2), uint16(5100+i))
seedPartyOwnerActorProjectionForTest(t, s, peer, peer.selectedCharacterID, 0)
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)
}
state.Members[0].GroupIndex, state.Members[0].SlotOrder = 2, 0
state.Members[1].GroupIndex, state.Members[1].SlotOrder = 1, 1
state.FrozenMembers[0].GroupIndex, state.FrozenMembers[0].SlotOrder = 2, 0
state.FrozenMembers[1].GroupIndex, state.FrozenMembers[1].SlotOrder = 1, 1
b.MemberAreas[f.leader.selectedCharacterID] = 3
b.MemberAreas[f.member.selectedCharacterID] = 3
b.GroupAreas[1], b.GroupAreas[2] = 3, 3
syncRuntimeFiendWarObjectives(state)
leader := f.leader // 按下进图的人
peer := f.member // 同格、按设计等着被搬进去的人
// 计划那一脚在开工前做的两件事,按真机的先后顺序摆出来:
// (1) 把本人的实时城镇坐标推到**目标区域 6**;
// (2) 在把计划进图投进本人信箱**之前**立牌。
selfRow := currentSceneTransitionRow{
ObjectOrResourceKey: currentSceneActorObjectKey(leader.selectedCharacterID),
Value1: 177,
Value2: 597,
Value3: currentAntonRaidDirection,
Value4: currentAntonRaidAreaState,
}
leader.townMu.Lock()
setCurrentTownPositionSceneFromTransitionLocked(leader, leader.selectedCharacterID, 98, 6, selfRow)
leader.townMu.Unlock()
s.beginRuntimeFiendWarPlannedEncounter(leader, f.now)
// [本用例的核心] 心跳此刻落地(真机就是 53.777 那一下):必须让位。
//
// 没有这条守卫时它会单人开图 —— 名单锚点已经是"目标区域 6",而同格的同伴
// 还留在格 3,于是名单里只剩触发者自己,日志上一行拒绝都不会有。
if err := s.tryRuntimeFiendWarEncounter(leader, f.now.Add(time.Millisecond)); err != nil {
t.Fatalf("heartbeat pass: %v", err)
}
leader.dungeon.mu.Lock()
entered := leader.dungeon.runtime != nil
leader.dungeon.mu.Unlock()
if entered {
t.Fatal("战况表心跳在计划进图还在飞的时候自己开了一场副本:它会单人开图," +
"并且把随后到达的整队进图顶掉(真机 2026-10-01 00:03:53 的形状)")
}
// [反证] 牌一落(=计划那一趟跑完后的正常状态),同一个心跳立刻就会单人开图。
// 这一段是刻意的:它证明上一条断言不是"心跳本来就不会开图"这种空断言,
// 而是这条守卫真的把两者分开了 —— 也正是 00:03:53 真机留下的
// game-current-party-frame-op9-send party_id=4 slot_count=1。
//
// 先补上真机那一脚在遭遇战之前做的第三件事:战场账本也被挪到了目标区域
// (town_move.go 的 moveRuntimeFiendWarGroup,门禁严格的那一半要求账本对齐 ——
// 真机上这一步在 op36 落地时就已经跑过了)。
s.endRuntimeFiendWarPlannedEncounter(leader)
if _, err := s.moveRuntimeFiendWarGroup(leader.selectedCharacterID, 6); err != nil {
t.Fatalf("move ledger: %v", err)
}
if err := s.tryRuntimeFiendWarEncounter(leader, f.now.Add(2*time.Second)); err != nil {
t.Fatalf("unprotected heartbeat pass: %v", err)
}
leader.dungeon.mu.Lock()
soloEntered := leader.dungeon.runtime != nil
leader.dungeon.mu.Unlock()
peer.dungeon.mu.Lock()
peerRuntime := peer.dungeon.runtime
peer.dungeon.mu.Unlock()
if !soloEntered {
t.Fatal("反证没成立:牌落下之后心跳应当能自己开图(fixture 太严会让上一条断言失去意义)")
}
if peerRuntime != nil {
t.Fatal("反证没成立:心跳那一趟不该把同伴带走(它算的是目标区域 6 的名单)")
}
}
// 计划那一趟跑完之后必须立刻落牌,否则战况表心跳会被白挡满整个安全网时长。
func TestFiendWarPlannedEncounterMarkIsClearedByTheEntry(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)
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
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": 38, "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)
}
bindDungeonSelectorOriginForTestAt(t, s, peer, 98, 3, 240, 484)
registerPartyPeerEndpointForTest(peer, byte(i+2), uint16(5100+i))
seedPartyOwnerActorProjectionForTest(t, s, peer, peer.selectedCharacterID, 0)
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)
}
b.MemberAreas[f.leader.selectedCharacterID] = 3
b.MemberAreas[f.member.selectedCharacterID] = 3
b.GroupAreas[1] = 3
syncRuntimeFiendWarObjectives(state)
mover := f.leader
mover.conn.(*bufferConn).write.Reset()
f.member.conn.(*bufferConn).write.Reset()
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)
}
mover.townMu.Lock()
handled, err := s.queueRuntimeFiendWarMoveEncounterLocked(mover, request, selfRow, body)
mover.townMu.Unlock()
if err != nil {
t.Fatalf("planned encounter pass: %v", err)
}
if !handled {
t.Fatal("计划那一脚没有被当成遭遇战处理")
}
if s.runtimeFiendWarPlannedEncounterPending(mover, f.now.Add(3*time.Second)) {
t.Fatal("计划进图跑完了牌还立着:战况表心跳会被白挡(安全网 5 秒不是正常路径)")
}
}