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,153 @@
package dnfbridge
import (
"os"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestStampCurrentDailyDeleteItemOnlyTouchesDailyDeleteItems(t *testing.T) {
ordinary := dnfrepo.ItemStack{ItemID: 3011}
if _, changed := stampCurrentDailyDeleteItem(ordinary, dungeonDropItemDefinition{}, 100); changed {
t.Fatal("a non daily-delete item must not be stamped")
}
stack, changed := stampCurrentDailyDeleteItem(
dnfrepo.ItemStack{ItemID: 490001382}, dungeonDropItemDefinition{DailyDeleteItem: true}, 100)
if !changed {
t.Fatal("a daily-delete item must be stamped on grant")
}
if got := stack.Extra[currentDailyDeleteItemStateKey]; got != "100" {
t.Fatalf("stamp = %q, want %q", got, "100")
}
// 重复盖同一天是幂等的,不该把行标记成脏的。
if _, again := stampCurrentDailyDeleteItem(stack, dungeonDropItemDefinition{DailyDeleteItem: true}, 100); again {
t.Fatal("re-stamping the same service day must be a no-op")
}
}
func TestCurrentDailyDeleteItemDue(t *testing.T) {
stamped := func(day string) dnfrepo.ItemStack {
return dnfrepo.ItemStack{ItemID: 490001382, Extra: map[string]string{currentDailyDeleteItemStateKey: day}}
}
for _, row := range []struct {
name string
stack dnfrepo.ItemStack
serviceDay int64
wantDue bool
wantStamp bool
}{
{name: "同一天不删", stack: stamped("100"), serviceDay: 100, wantDue: false, wantStamp: true},
{name: "跨过边界就删", stack: stamped("100"), serviceDay: 101, wantDue: true, wantStamp: true},
{name: "跨多天也删", stack: stamped("100"), serviceDay: 137, wantDue: true, wantStamp: true},
// 时钟回拨/存档搬迁不该把东西删掉。
{name: "戳在未来不删", stack: stamped("200"), serviceDay: 100, wantDue: false, wantStamp: true},
// 没有戳的是存量:补盖,不是没收。
{name: "没有戳", stack: dnfrepo.ItemStack{ItemID: 490001382}, serviceDay: 100, wantDue: false, wantStamp: false},
// 读不懂的戳同样按存量处理,宁可多留一天也别凭坏数据删东西。
{name: "戳坏了", stack: stamped("not-a-day"), serviceDay: 100, wantDue: false, wantStamp: false},
} {
t.Run(row.name, func(t *testing.T) {
due, hasStamp := currentDailyDeleteItemDue(row.stack, row.serviceDay)
if due != row.wantDue || hasStamp != row.wantStamp {
t.Fatalf("due=%t stamped=%t, want due=%t stamped=%t", due, hasStamp, row.wantDue, row.wantStamp)
}
})
}
}
// 边界必须是北京 06:00,和 premium 的服务日是同一个,否则每日删除会和签到、
// 疲劳重置错开一截。
func TestDailyDeleteUsesTheServiceDayBoundary(t *testing.T) {
// 2026-09-16 05:59 北京 = 前一个服务日;06:01 北京 = 新的服务日。
beijing := time.FixedZone("CST", 8*3600)
before := time.Date(2026, 9, 16, 5, 59, 0, 0, beijing)
after := time.Date(2026, 9, 16, 6, 1, 0, 0, beijing)
if premium.ServiceDayID(before.UTC()) == premium.ServiceDayID(after.UTC()) {
t.Fatal("06:00 Beijing must separate two service days")
}
// 同一天内的两个时刻必须落在同一个服务日。
noon := time.Date(2026, 9, 16, 12, 0, 0, 0, beijing)
if premium.ServiceDayID(after.UTC()) != premium.ServiceDayID(noon.UTC()) {
t.Fatal("06:01 and 12:00 on the same day must share a service day")
}
}
// 发放路径统一在 applyCurrentPVFItemExpirationAt 里盖戳:本服屏蔽了物品期限,
// 每日删除物品大多没有 ExpirationDate,所以那个函数不能在期限为空时提前返回。
func TestApplyCurrentPVFItemExpirationStampsDailyDeleteWithoutAnExpirationDate(t *testing.T) {
now := time.Date(2026, 9, 16, 9, 0, 0, 0, time.UTC)
stack, changed := applyCurrentPVFItemExpirationAt(
dnfrepo.ItemStack{ItemID: 490001382},
dungeonDropItemDefinition{ItemID: 490001382, Kind: dungeonDropItemStackable, DailyDeleteItem: true},
now,
)
if !changed {
t.Fatal("granting a daily-delete item must mark the stack dirty")
}
want := premium.ServiceDayID(now)
if got := stack.Extra[currentDailyDeleteItemStateKey]; got == "" {
t.Fatalf("daily-delete stamp is missing; extra = %v", stack.Extra)
} else if due, _ := currentDailyDeleteItemDue(stack, want); due {
t.Fatal("an item granted today must not already be due")
}
if due, _ := currentDailyDeleteItemDue(stack, want+1); !due {
t.Fatal("the item must be due after the next 06:00 boundary")
}
}
// 普通物品不该因为这条路径多出任何 Extra 键。
func TestApplyCurrentPVFItemExpirationLeavesOrdinaryItemsAlone(t *testing.T) {
stack, _ := applyCurrentPVFItemExpirationAt(
dnfrepo.ItemStack{ItemID: 3011},
dungeonDropItemDefinition{ItemID: 3011, Kind: dungeonDropItemStackable},
time.Date(2026, 9, 16, 9, 0, 0, 0, time.UTC),
)
if _, present := stack.Extra[currentDailyDeleteItemStateKey]; present {
t.Fatalf("ordinary item picked up a daily-delete stamp; extra = %v", stack.Extra)
}
}
// 真 PVF 冒烟:玩家报的那两件必须被解析成每日删除,而且它们身上**没有**期限字段
// —— 这正是"客户端显示 1 天、服务端却毫无察觉"的原因。
func TestRealPVFDailyDeleteItemsAreParsed(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to validate daily-delete parsing")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
for itemID, name := range map[uint32]string{
490001382: "公会祝福礼盒",
490001383: "魔界抗疲劳秘药",
} {
definition, err := catalog.ResolveItem(itemID)
if err != nil {
t.Fatalf("resolve %d (%s): %v", itemID, name, err)
}
if !definition.DailyDeleteItem {
t.Fatalf("%d (%s) must parse as a daily-delete item", itemID, name)
}
// 本服屏蔽了期限字段,所以这两件不该有 ExpirationDate/UsablePeriodDays:
// 每日删除必须独立于它们生效,不能挂在期限那条通道上。
if !definition.ExpirationDate.IsZero() || definition.UsablePeriodDays != 0 {
t.Fatalf("%d (%s) unexpectedly carries an expiry: date=%v days=%d",
itemID, name, definition.ExpirationDate, definition.UsablePeriodDays)
}
}
// 普通消耗品不该被误判。
if definition, err := catalog.ResolveItem(3011); err == nil && definition.DailyDeleteItem {
t.Fatal("an ordinary material must not parse as daily-delete")
}
}