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,185 @@
package dnfbridge
import (
"encoding/binary"
"os"
"strconv"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
"longheng.io/server/internal/modules/dnf/raid"
)
// 真机 2026-10-02 12:34~12:38(raid 12058625,分片 012)的**原样复刻**:
//
// 04:34:58 char 21 走进 306000460(下侧 BOSS 房)→ bot boss state = 4
// 04:35:02 char 20 走进 306000463(上侧 BOSS 房)→ top = 4,且 bot 已是 4
// ⇒ PVF rule 131/132 触发 boss pattern init ⇒ 两侧各分到一颗球、pattern = 1111
// 04:35:35 起 char 1(上侧队长)与 char 22(下侧队长)各自反复报 kind=1 value=5
// 服务端:rejected 0、dropped 0 —— 但机制**不推进**,球永远是同一颗。
//
// 本用例用**真 PVF 配置**把这段摆出来,然后要求:两队各自打掉**自己手上那颗球**
// 之后,机制条按颗数掉位(1111 → 111 → 11),而且打自己那颗不影响对面。
func TestRealPVFFiendWarRoshanPatternAdvancesFromBothSidesReports(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
service := &Service{gameSessions: map[uint16]*gameSession{}}
service.options.pvfPath = path
config, err := service.runtimeRaidFiendWarConfig()
if err != nil {
t.Fatalf("真 PVF 配置: %v", err)
}
battle, err := raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatalf("战场: %v", err)
}
// 真机顺序:先下侧进 BOSS 房,再上侧。
for _, mapID := range []int{306000460, 306000463} {
if err := battle.Apply(raid.FiendWarEvent{Kind: "on enter map", ID: mapID}, time.Now().UTC()); err != nil {
t.Fatalf("on enter map %d: %v", mapID, err)
}
}
topClone := battle.Value("top boss state")
botClone := battle.Value("bot boss state")
// 客户端 0x9D80A0 那张表:5=宝石/虚无 6=火 7=冰 8=风。
if topClone < 5 || topClone > 8 {
t.Fatalf("上侧 state=%d,want 5..8(两侧都进 BOSS 房之后应当 pattern init 分球)", topClone)
}
if botClone < 5 || botClone > 8 || botClone == topClone {
t.Fatalf("两侧应当拿到两颗**不同**的球:top=%d bot=%d", topClone, botClone)
}
if got := battle.Value("boss pattern state"); got != 1111 {
t.Fatalf("机制条=%d,want 1111", got)
}
// 真机上这两个区域此刻是"被占"的(Enter 成功过)。dnfbridge 侧没有 set,
// 所以直接按战场语义摆好 —— BossReport 的第一道门就是这一条。
battle.Areas[30] = raid.FiendWarAreaState{Group: 1}
battle.Areas[31] = raid.FiendWarAreaState{Group: 2}
const raidKey = uint32(0x00c80440)
topSeat := runtimeRaidDungeonSeat{Group: 1}
botSeat := runtimeRaidDungeonSeat{Group: 2}
members := []runtimeRaidMemberState{
{CharID: 1, Name: "top-leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 20, Name: "top-member", GroupIndex: 2, SlotOrder: 0},
{CharID: 22, Name: "bot-leader", GroupIndex: 3, SlotOrder: 0},
{CharID: 21, Name: "bot-member", GroupIndex: 4, SlotOrder: 0},
}
// 真机:上侧的 run 是 char 20 开的(token=11),下侧是 char 21 开的(token=13);
// 两个队长(char 1 / char 22)都是 adopt 进来的。
topProgress := runtimeRaidDungeonProgress{
Phase: 1, MapID: 4978, Seat: topSeat, CharacterID: 20,
LifecycleToken: 11, Status: runtimeRaidDungeonProgressLoaded,
}
botProgress := runtimeRaidDungeonProgress{
Phase: 1, MapID: 4953, Seat: botSeat, CharacterID: 21,
LifecycleToken: 13, Status: runtimeRaidDungeonProgressLoaded,
}
state := &runtimeRaidState{
RaidKey: raidKey, LeaderID: 1, Started: true, EntryCostPaid: true,
RaidMode: runtimeRaidOwnerFiendWar,
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive, Phase: 1, FiendWar: battle,
GroupOccupancy: map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding{
topSeat: {RaidKey: raidKey, Phase: 1, MapID: 4978, LifecycleToken: 11},
botSeat: {RaidKey: raidKey, Phase: 1, MapID: 4953, LifecycleToken: 13},
},
Dungeons: map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress{
{MapID: 4978, Seat: topSeat}: topProgress,
{MapID: 4953, Seat: botSeat}: botProgress,
},
},
}
service.raids = map[uint32]*runtimeRaidState{raidKey: state}
// 两个队长各带一个队员;队长是 char 1(上)/ char 22(下)。
newLeaderSession := func(charID, memberID uint16, dungeonID int, localToken, runToken uint64) *gameSession {
session := &gameSession{
conn: &bufferConn{},
connID: "roschan-report-" + strconv.Itoa(int(charID)),
channel: channelcatalog.Channel{ID: 184, Type: 42},
selectedCharacterID: charID,
characterGeneration: 1,
}
session.dungeon.runtime = &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: uint32(dungeonID)},
lifecycleToken: localToken,
raidDungeon: runtimeRaidDungeonBinding{
RaidKey: raidKey, Phase: 1, MapID: dungeonID,
LifecycleToken: runToken,
// **真机就是这个形状**:adopt 进来的成员,本地 loadedNotified 是 false
// (notifyRuntimeRaidDungeonLifecycleLocked 对"加入者"跳过 loaded,
// 只有 completed 才在本地留一笔)。
// 真机 12:34~12:38 的两个队长(char 1 / char 22)全都是 false,
// 而 kind=1 机制上报只有他们在发 ⇒ 每一条都被静默丢掉。
loadedNotified: false,
},
partyDungeonOwner: &runtimePartyDungeonOwner{
partyID: uint16(100 + dungeonID%100),
leaderID: charID,
members: []runtimePartyDungeonMember{
{slot: 0, userID: charID, generation: 1},
{slot: 1, userID: memberID, generation: 1},
},
},
}
service.gameSessions[charID] = session
return session
}
topLeader := newLeaderSession(1, 20, 4978, 9, 11)
botLeader := newLeaderSession(22, 21, 4953, 12, 13)
body := func(kind, value int) []byte {
payload := make([]byte, 8)
binary.LittleEndian.PutUint32(payload[:4], uint32(kind))
binary.LittleEndian.PutUint32(payload[4:], uint32(value))
return payload
}
// 客户端 0x9D80A0 那张表:5=宝石/虚无 6=火 7=冰 8=风;球池 boss pattern state
// 的四个十进制位就是这四颗球(1=还在)。真 PVF 的分球是随机的,所以期望值
// 必须按"这一侧实际拿到哪颗球"算出来,不能写死 111/11。
cloneWeight := func(clone int) int { return []int{1000, 100, 10, 1}[clone-5] }
// 上侧先报:只清掉**它自己**那颗球那一位,并给它补一颗新的;下侧一点不动。
if err := service.handleRuntimeFiendWarBoss(topLeader, body(1, topClone)); err != nil {
t.Fatalf("上侧机制上报: %v", err)
}
if got, want := state.Lifecycle.FiendWar.Value("boss pattern state"), 1111-cloneWeight(topClone); got != want {
t.Fatalf("上侧打掉自己那颗球(%d)之后机制条 = %d,want %d", topClone, got, want)
}
if got := state.Lifecycle.FiendWar.Value("bot boss state"); got != botClone {
t.Fatalf("上侧打自己的球却动了对面:bot boss state=%d,want %d", got, botClone)
}
topSecond := state.Lifecycle.FiendWar.Value("top boss state")
if topSecond < 5 || topSecond > 8 || topSecond == botClone {
t.Fatalf("上侧补到的球 = %d,want 5..8 且不等于对面那颗 %d", topSecond, botClone)
}
// 下侧报它手上那颗:池子里只剩上侧没拿的那一颗。
if err := service.handleRuntimeFiendWarBoss(botLeader, body(1, botClone)); err != nil {
t.Fatalf("下侧机制上报: %v", err)
}
wantAfterBot := 1111 - cloneWeight(topClone) - cloneWeight(botClone)
if got := state.Lifecycle.FiendWar.Value("boss pattern state"); got != wantAfterBot {
t.Fatalf("下侧打掉自己那颗球(%d)之后机制条 = %d,want %d\n"+
"(真机 12:35~12:38:两侧都在报 kind=1 value=5,rejected 0 / dropped 0,"+
"但球一直停在第一颗)", botClone, got, wantAfterBot)
}
botSecond := state.Lifecycle.FiendWar.Value("bot boss state")
if botSecond < 5 || botSecond > 8 || botSecond == topSecond {
t.Fatalf("下侧补到的球 = %d,want 5..8 且不等于上侧那颗 %d", botSecond, topSecond)
}
// 现在各自手上那颗也必须能被报掉 —— 这就是"球会切换"。
if err := service.handleRuntimeFiendWarBoss(topLeader, body(1, topSecond)); err != nil {
t.Fatalf("上侧第二颗球的机制上报: %v", err)
}
if got, want := state.Lifecycle.FiendWar.Value("boss pattern state"), wantAfterBot-cloneWeight(topSecond); got != want {
t.Fatalf("上侧第二颗球(%d)之后机制条 = %d,want %d", topSecond, got, want)
}
}