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,254 @@
package dnfbridge
import (
"strconv"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// testFiendWarArgs 把一个整数包成规则指令的参数,形状与 PVF 解析出来的 token 一致
// (matches/act 只读 Args[0].Int)。
func testFiendWarArgs(values ...int) []dnfpvf.Token {
tokens := make([]dnfpvf.Token, 0, len(values))
for _, value := range values {
tokens = append(tokens, dnfpvf.Token{Kind: dnfpvf.TokenInt, Raw: strconv.Itoa(value), Int: int64(value)})
}
return tokens
}
// 超时空:走进 **BOSS 那张地图** 才把这一侧的 BOSS 机制推进到"四颗星球出场",而
// 不是走进副本就算。
//
// 真机 2026-10-02(两两进罗什,分片 20261002-006)的形状:四个球一个都不出现、
// 罗什不给打,62 条 op1511 全是 kind=0 value=100(客户端压根没进机制阶段)。
// PVF 的状态机是:
//
// [ON ENTER DUNGEON] 4978 → top boss state = 2 (还没进机制)
// [ON ENTER MAP] 306000463 → top boss state = 5 (四颗星球出场)
// 客户端每打掉一颗报一条 kind=1 → 5→6→7→8 → 9(伤害窗口)→ BOSS 死 → 10
//
// 而服务端只在**副本 finish-loading** 那一次触发 on enter map,那一刻人在
// 0:0 起始房(306000461),于是带过去的是起始地图 ID,什么规则都没命中;
// 玩家随后走进 BOSS 房时没有任何代码再触发它 —— state 永远停在 2。
//
// 本用例用一条合成规则钉住"房间边"这件事:只有走进 306000463 才推进,
// 走起始房不推进、重复走不重复推进。
func TestFiendWarBossRoomEnterMapOpensTheBossPattern(t *testing.T) {
const (
raidKey = uint32(0x00c803e8)
startRoomMapID = 306000461
bossRoomMapID = 306000463
bossStateSymbol = 100
)
config := &raid.FiendWarBattleConfig{
Raid: runtimeRaidBridgeTestAntonConfig(),
WaitingTown: 98,
WaitingArea: 2,
Areas: map[int]raid.FiendWarArea{
2: {Index: 2},
30: {Index: 30, Dungeon: 4978, Kind: "top boss", Symbol: bossStateSymbol},
},
Symbols: map[string]uint32{"top boss state": bossStateSymbol, "boss pattern state": 101},
// 与真 PVF 里 [ON ENTER MAP] 306000463 那一条同义:走进 BOSS 房 → state 5。
Rules: []raid.FiendWarRule{{
Conditions: []raid.FiendWarInstruction{{Op: "on enter map", Args: testFiendWarArgs(bossRoomMapID)}},
Actions: []raid.FiendWarInstruction{{
Op: "set", Symbol: bossStateSymbol, Args: testFiendWarArgs(5),
}},
}},
}
battle, err := raid.NewFiendWarBattle(config)
if err != nil {
t.Fatalf("fixture 战场: %v", err)
}
// 入场值:真机 2026-10-01 实测是 2(还没有任何机制)。
battle.Symbols[bossStateSymbol] = 2
state := &runtimeRaidState{
RaidKey: raidKey,
Started: true,
EntryCostPaid: true,
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: 1,
FiendWar: battle,
},
}
service := &Service{
raids: map[uint32]*runtimeRaidState{raidKey: state},
gameSessions: map[uint16]*gameSession{},
}
session := &gameSession{
conn: &bufferConn{},
connID: "fiendwar-boss-map-enter",
channel: channelcatalog.Channel{ID: 184, Type: 42},
selectedCharacterID: 1,
characterGeneration: 1,
}
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 4978},
raidDungeon: runtimeRaidDungeonBinding{
RaidKey: raidKey, Phase: 1, MapID: 4978, LifecycleToken: 1, loadedNotified: true,
},
}
session.dungeon.runtime = runtime
// 读回状态必须走 state.Lifecycle.FiendWar:切换路径是在**克隆**上跑规则、
// 成功后才替换回去的,本地那个 battle 变量已经不是权威了。
value := func() int { return state.Lifecycle.FiendWar.Value("top boss state") }
// [反例] 起始房:一个字都不该改。真机上 finish-loading 带的就是这一张地图,
// 它不推进机制正是这条缺陷的另一半。
service.notifyRuntimeFiendWarMapEnteredLocked(session, runtime, startRoomMapID, "test")
if got := value(); got != 2 {
t.Fatalf("起始房推进了 BOSS 机制:state=%d,want 2", got)
}
// [本用例的核心] 走进 BOSS 房:必须推到 5 —— 客户端只有看到 5 才放那四颗星球。
service.notifyRuntimeFiendWarMapEnteredLocked(session, runtime, bossRoomMapID, "test")
if got := value(); got != 5 {
t.Fatalf("走进 BOSS 房没有开启机制:top boss state=%d,want 5\n"+
"(真机 2026-10-02 两两进罗什:四个球一个都不出现、罗什不给打)", got)
}
// 幂等:同一次进图里再走一遍不能重复推进(state != 2 那条守卫)。
state.Lifecycle.FiendWar.Symbols[bossStateSymbol] = 3
service.notifyRuntimeFiendWarMapEnteredLocked(session, runtime, bossRoomMapID, "test")
if got := value(); got != 3 {
t.Fatalf("重复进房把已经推进过的状态改了:state=%d,want 3", got)
}
state.Lifecycle.FiendWar.Symbols[bossStateSymbol] = 2
// [反例] 不是超时空频道:一次都不触发。
other := &gameSession{
conn: &bufferConn{},
connID: "fiendwar-boss-map-enter-not-fiendwar",
channel: channelcatalog.Channel{ID: 184, Type: 1},
selectedCharacterID: 1,
characterGeneration: 1,
}
other.dungeon.runtime = runtime
service.notifyRuntimeFiendWarMapEnteredLocked(other, runtime, bossRoomMapID, "test")
if got := value(); got != 2 {
t.Fatalf("非超时空频道推进了 BOSS 机制:state=%d,want 2", got)
}
// [反例] 没有 loadedNotified(还没进副本):不触发。
notLoaded := *runtime
notLoaded.raidDungeon.loadedNotified = false
service.notifyRuntimeFiendWarMapEnteredLocked(session, &notLoaded, bossRoomMapID, "test")
if got := value(); got != 2 {
t.Fatalf("没进副本就推进了 BOSS 机制:state=%d,want 2", got)
}
// [反例] 别的副本(不在任何 BOSS 区域名下):不触发。
otherDungeon := *runtime
otherDungeon.Request = dungeoncmd.SelectDungeonRequest{DungeonID: 4955}
service.notifyRuntimeFiendWarMapEnteredLocked(session, &otherDungeon, bossRoomMapID, "test")
if got := value(); got != 2 {
t.Fatalf("不相干的副本推进了 BOSS 机制:state=%d,want 2", got)
}
}
// 超时空:**队长**按下撤退(无包体 op42)时,整支临时队伍一起回城;队员自己按不算。
//
// 操作员 2026-10-02 定案;真机 10:29:10 只有队长一个人回了城(同队的 char 21 留在
// 格里,23 秒后连点 op72 被 settlement_result_not_ready 拒了 119 次)。
func TestFiendWarLeaderRetreatPullsTheWholeTemporaryParty(t *testing.T) {
service := &Service{gameSessions: map[uint16]*gameSession{}}
owner := &runtimePartyDungeonOwner{
partyID: 22,
leaderID: 1,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 1},
{slot: 1, userID: 21},
},
}
runtime := &runtimeDungeonState{
raidDungeon: runtimeRaidDungeonBinding{RaidKey: 12058625, Phase: 1, MapID: 4978, LifecycleToken: 20},
partyDungeonOwner: owner,
}
fiendWar := &gameSession{
conn: &bufferConn{},
channel: channelcatalog.Channel{ID: 184, Type: 42},
selectedCharacterID: 1,
}
fiendWar.dungeon.runtime = runtime
// gameSession 里带锁:不能靠拷贝造变体(go vet 会拦),每条都用新会话。
newSession := func(channelType byte, charID uint16) *gameSession {
session := &gameSession{
conn: &bufferConn{},
connID: "fiendwar-retreat-" + strconv.Itoa(int(charID)),
channel: channelcatalog.Channel{ID: 184, Type: channelType},
selectedCharacterID: charID,
}
session.dungeon.runtime = runtime
return session
}
// 队长:拉全队,名单就是这支临时队伍。
plan := service.runtimeFiendWarPartyRetreatPlanFor(fiendWar, runtime)
if !plan.armed {
t.Fatal("队长的撤退没有拉全队(真机 10:29:10 就是这一脚,只有他一个人回了城)")
}
if plan.leader != 1 || plan.partyID != 22 || plan.raidKey != 12058625 {
t.Fatalf("撤退计划抓错了人/队:%+v", plan)
}
if len(plan.members) != 2 || plan.members[1] != 21 {
t.Fatalf("撤退计划的成员名单不对:%+v", plan.members)
}
// 队员:自己不带队,走原来的单人退出通道。
if got := service.runtimeFiendWarPartyRetreatPlanFor(newSession(42, 21), runtime); got.armed {
t.Fatalf("队员自己按撤退把全队拉走了:%+v", got)
}
// 非超时空:一个字不改(安东/卢克)。
if got := service.runtimeFiendWarPartyRetreatPlanFor(newSession(1, 1), runtime); got.armed {
t.Fatalf("非超时空也拉全队了:%+v", got)
}
// 没有临时队伍(单人进图):没人可拉。
soloRuntime := &runtimeDungeonState{raidDungeon: runtime.raidDungeon}
solo := newSession(42, 1)
solo.dungeon.runtime = soloRuntime
if got := service.runtimeFiendWarPartyRetreatPlanFor(solo, soloRuntime); got.armed {
t.Fatalf("没有临时队伍却立了撤退计划:%+v", got)
}
// 队员离线时那一趟是个空操作,不许崩、也不许发任何东西。
service.runRuntimeFiendWarPartyRetreat(plan, "test")
// 未立计划时同样是空操作。
service.runRuntimeFiendWarPartyRetreat(runtimeFiendWarPartyRetreatPlan{}, "test")
}
// dungeonRuntimeOwnsCharacter 的语义在本用例里被间接依赖:撤退扇出只带"这一份
// runtime 真正属于他"的成员,免得把别人的副本算成他的。这条把它钉住,
// 免得将来"谁的副本"变了而撤退带错人。
func TestFiendWarPartyRetreatOwnershipPredicate(t *testing.T) {
runtime := &runtimeDungeonState{
raidDungeon: runtimeRaidDungeonBinding{RaidKey: 1},
}
if dungeonRuntimeOwnsCharacter(runtime, 1) {
t.Fatal("没有角色绑定的 runtime 不该认领任何人")
}
runtime.Character = dnfrepo.CharacterRecord{CharacterID: "1"}
if !dungeonRuntimeOwnsCharacter(runtime, 1) {
t.Fatal("runtime 绑定的角色应当被认领")
}
if dungeonRuntimeOwnsCharacter(runtime, 2) {
t.Fatal("别人的角色不该被这份 runtime 认领")
}
runtime.partyDungeonOwner = &runtimePartyDungeonOwner{leaderID: 1}
if !dungeonRuntimeOwnsCharacter(runtime, 1) {
t.Fatal("有队伍时队长本人仍应被认领")
}
_ = worldmap.DungeonRunActive
_ = time.Second
}