DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user