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,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 秒不是正常路径)")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user