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

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

161 lines
5.7 KiB
Go

package dnfbridge
import (
"encoding/binary"
"os"
"testing"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// The catalog drives entry permission, so it has to survive the real archive:
// the style list names the layout with mixed case, the layout nests blocks, and
// a stage read wrong silently locks or unlocks the wrong dungeon.
func TestRealPVFGuildDungeonCatalogMatchesTheLiveLayout(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to parse the live guild dungeon layout")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
catalog, err := dnfguild.LoadDungeonCatalog(archive)
if err != nil {
t.Fatal(err)
}
if catalog.FinalDungeon != 8508 {
t.Fatalf("final dungeon=%d want 8508", catalog.FinalDungeon)
}
for _, want := range []struct {
index uint32
gaugeOnClear uint32
ratio uint32
required []uint32
}{
{index: 8500, gaugeOnClear: 150, ratio: 50},
{index: 8504, gaugeOnClear: 75, ratio: 50, required: []uint32{8500}},
{index: 8507, gaugeOnClear: 50, ratio: 50, required: []uint32{8500}},
{index: 8505, gaugeOnClear: 50, ratio: 50, required: []uint32{8504}},
{index: 8503, gaugeOnClear: 75, ratio: 50, required: []uint32{8507}},
{index: 8508, gaugeOnClear: 30, ratio: 100, required: []uint32{8503, 8505}},
} {
stage, found := catalog.Stage(want.index)
if !found {
t.Fatalf("stage %d missing", want.index)
}
if stage.GaugeLimit != 10000 || stage.GaugeOnClear != want.gaugeOnClear ||
stage.ConquestGaugeRatio != want.ratio {
t.Fatalf("stage %d=%+v want clear=%d ratio=%d",
want.index, stage, want.gaugeOnClear, want.ratio)
}
if len(stage.RequiredConquest) != len(want.required) {
t.Fatalf("stage %d prerequisites=%v want %v",
want.index, stage.RequiredConquest, want.required)
}
for i, required := range want.required {
if stage.RequiredConquest[i] != required {
t.Fatalf("stage %d prerequisites=%v want %v",
want.index, stage.RequiredConquest, want.required)
}
}
}
// A guild that has never cleared anything sees exactly 伤城, which is what
// the operator observed when the table was first seeded with empty gauges.
empty := map[uint32]uint32{}
for _, stage := range catalog.Stages {
unlocked := catalog.Unlocked(empty, stage.Index)
if (stage.Index == 8500) != unlocked {
t.Fatalf("with no progress, stage %d unlocked=%t", stage.Index, unlocked)
}
}
// The wire carries clears, not the gauge: the client multiplies by
// [gauge on clear] itself. 9000 of 8500's 10000 is 60 clears of 150, which
// is the 90% the operator asked for; sending 9000 rendered 100% because the
// client read it as nine thousand clears.
if got := currentGuildDungeonClears(mustStage(t, catalog, 8500), 9000); got != 60 {
t.Fatalf("8500 gauge 9000 -> %d clears, want 60", got)
}
if got := currentGuildDungeonClears(mustStage(t, catalog, 8500), 150); got != 1 {
t.Fatalf("8500 gauge 150 -> %d clears, want 1", got)
}
// A gauge past the limit must not render past a full bar.
if got := currentGuildDungeonClears(mustStage(t, catalog, 8500), 99_999); got != 10000/150 {
t.Fatalf("8500 over-credited -> %d clears, want %d", got, 10000/150)
}
// Wire shape: u8 flag + 3 u32 + u32 count + count*(u32,u32).
body := buildCurrentGuildDungeonUnlockBody(catalog, map[uint32]uint32{8500: 9000})
if want := 17 + 8*len(catalog.Stages); len(body) != want {
t.Fatalf("body len=%d want %d", len(body), want)
}
if body[0] != currentGuildDungeonUnlockFlag {
t.Fatalf("flag=%d", body[0])
}
if got := binary.LittleEndian.Uint32(body[13:17]); got != uint32(len(catalog.Stages)) {
t.Fatalf("count=%d want %d", got, len(catalog.Stages))
}
for index, stage := range catalog.Stages {
offset := 17 + 8*index
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != stage.Index {
t.Fatalf("pair %d index=%d want %d", index, got, stage.Index)
}
want := uint32(0)
if stage.Index == 8500 {
want = 60
}
if got := binary.LittleEndian.Uint32(body[offset+4 : offset+8]); got != want {
t.Fatalf("pair %d clears=%d want %d", index, got, want)
}
}
}
func mustStage(t *testing.T, catalog dnfguild.DungeonCatalog, index uint32) dnfguild.DungeonStage {
t.Helper()
stage, found := catalog.Stage(index)
if !found {
t.Fatalf("stage %d missing", index)
}
return stage
}
// The per-stage reward counts differ (3 through 7 公会贡献证书), so a parser that
// dropped the [reward] blocks or shared one block across stages would show up
// here rather than as a silent under-payment in production.
func TestRealPVFGuildDungeonRewardsParsePerStage(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to parse the live guild dungeon rewards")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
catalog, err := dnfguild.LoadDungeonCatalog(archive)
if err != nil {
t.Fatal(err)
}
for _, stage := range catalog.Stages {
if len(stage.Rewards) != 2 {
t.Fatalf("stage %d has %d reward blocks, want 2 (99%% and 100%%)",
stage.Index, len(stage.Rewards))
}
for _, reward := range stage.Rewards {
if reward.GaugeRatio != 99 && reward.GaugeRatio != 100 {
t.Fatalf("stage %d reward ratio=%d", stage.Index, reward.GaugeRatio)
}
// 公会贡献证书; the count is what varies per stage.
if reward.ItemID != 10154439 || reward.ItemCount == 0 || reward.Contribute != 20 {
t.Fatalf("stage %d reward=%+v", stage.Index, reward)
}
}
}
first := mustStage(t, catalog, 8500)
if first.Rewards[0].ItemCount != 3 {
t.Fatalf("8500 reward count=%d want 3", first.Rewards[0].ItemCount)
}
}