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,5 @@
// Package alignedcmd 负责把当前 EXE 已确认协议号一致的 C2S 命令归入业务模块。
//
// 本包只做协议迁移边界判断,不直接读写角色、物品、金币或任务状态。
// 具体业务必须在对应 owner/repository/handler 补齐 EXE 包体证据后再实现。
package alignedcmd
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
package alignedcmd
import (
"strconv"
"longheng.io/server/internal/modules/dnf/jobmap"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// MagicBoxGroupsForCharacter 过滤掉与角色职业/等级不匹配的 `[charactor]` 组。
//
// [booster info] 里一个 `[charactor] \`[swordman]\` 1 99 1 <item> <weight> <count>`
// 就是一组"只发给该职业"的奖励。礼盒一旦按职业分支(例如豪华婚礼化妆箱:鬼剑士
// 拿男鬼剑时装、女鬼剑士拿 at swordman 那组),就**只能发开启者自己那一组**;
// 不过滤的话会把所有职业的时装/道具一起发出去。
//
// Job 为空的分组(普通 [etc]/[stackable]/[avatar] 组)不受影响,原样保留。
func MagicBoxGroupsForCharacter(groups []MagicBoxRewardGroup, character dnfrepo.CharacterRecord) []MagicBoxRewardGroup {
job, err := strconv.Atoi(character.Job)
result := make([]MagicBoxRewardGroup, 0, len(groups))
for _, group := range groups {
if group.Job != "" &&
(err != nil || !jobmap.MatchesQuestTag(group.Job, job) ||
int64(character.Level) < group.MinLevel || int64(character.Level) > group.MaxLevel) {
continue
}
result = append(result, group)
}
return result
}
@@ -0,0 +1,48 @@
package alignedcmd
import (
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func magicBoxJobGroupIDs(groups []MagicBoxRewardGroup) []int64 {
ids := make([]int64, 0, len(groups))
for _, group := range groups {
if len(group.Entries) > 0 {
ids = append(ids, group.Entries[0].ItemID)
}
}
return ids
}
// [booster info]/[charactor] 是按职业(并带等级区间)分组的。
// 只有开启者自己职业那一组才该发 —— 否则一个鬼剑士开豪华婚礼化妆箱,
// 会把全职业的时装/道具一起领走(2026-10-04 真机现场)。
//
// 职业编号见 jobmap:0=鬼剑士[swordman],11=女鬼剑士[at swordman]。
func TestMagicBoxGroupsForCharacterKeepsOnlyTheOpeningJob(t *testing.T) {
groups := []MagicBoxRewardGroup{
{DrawCount: 1, Job: "[swordman]", MinLevel: 1, MaxLevel: 99, Entries: []MagicBoxRewardEntry{{ItemID: 3001, Weight: 100000, Count: 1}}},
{DrawCount: 1, Job: "[at swordman]", MinLevel: 1, MaxLevel: 99, Entries: []MagicBoxRewardEntry{{ItemID: 3011, Weight: 100000, Count: 1}}},
{DrawCount: 2, Entries: []MagicBoxRewardEntry{{ItemID: 3000, Weight: 100000, Count: 2}}},
}
// 鬼剑士:拿 swordman 组 + 无职业门的那组,**不能**拿到女鬼剑士的。
swordman := magicBoxJobGroupIDs(MagicBoxGroupsForCharacter(groups, dnfrepo.CharacterRecord{Job: "0", Level: 90}))
if len(swordman) != 2 || swordman[0] != 3001 || swordman[1] != 3000 {
t.Fatalf("鬼剑士拿到 %v,应为 [3001 3000]", swordman)
}
// 女鬼剑士:只拿 at swordman 组,不能拿到男鬼剑士的。
female := magicBoxJobGroupIDs(MagicBoxGroupsForCharacter(groups, dnfrepo.CharacterRecord{Job: "11", Level: 90}))
if len(female) != 2 || female[0] != 3011 || female[1] != 3000 {
t.Fatalf("女鬼剑士拿到 %v,应为 [3011 3000]", female)
}
// 等级掉出区间:职业组不参与,只剩无职业门的那组。
low := magicBoxJobGroupIDs(MagicBoxGroupsForCharacter(groups, dnfrepo.CharacterRecord{Job: "0", Level: 0}))
if len(low) != 1 || low[0] != 3000 {
t.Fatalf("等级 0 拿到 %v,应只剩 [3000]", low)
}
}
@@ -0,0 +1,62 @@
package alignedcmd
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
// PendingSpec 描述一个已识别但暂不允许回包的命令。
type PendingSpec struct {
Operation string
MinBodyLen int
PendingReason string
}
// PendingHandler 是尚未闭合写包证据的业务域占位 handler。
type PendingHandler struct {
domain dnfenum.AlignedDomain
specs map[dnfenum.CmdPacket]PendingSpec
}
// NewPendingHandler 创建一个只识别请求、不允许成功回包的业务域 handler。
func NewPendingHandler(domain dnfenum.AlignedDomain, specs map[dnfenum.CmdPacket]PendingSpec) PendingHandler {
copied := make(map[dnfenum.CmdPacket]PendingSpec, len(specs))
for op, spec := range specs {
copied[op] = spec
}
return PendingHandler{domain: domain, specs: copied}
}
// Domain 返回业务域。
func (h PendingHandler) Domain() dnfenum.AlignedDomain {
return h.domain
}
// Handle 只做请求识别,禁止 bridge 在证据不足时回成功包。
func (h PendingHandler) Handle(_ context.Context, req Request) (Result, error) {
spec, ok := h.specs[dnfenum.CmdPacket(req.Opcode)]
if !ok {
return Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("%s 模块未登记该 opcode", h.domain),
}, nil
}
if spec.MinBodyLen > 0 && len(req.Body) < spec.MinBodyLen {
return Result{
Handled: true,
ResponseAllowed: false,
Operation: spec.Operation,
Reason: fmt.Sprintf("%s 包体过短:got %d want >= %d", spec.Operation, len(req.Body), spec.MinBodyLen),
}, nil
}
return Result{
Handled: true,
ResponseAllowed: false,
Operation: spec.Operation,
Reason: spec.PendingReason,
}, nil
}
@@ -0,0 +1,46 @@
package alignedcmd
import "longheng.io/server/internal/modules/dnf/dnfenum"
// Action 表示命令进入模块化迁移后的处理决策。
type Action string
const (
ActionPendingModule Action = "pending_module"
ActionBlocked Action = "blocked_by_evidence"
)
// Decision 是 dnfbridge 分流层需要的最小决策结果。
type Decision struct {
Opcode uint16
Domain dnfenum.AlignedDomain
Support dnfenum.AlignedSupport
Action Action
Evidence string
Note string
Reason string
}
// Classify 根据当前 EXE 证据把命令归入模块化队列或阻断旧映射。
func Classify(opcode uint16) (Decision, bool) {
if aligned, ok := dnfenum.LookupAlignedCommand(opcode); ok {
return Decision{
Opcode: opcode,
Domain: aligned.Domain,
Support: aligned.Support,
Action: ActionPendingModule,
Evidence: aligned.Evidence,
Note: aligned.Note,
}, true
}
if blocked, ok := dnfenum.LookupBlockedMigration(opcode); ok {
return Decision{
Opcode: opcode,
Domain: blocked.Domain,
Action: ActionBlocked,
Evidence: blocked.Evidence,
Reason: blocked.Reason,
}, true
}
return Decision{}, false
}
@@ -0,0 +1,122 @@
package alignedcmd
import (
"context"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
func TestClassifyPendingModule(t *testing.T) {
got, ok := Classify(uint16(dnfenum.CmdPacketDeleteItem))
if !ok {
t.Fatalf("Classify(DeleteItem) not found")
}
if got.Action != ActionPendingModule {
t.Fatalf("action = %q, want %q", got.Action, ActionPendingModule)
}
if got.Domain != dnfenum.AlignedDomainInventory {
t.Fatalf("domain = %q, want %q", got.Domain, dnfenum.AlignedDomainInventory)
}
if got.Support != dnfenum.AlignedSupportDirect {
t.Fatalf("support = %q, want %q", got.Support, dnfenum.AlignedSupportDirect)
}
}
func TestClassifyBlocked(t *testing.T) {
got, ok := Classify(uint16(dnfenum.CmdPacketGetItembox))
if !ok {
t.Fatalf("Classify(519) not found")
}
if got.Action != ActionBlocked {
t.Fatalf("action = %q, want %q", got.Action, ActionBlocked)
}
if got.Domain != dnfenum.AlignedDomainPackage {
t.Fatalf("domain = %q, want %q", got.Domain, dnfenum.AlignedDomainPackage)
}
}
func TestRegistryRoutePendingWithoutHandler(t *testing.T) {
registry := DefaultRegistry()
got, ok, err := registry.Route(context.Background(), Request{Opcode: uint16(dnfenum.CmdPacketMailboxOpen), Body: []byte{1, 2, 3}})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok {
t.Fatalf("Route(MailboxOpen) not classified")
}
if !got.Handled {
t.Fatalf("pending command should be handled by router")
}
if got.ResponseAllowed {
t.Fatalf("pending command without handler must not allow response")
}
if got.Decision.Domain != dnfenum.AlignedDomainMail {
t.Fatalf("domain = %q, want %q", got.Decision.Domain, dnfenum.AlignedDomainMail)
}
}
func TestRegistryRouteBlocked(t *testing.T) {
registry := DefaultRegistry()
got, ok, err := registry.Route(context.Background(), Request{Opcode: uint16(dnfenum.CmdPacketGetItembox)})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok {
t.Fatalf("Route(519) not classified")
}
if got.ResponseAllowed {
t.Fatalf("blocked command must not allow response")
}
if got.Decision.Action != ActionBlocked {
t.Fatalf("action = %q, want %q", got.Decision.Action, ActionBlocked)
}
}
func TestRegistryRouteRequiresCurrentCommandDispatcher(t *testing.T) {
registry := DefaultRegistry()
_, ok, err := registry.Route(context.Background(), Request{
Command: byte(dnfenum.GameCmdNotice),
CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketBuySkill),
})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if ok {
t.Fatal("notice dispatcher type 29 was misrouted as a skill command")
}
}
type postActionCloneHandler struct {
actions []PostAction
}
func (postActionCloneHandler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainMail
}
func (h postActionCloneHandler) Handle(context.Context, Request) (Result, error) {
return Result{
Handled: true,
ResponseAllowed: true,
Operation: "post_action_clone",
PostActions: h.actions,
}, nil
}
func TestRegistryRouteClonesPostActions(t *testing.T) {
actions := []PostAction{PostActionRefreshSelectedActorAppearance}
registry := NewRegistry(postActionCloneHandler{actions: actions})
got, ok, err := registry.Route(context.Background(), Request{Opcode: uint16(dnfenum.CmdPacketMailboxOpen)})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok || len(got.PostActions) != 1 || got.PostActions[0] != PostActionRefreshSelectedActorAppearance {
t.Fatalf("post actions = %#v", got.PostActions)
}
actions[0] = "mutated_after_route"
if got.PostActions[0] != PostActionRefreshSelectedActorAppearance {
t.Fatalf("route result retained handler slice alias: %#v", got.PostActions)
}
}
@@ -0,0 +1,38 @@
package alignedcmd
// 抽奖类消耗品(PVF `[stackable type]` = `[monster tarot card]`)的使用契约。
//
// 与 MagicBoxResolution 同族,但有两处必须分开表达的东西:
// - **金币花费**:PVF `[lottery use cost]` 扣的是**金币**(服主 2026-09-29 确认:
// 塔罗牌这次就是 100000)。它的提交必须和道具同笔,所以交给
// Owner.withinCharacterAssetTransaction(金币 + 道具 + 装备三个仓库同一事务),
// 不能走只有背包的 withinInventoryTransaction。
// - **每项数量**:`[int data]` 的三元组是 (item, weight, count),count 可能不是 1
// (真档 10159697 的最后一行是 `10159142 300000 250`),所以中奖项自带 Count。
//
// 抽取在**服务端解析阶段**完成(用服务自身的可注入选择函数,测试可注入确定值),
// 这里只携带**已经抽中的那一项**:Owner 侧因此是纯确定性的,不含随机源。
type TarotLotteryOutcome struct {
Reward MagicBoxRewardItem
Count int64
}
// TarotLotteryResolution is the active-PVF contract for one tarot-card use.
type TarotLotteryResolution struct {
SourceItemID int64
SourcePVFPath string
StackableType string
// GoldCost is PVF `[lottery use cost]` in gold. It is read from the document,
// never hard-coded per item.
GoldCost int64
// Drawn is the winning entry, or nil when the roll landed in the table's gap.
Drawn *TarotLotteryOutcome
// Missed marks a roll that landed in `[total_weight, scale)` -- a gap the
// shipped tables can have (10159697 sums to 999998 out of 1000000). This tree
// treats it as a failure and aborts the whole use (see useTarotLotteryCard):
// 宁可这次不生效,也不能让玩家花了钱拿到空气。这是我们与官服可能不同的一处
// 保守选择 —— 官方"照收钱不给东西"或"整次中止"两种做法都说得通。
Missed bool
Roll int64
TotalWeight int64
}