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,143 @@
package dnfbridge
import (
"testing"
"time"
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/statbuff"
)
// 赛丽亚那五件是同一批(10008219 掉落率 / 10008220 强化成功率 / 10008221 城镇移速 /
// 10008222 通关经验 / 10008223 进图属性)。服主 2026-10-03:
// "10008222,10008223,10008219,10008220,10008221 这些道具 也一并做了吧 有几个好像已经做完了"。
//
// 前两件早已完成;这里钉住后三件的分类:
//
// 10008221 → 客户端自施放 family `village_move_speed`(15/30/50)
// 10008222 → 服务端效果 `dungeon_clear_exp_rate`(50/100/200)
// 10008223 → 客户端自施放 family `move_speed`(进图属性)
func TestAlignedStackableUseResolverCoversTheSeriaPotionSet(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "",
"stackable/stackable.lst": "10008221 `cash/town_speed.stk`\n10008222 `cash/clear_exp.stk`\n10008223 `cash/dungeon_status.stk`\n",
"stackable/cash/town_speed.stk": "[name] `seria town`\n[explain] `使用后, 可以获得城镇移动速度提升Buff, 效果持续30分钟。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 1800000 `myself`\n",
"stackable/cash/clear_exp.stk": "[name] `seria exp`\n[explain] `使用后, 可以获得地下城通关经验值增加Buff, 效果持续30分钟。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 1800000 `myself`\n",
"stackable/cash/dungeon_status.stk": "[name] `seria status`\n[explain] `使用后, 角色进入地下城后力量、 智力、 体力、 精神及攻速、 移动速度、 施放速度等属性增加。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 1800000 `myself`\n",
"etc/variousbuffstackablelist.etc": "[index]\n10008221 \n[probability buff list]\n1100 60 1101 30 1102 10 \n[/index]\n" +
"[index]\n10008222 \n[probability buff list]\n1103 30 1104 50 1105 20 \n[/index]\n" +
"[index]\n10008223 \n[probability buff list]\n1106 30 1107 40 1108 30 \n[/index]\n",
"etc/variousbufflist.etc": seriaBuffDefinition(1100, "village_move_speed", 15) +
seriaBuffDefinition(1101, "village_move_speed", 30) +
seriaBuffDefinition(1102, "village_move_speed", 50) +
seriaBuffDefinition(1103, "dungeon_clear_exp_rate", 50) +
seriaBuffDefinition(1104, "dungeon_clear_exp_rate", 100) +
seriaBuffDefinition(1105, "dungeon_clear_exp_rate", 200) +
seriaBuffDefinition(1106, "move_speed", 50) +
seriaBuffDefinition(1107, "move_speed", 100) +
seriaBuffDefinition(1108, "move_speed", 200),
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
service := &Service{pvfItemCatalog: catalog}
resolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
if err != nil || resolver == nil {
t.Fatalf("op44 resolver=%v err=%v", resolver, err)
}
// 城镇移速 / 进图属性:客户端自施放那一族(服务端只注册 buff)。
for _, tc := range []struct {
itemID int64
family string
wantRoll int
}{
{itemID: 10008221, family: "village_move_speed", wantRoll: 3},
{itemID: 10008223, family: "move_speed", wantRoll: 3},
} {
resolution, resolveErr := resolver(tc.itemID)
potion := resolution.StatChangePotion
if resolveErr != nil || potion == nil {
t.Fatalf("item %d resolution=%+v err=%v", tc.itemID, resolution, resolveErr)
}
if potion.ClientBuffFamily != tc.family || len(potion.ClientBuffRolls) != tc.wantRoll {
t.Fatalf("item %d family=%q rolls=%+v want family=%q rolls=%d",
tc.itemID, potion.ClientBuffFamily, potion.ClientBuffRolls, tc.family, tc.wantRoll)
}
if len(potion.BuffRolls) != 0 || len(potion.UpgradeRolls) != 0 || len(potion.DungeonClearExpRolls) != 0 {
t.Fatalf("item %d must only be a client-applied family: %+v", tc.itemID, potion)
}
}
// 通关经验:服务端要自己算的那一族。
resolution, err := resolver(10008222)
if err != nil {
t.Fatal(err)
}
potion := resolution.StatChangePotion
if potion == nil || len(potion.DungeonClearExpRolls) != 3 || potion.ClientBuffFamily != "" {
t.Fatalf("clear-exp potion=%+v", potion)
}
wantRolls := []struct {
buffID int64
weight int64
bonus int64
}{{1103, 30, 50}, {1104, 50, 100}, {1105, 20, 200}}
for i, want := range wantRolls {
got := potion.DungeonClearExpRolls[i]
if got.BuffID != want.buffID || got.Weight != want.weight || got.BonusPercent != want.bonus {
t.Fatalf("clear-exp roll %d=%+v want %+v", i, got, want)
}
}
}
func seriaBuffDefinition(buffID int64, effectName string, param int64) string {
return "[index]\n" + itoaForTest(buffID) + " \n[icon index]\n3000 \n[tooltip text]\n`seria` \n[effect]\n[name]\n`" +
effectName + "` \n[param]\n" + itoaForTest(param) + " \n[/param]\n[/effect]\n[/index]\n"
}
// 通关经验加成:base × percent / 100,窗口过期就是 0。
func TestCurrentStatChangePotionClearExperienceBonus(t *testing.T) {
// 生效中的窗口用真实 API 写(到期 = 现在 + 1 小时)。
withWindow := func(percent int64) dnfrepo.CharacterRecord {
character := dnfrepo.CharacterRecord{CharacterID: "11"}
if _, err := statbuff.ActivateRate(&character, statbuff.KindDungeonClearExp,
10008222, 1105, percent, time.Hour, time.Now().UTC()); err != nil {
t.Fatalf("activate: %v", err)
}
return character
}
// 已过期的窗口只能直接写 Stats:ActivateRate 的语义是"现在开始生效 N 秒",
// 不接受过去的到期时间(这正是 fail-closed 的一半)。
expired := func(percent int64) dnfrepo.CharacterRecord {
return dnfrepo.CharacterRecord{CharacterID: "11", Stats: map[string]int64{
"dungeon_clear_exp_rate_buff_percent": percent,
"dungeon_clear_exp_rate_buff_expire_at": time.Now().Add(-time.Hour).UTC().Unix(),
"dungeon_clear_exp_rate_buff_item_id": 10008222,
"dungeon_clear_exp_rate_buff_id": 1105,
}}
}
for _, tc := range []struct {
name string
character dnfrepo.CharacterRecord
base uint32
want uint32
}{
{name: "no window adds nothing", character: dnfrepo.CharacterRecord{CharacterID: "11"}, base: 1000, want: 0},
{name: "+50% of the base", character: withWindow(50), base: 1000, want: 500},
{name: "+100% of the base", character: withWindow(100), base: 1000, want: 1000},
{name: "+200% of the base", character: withWindow(200), base: 250, want: 500},
{name: "expired window adds nothing", character: expired(200), base: 1000, want: 0},
{name: "zero base stays zero", character: withWindow(200), base: 0, want: 0},
} {
t.Run(tc.name, func(t *testing.T) {
service := &Service{}
if got := service.currentStatChangePotionClearExperienceBonus(tc.character, tc.base); got != tc.want {
t.Fatalf("bonus=%d want=%d", got, tc.want)
}
})
}
}